mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat(workflows): add workflow editor (#6248)
**Category:** new-feature **User Impact:** Users can create, edit, duplicate, and deep-link to workflows in a responsive visual editor without losing unsupported YAML or unsaved work. **Problem:** Workflow editing was split across disconnected surfaces and lacked reliable URL state, lifecycle protection, and parity between Form and YAML modes. **Solution:** This adds a route-addressable editor foundation with stable pane identity, guarded dirty exits, lossless Form/YAML transitions, responsive workflow and channel controls, and matching reaction-filter execution support. <details> <summary>File changes</summary> **crates/buzz-workflow/src/lib.rs** Apply reaction trigger filters during workflow execution and cover target-message gating. **crates/buzz-workflow/src/schema.rs** Extend the reaction trigger schema with the editor-owned filter field. **desktop/src/app/navigation/useAppNavigation.ts** Add navigation helpers for explicit workflow create, edit, and duplicate editor modes. **desktop/src/app/routes/WorkflowsRouteScreen.tsx** Coordinate route state with the shared workflow library and editor dialog. **desktop/src/app/routes/lazyWorkflowsRouteScreen.ts** Share one lazy route component across workflow route entry points to avoid loading flashes. **desktop/src/app/routes/workflows.$workflowId.tsx** Parse workflow editor modes and pane deep links for workflow-specific URLs. **desktop/src/app/routes/workflows.tsx** Parse library-level create state and render the shared workflow route screen. **desktop/src/app/AppWorkflowEditorOverlayProvider.tsx** Host the shared workflow editor at the app-shell level so channel-originated workflow dialogs stay above the active channel instead of replacing it. **desktop/src/shared/context/WorkflowEditorOverlayContext.tsx** Expose route-independent open-existing and create-new workflow actions to channel settings. **desktop/src/features/workflows/ui/WorkflowEditorHost.tsx** Share editor loading, unavailable, and dialog lifecycle wiring between canonical workflow routes and the channel overlay. **desktop/src/features/channels/ui/ChannelManagementSheet.tsx** Add an experiment-gated, Canvas-style Workflows ingress below Canvas, including channel-scoped loading, error, empty, and list states plus open/create actions; disabled users issue no workflow query. **desktop/src/features/channels/ui/ChannelWorkflowsSection.tsx** Render the channel workflow list and New workflow action without pushing the existing settings sheet past its file-size ceiling. **desktop/src/features/workflows/ui/ChannelCombobox.tsx** Adopt the final channel presentation, portalled scrolling, and one-shot create-flow opening behavior. **desktop/src/features/workflows/ui/CreateWorkflowDialog.tsx** Remove the superseded create-only dialog in favor of the unified workflow editor. **desktop/src/features/workflows/ui/WorkflowCard.tsx** Open workflow cards in the detail and run-history modal while preserving explicit edit and duplicate actions. **desktop/src/features/workflows/ui/WorkflowDetailDialog.tsx** Present workflow Trigger/Steps in the shared modal chrome, with top-chrome ingress to a responsive right-side run-history inspector and an explicit edit action. **desktop/src/features/workflows/ui/WorkflowDialog.tsx** Unify create, edit, and duplicate lifecycle handling with URL panes, generated-name synchronization, dirty-exit guards, stale-write preservation, and protected webhook-secret handoff. **desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx** Build the responsive Form/YAML editor shell, stable step selection, insertion and removal behavior, and lossless canonical-YAML synchronization. **desktop/src/features/workflows/ui/WorkflowStepCard.tsx** Align workflow step controls and presentation with the final editor interaction model. **desktop/src/features/workflows/ui/WorkflowUnavailableDialog.tsx** Show a non-disclosing loading or unavailable state for missing and inaccessible workflow links, with retry and close actions. **desktop/src/features/workflows/ui/WorkflowWebhookSecretDialog.tsx** Obscure one-time webhook secrets by default and require explicit confirmation before any close or navigation discards them. **desktop/src/features/workflows/ui/WorkflowsScreen.tsx** Connect library state and workflow actions to the route-addressable editor. **desktop/src/features/workflows/ui/WorkflowsView.tsx** Restore the responsive workflow library, create tile, cards, loading states, and shared action menu. **desktop/src/features/workflows/ui/workflowEditorPane.test.mjs** Cover pane parsing, serialization, and stable step-ID reconciliation. **desktop/src/features/workflows/ui/workflowEditorPane.ts** Define explicit trigger and stable step pane URL state. **desktop/src/features/workflows/ui/workflowYamlDocument.ts** Read and update header fields independently of full form validation so incomplete steps cannot clear or disable the workflow title. **desktop/src/features/workflows/ui/workflowYamlDocument.test.mjs** Cover document-level workflow header reads and writes for incomplete definitions. **desktop/tests/e2e/workflow-title-stability.spec.ts** Verify generated, renamed, saved, and duplicated titles remain stable while moving between trigger and step panes. **desktop/src/features/workflows/ui/workflowFormTypes.test.mjs** Cover lossless Form/YAML round trips and actionable fallback for unsupported fields. **desktop/src/features/workflows/ui/workflowFormTypes.ts** Own canonical workflow YAML conversion while preserving supported trigger and step fields. **desktop/src/shared/ui/PortalledScrollArea.tsx** Provide bounded scrolling for popovers rendered outside their dialog container. **desktop/src/shared/ui/popover.tsx** Allow workflow popovers to use the shared portalled scroll container. **desktop/tests/e2e/channels.spec.ts** Cover disabled and enabled Workflows experiment states, including suppressed queries while disabled, placement beneath Canvas, channel workflow listing and opening, channel-preselected workflow creation, and direct return to the channel Workflows panel after close, discard, or cancel. **desktop/tests/e2e/workflows.spec.ts** Exercise library actions, deep links, create/edit/duplicate lifecycle, dirty exits, responsive editor behavior, YAML safety, stale updates, and one-shot channel selection. </details> ## Reproduction steps 1. Open **Workflows** and confirm the responsive card library, create tile, card action menu, and card-to-detail/run-history modal navigation. 2. Open `?view=create`; confirm the channel chooser opens once, the trigger inspector stays hidden until a channel is selected, and closing the chooser does not make it reopen after unrelated edits. 3. Create a workflow, switch between Form and YAML, add and remove steps, refresh a pane deep link, and confirm the selected trigger or stable step remains addressable. 4. Edit or duplicate a workflow, make an unsaved change, and confirm close, Escape, browser navigation, and route target changes require discard confirmation while pane-only navigation does not. 5. Enter unsupported YAML and confirm Form mode gives an actionable fallback without rewriting the definition; verify reaction triggers preserve and execute their filter. 6. Open a channel’s settings, select **Workflows** below **Canvas**, and open or create a workflow; confirm the shared modal stays over the channel, the channel URL does not change, New workflow preselects that channel, and closing or discarding returns directly to the channel’s **Workflows** panel. 7. In create, edit, and duplicate modes, move between the trigger and incomplete step panes and confirm the generated or edited title remains visible and editable. ## Screenshots Fresh captures from product head `c5c3abc91a71fe511d43e6cc9168b1626d0c217c`; the later review-guidance fix does not alter these pictured states. ### Workflow library and actions  ### Workflow editor — wide  ### Workflow editor — narrow inspector overlay  ### Workflow editor — active channel overlay  ## Review feedback addressed - Corrected Diff Posted condition guidance to use the executor-supported `str_contains(trigger_text, "deploy")` syntax and added a visible Playwright regression assertion. - Scoped direct workflow-detail navigation coverage to the stable `Edit workflow` dialog name, asserted the workflow title separately, and retained the trigger-node assertion. - Routed dirty channel-overlay Duplicate/Edit transitions through the existing discard confirmation, preserving the original YAML draft when the user keeps editing. - Made workflow deletion await relay success, remain non-dismissible while pending, and retain the confirmation/editor/draft with an actionable inline error on rejection. - Gated the channel-settings Workflows ingress and channel workflow query behind the `workflows` experiment, with defensive rendering if the flag changes while that view is active. - Matched the Reaction Added trigger to the prototype reaction picker, preserving native/custom/legacy values in canonical YAML and providing an explicit clear action. - Kept a channel’s Workflows panel mounted beneath channel-origin editors so clean close, dirty discard, and create cancel return directly to that panel without changing the channel URL. ## Verification Verified at exact pushed head `76ebba7b7ace1e13445744200da70ca9da231b7e`: - Push hooks passed: destination-org policy, branch-skew, differential file-size, Desktop checks, TypeScript typecheck, and **5,115/5,115** Desktop unit tests - Focused channel lifecycle E2E passed: dirty edit discard returns directly to the channel Workflows panel; create cancel does the same while preserving the channel URL and preselected channel - E2E production build, standalone TypeScript typecheck, and Biome checks on both touched files passed - Reaction-picker regression spec remains recorded at `dc28ffa98ec34b4e0656757fb2dd93e60a84674d`: **5/5 passed** (picker interaction, canonical YAML persistence/clear, legacy-value preservation, save/reopen round trip, narrow viewport containment) - Blox existing workflow E2E regression set at `dc28ffa98ec34b4e0656757fb2dd93e60a84674d`: **36/36 passed** - Earlier blocker and review regressions remain recorded at `3760c3d657a525f5af98e8d0f98bdd03999d8e61`: **5,114/5,114** Desktop unit tests and the dirty-overlay/deletion focused checks - Working tree clean; local branch, remote branch, and PR head all match the exact SHA above ### Related issue None found. Closest prior work: #231. --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -887,6 +887,7 @@ async fn should_fire_workflow(
|
||||
) -> bool {
|
||||
if let TriggerDef::ReactionAdded {
|
||||
emoji: Some(ref expected),
|
||||
..
|
||||
} = def.trigger
|
||||
{
|
||||
if &trigger_ctx.emoji != expected {
|
||||
@@ -900,33 +901,13 @@ async fn should_fire_workflow(
|
||||
}
|
||||
}
|
||||
|
||||
if let TriggerDef::MessagePosted {
|
||||
filter: Some(ref expr),
|
||||
} = def.trigger
|
||||
{
|
||||
match executor::evaluate_condition(expr, trigger_ctx, &HashMap::new()).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
workflow_id = %workflow_id,
|
||||
"Trigger filter evaluated false — skipping workflow"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
workflow_id = %workflow_id,
|
||||
"Trigger filter error: {e} — skipping workflow"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let TriggerDef::DiffPosted {
|
||||
filter: Some(ref expr),
|
||||
} = def.trigger
|
||||
{
|
||||
let filter = match &def.trigger {
|
||||
TriggerDef::MessagePosted { filter }
|
||||
| TriggerDef::ReactionAdded { filter, .. }
|
||||
| TriggerDef::DiffPosted { filter } => filter.as_ref(),
|
||||
TriggerDef::Schedule { .. } | TriggerDef::Webhook => None,
|
||||
};
|
||||
if let Some(expr) = filter {
|
||||
match executor::evaluate_condition(expr, trigger_ctx, &HashMap::new()).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
@@ -1364,7 +1345,10 @@ steps:
|
||||
|
||||
#[test]
|
||||
fn trigger_matches_reaction() {
|
||||
let trigger = TriggerDef::ReactionAdded { emoji: None };
|
||||
let trigger = TriggerDef::ReactionAdded {
|
||||
emoji: None,
|
||||
filter: None,
|
||||
};
|
||||
assert!(trigger_matches_event(
|
||||
&trigger,
|
||||
buzz_core::kind::KIND_REACTION
|
||||
@@ -1375,6 +1359,36 @@ steps:
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reaction_filter_matches_target_message() {
|
||||
let yaml = r#"
|
||||
name: "React to one message"
|
||||
trigger:
|
||||
on: reaction_added
|
||||
filter: 'trigger_message_id == "target-message"'
|
||||
steps:
|
||||
- id: wait
|
||||
action: delay
|
||||
duration: 1s
|
||||
"#;
|
||||
let (def, _) = WorkflowEngine::parse_yaml(yaml).expect("parse failed");
|
||||
let mut trigger_ctx = executor::TriggerContext {
|
||||
message_id: "target-message".to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(
|
||||
should_fire_workflow(&def, &trigger_ctx, Uuid::new_v4()).await,
|
||||
"reaction to the selected message should fire"
|
||||
);
|
||||
|
||||
trigger_ctx.message_id = "different-message".to_owned();
|
||||
assert!(
|
||||
!should_fire_workflow(&def, &trigger_ctx, Uuid::new_v4()).await,
|
||||
"reaction to a different message should be filtered out"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_trigger_never_matches_events() {
|
||||
let trigger = TriggerDef::Schedule {
|
||||
@@ -1421,7 +1435,10 @@ steps:
|
||||
|
||||
#[test]
|
||||
fn reaction_added_matches_kind_7_only() {
|
||||
let trigger = TriggerDef::ReactionAdded { emoji: None };
|
||||
let trigger = TriggerDef::ReactionAdded {
|
||||
emoji: None,
|
||||
filter: None,
|
||||
};
|
||||
// Must match KIND_REACTION = 7.
|
||||
assert!(trigger_matches_event(&trigger, 7));
|
||||
// Must NOT match stream message (kind 9).
|
||||
@@ -1436,6 +1453,7 @@ steps:
|
||||
// trigger_matches_event only checks the kind number.
|
||||
let trigger = TriggerDef::ReactionAdded {
|
||||
emoji: Some("thumbsup".to_owned()),
|
||||
filter: None,
|
||||
};
|
||||
assert!(trigger_matches_event(&trigger, 7));
|
||||
assert!(!trigger_matches_event(&trigger, 9));
|
||||
@@ -1458,7 +1476,10 @@ steps:
|
||||
// before calling trigger_matches_event, but verify the function itself
|
||||
// also returns false for these kinds.
|
||||
let msg_trigger = TriggerDef::MessagePosted { filter: None };
|
||||
let react_trigger = TriggerDef::ReactionAdded { emoji: None };
|
||||
let react_trigger = TriggerDef::ReactionAdded {
|
||||
emoji: None,
|
||||
filter: None,
|
||||
};
|
||||
|
||||
for kind in buzz_core::kind::KIND_WORKFLOW_TRIGGERED
|
||||
..=buzz_core::kind::KIND_WORKFLOW_APPROVAL_DENIED
|
||||
@@ -1478,7 +1499,10 @@ steps:
|
||||
fn trigger_matches_event_kind_zero_matches_nothing() {
|
||||
// Kind 0 is a profile event — no trigger should match it.
|
||||
let msg_trigger = TriggerDef::MessagePosted { filter: None };
|
||||
let react_trigger = TriggerDef::ReactionAdded { emoji: None };
|
||||
let react_trigger = TriggerDef::ReactionAdded {
|
||||
emoji: None,
|
||||
filter: None,
|
||||
};
|
||||
let sched_trigger = TriggerDef::Schedule {
|
||||
cron: None,
|
||||
interval: Some("1h".to_owned()),
|
||||
@@ -1715,7 +1739,11 @@ steps:
|
||||
async fn setup_db() -> buzz_db::Db {
|
||||
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned());
|
||||
// Local-only test default; this is not a production credential.
|
||||
.unwrap_or_else(|_| {
|
||||
let local_test_database = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
|
||||
local_test_database.to_owned()
|
||||
});
|
||||
buzz_db::Db::new(&buzz_db::DbConfig {
|
||||
database_url,
|
||||
..Default::default()
|
||||
|
||||
@@ -47,6 +47,9 @@ pub enum TriggerDef {
|
||||
/// Optional: only fire for this specific emoji.
|
||||
#[serde(default)]
|
||||
emoji: Option<String>,
|
||||
/// Optional evalexpr filter over the reaction context.
|
||||
#[serde(default)]
|
||||
filter: Option<String>,
|
||||
},
|
||||
/// Fires when a diff message (kind:40008) is posted in the workflow's channel.
|
||||
DiffPosted {
|
||||
@@ -300,11 +303,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_reaction_added_trigger() {
|
||||
let yaml = "name: Triage\ntrigger:\n on: reaction_added\n emoji: clipboard\nsteps:\n - id: ack\n action: add_reaction\n emoji: eyes\n";
|
||||
let yaml = "name: Triage\ntrigger:\n on: reaction_added\n emoji: clipboard\n filter: 'trigger_message_id == \"abc123\"'\nsteps:\n - id: ack\n action: add_reaction\n emoji: eyes\n";
|
||||
let (def, _) = parse_yaml(yaml).expect("parse failed");
|
||||
match &def.trigger {
|
||||
TriggerDef::ReactionAdded { emoji } => {
|
||||
TriggerDef::ReactionAdded { emoji, filter } => {
|
||||
assert_eq!(emoji.as_deref(), Some("clipboard"));
|
||||
assert_eq!(filter.as_deref(), Some("trigger_message_id == \"abc123\""));
|
||||
}
|
||||
other => panic!("unexpected trigger: {other:?}"),
|
||||
}
|
||||
@@ -488,8 +492,9 @@ mod tests {
|
||||
let yaml = "name: Any Reaction\ntrigger:\n on: reaction_added\nsteps:\n - id: s1\n action: add_reaction\n emoji: eyes\n";
|
||||
let (def, _) = parse_yaml(yaml).expect("parse failed");
|
||||
match &def.trigger {
|
||||
TriggerDef::ReactionAdded { emoji } => {
|
||||
TriggerDef::ReactionAdded { emoji, filter } => {
|
||||
assert!(emoji.is_none(), "emoji should default to None");
|
||||
assert!(filter.is_none(), "filter should default to None");
|
||||
}
|
||||
other => panic!("unexpected trigger: {other:?}"),
|
||||
}
|
||||
|
||||
@@ -73,6 +73,9 @@ export default defineConfig({
|
||||
"**/relay-reconnect.spec.ts",
|
||||
"**/relay-reconnect-affordance.spec.ts",
|
||||
"**/workflows.spec.ts",
|
||||
"**/workflow-reaction-picker.spec.ts",
|
||||
"**/workflow-local-controls.spec.ts",
|
||||
"**/workflow-title-stability.spec.ts",
|
||||
"**/identity-archive.spec.ts",
|
||||
"**/identity-archive-hide.spec.ts",
|
||||
"**/relay-connectivity.spec.ts",
|
||||
|
||||
@@ -101,6 +101,7 @@ import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay";
|
||||
import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
|
||||
import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu";
|
||||
import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider";
|
||||
import { AppWorkflowEditorOverlayProvider } from "@/app/AppWorkflowEditorOverlayProvider";
|
||||
import { LazySettingsScreen } from "@/app/LazySettingsScreen";
|
||||
const EMPTY_CHANNELS: Channel[] = [];
|
||||
export function AppShell() {
|
||||
@@ -764,216 +765,222 @@ export function AppShell() {
|
||||
data-testid="app-sidebar-layer"
|
||||
>
|
||||
<AppProfilePanelProvider>
|
||||
{!settingsOpen && !isHuddleRoom ? (
|
||||
<AppTopChrome
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
hasCommunityRail={hasCommunityRail}
|
||||
onGoBack={goBack}
|
||||
onGoForward={goForward}
|
||||
/>
|
||||
) : null}
|
||||
{settingsOpen ? (
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<React.Suspense fallback={null}>
|
||||
<LazySettingsScreen
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
isUpdatingDesktopNotifications={
|
||||
notificationSettings.isUpdatingDesktopEnabled
|
||||
}
|
||||
notificationErrorMessage={
|
||||
notificationSettings.errorMessage
|
||||
}
|
||||
notificationPermission={notificationSettings.permission}
|
||||
notificationSettings={notificationSettings.settings}
|
||||
onClose={handleCloseSettings}
|
||||
onSectionChange={handleSettingsSectionChange}
|
||||
onSetDesktopNotificationsEnabled={
|
||||
notificationSettings.setDesktopEnabled
|
||||
}
|
||||
onSetHomeBadgeEnabled={
|
||||
notificationSettings.setHomeBadgeEnabled
|
||||
}
|
||||
onSetSlotAlertsEnabled={
|
||||
notificationSettings.setSlotAlertsEnabled
|
||||
}
|
||||
onSetNotifyWhileViewing={
|
||||
notificationSettings.setNotifyWhileViewing
|
||||
}
|
||||
onSetAllSlotAlertsEnabled={
|
||||
notificationSettings.setAllSlotAlertsEnabled
|
||||
}
|
||||
onSetSoundForSlot={notificationSettings.setSoundForSlot}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex min-h-0 flex-1 overflow-visible">
|
||||
{!isHuddleRoom ? (
|
||||
<AppSidebar
|
||||
activeCommunity={communitiesHook.activeCommunity}
|
||||
channels={sidebarChannels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={channelsErrorMessage}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
homeBadgeCount={homeBadgeCount + dueReminderBadge}
|
||||
addCommunityPrefill={addCommunityDialog.prefill}
|
||||
isAddCommunityOpen={addCommunityDialog.open}
|
||||
relayConnectionCard={relayConnectionCard}
|
||||
isCreatingChannel={createChannelMutation.isPending}
|
||||
isCreatingForum={createForumMutation.isPending}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
isCreateChannelOpen={isCreateChannelOpen}
|
||||
isHuddleCompanionOpen={isHuddleCompanionOpen}
|
||||
isPresencePending={presenceSession.isPending}
|
||||
onAddCommunity={(community) => {
|
||||
const id = communitiesHook.addCommunity({
|
||||
...community,
|
||||
pubkey:
|
||||
community.pubkey ?? identityQuery.data?.pubkey,
|
||||
});
|
||||
handleSwitchCommunity(id);
|
||||
}}
|
||||
onAddCommunityOpenChange={
|
||||
addCommunityDialog.onOpenChange
|
||||
}
|
||||
onNewMessage={goNewMessage}
|
||||
onBackgroundClick={requestFocusedThreadClose}
|
||||
onCreateChannelOpenChange={setIsCreateChannelOpen}
|
||||
onOpenAddCommunity={addCommunityDialog.openDialog}
|
||||
onSendFeedback={() => setIsSendFeedbackOpen(true)}
|
||||
onUpdateCommunity={communitiesHook.updateCommunity}
|
||||
onRemoveCommunity={handleRemoveCommunity}
|
||||
onSwitchCommunity={handleSwitchCommunity}
|
||||
onCreateAgent={() => requestOpenCreateAgent()}
|
||||
selfPresenceStatus={presenceSession.currentStatus}
|
||||
communities={communitiesHook.communities}
|
||||
onCreateChannel={handleCreateChannel}
|
||||
onCreateForum={handleCreateForum}
|
||||
onHideDm={handleHideDm}
|
||||
onHuddleEnded={handleHuddleEnded}
|
||||
onMarkAllChannelsRead={markAllChannelsRead}
|
||||
onMarkChannelRead={markChannelRead}
|
||||
onMarkChannelUnread={markChannelUnread}
|
||||
onBrowseChannels={handleOpenBrowseChannels}
|
||||
onOpenDm={async ({ pubkeys }) => {
|
||||
const directMessage =
|
||||
await openDmMutation.mutateAsync({
|
||||
pubkeys,
|
||||
<AppWorkflowEditorOverlayProvider>
|
||||
{!settingsOpen && !isHuddleRoom ? (
|
||||
<AppTopChrome
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
hasCommunityRail={hasCommunityRail}
|
||||
onGoBack={goBack}
|
||||
onGoForward={goForward}
|
||||
/>
|
||||
) : null}
|
||||
{settingsOpen ? (
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<React.Suspense fallback={null}>
|
||||
<LazySettingsScreen
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
isUpdatingDesktopNotifications={
|
||||
notificationSettings.isUpdatingDesktopEnabled
|
||||
}
|
||||
notificationErrorMessage={
|
||||
notificationSettings.errorMessage
|
||||
}
|
||||
notificationPermission={
|
||||
notificationSettings.permission
|
||||
}
|
||||
notificationSettings={notificationSettings.settings}
|
||||
onClose={handleCloseSettings}
|
||||
onSectionChange={handleSettingsSectionChange}
|
||||
onSetDesktopNotificationsEnabled={
|
||||
notificationSettings.setDesktopEnabled
|
||||
}
|
||||
onSetHomeBadgeEnabled={
|
||||
notificationSettings.setHomeBadgeEnabled
|
||||
}
|
||||
onSetSlotAlertsEnabled={
|
||||
notificationSettings.setSlotAlertsEnabled
|
||||
}
|
||||
onSetNotifyWhileViewing={
|
||||
notificationSettings.setNotifyWhileViewing
|
||||
}
|
||||
onSetAllSlotAlertsEnabled={
|
||||
notificationSettings.setAllSlotAlertsEnabled
|
||||
}
|
||||
onSetSoundForSlot={
|
||||
notificationSettings.setSoundForSlot
|
||||
}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex min-h-0 flex-1 overflow-visible">
|
||||
{!isHuddleRoom ? (
|
||||
<AppSidebar
|
||||
activeCommunity={communitiesHook.activeCommunity}
|
||||
channels={sidebarChannels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={channelsErrorMessage}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
homeBadgeCount={homeBadgeCount + dueReminderBadge}
|
||||
addCommunityPrefill={addCommunityDialog.prefill}
|
||||
isAddCommunityOpen={addCommunityDialog.open}
|
||||
relayConnectionCard={relayConnectionCard}
|
||||
isCreatingChannel={createChannelMutation.isPending}
|
||||
isCreatingForum={createForumMutation.isPending}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
isCreateChannelOpen={isCreateChannelOpen}
|
||||
isHuddleCompanionOpen={isHuddleCompanionOpen}
|
||||
isPresencePending={presenceSession.isPending}
|
||||
onAddCommunity={(community) => {
|
||||
const id = communitiesHook.addCommunity({
|
||||
...community,
|
||||
pubkey:
|
||||
community.pubkey ?? identityQuery.data?.pubkey,
|
||||
});
|
||||
await goChannel(directMessage.id);
|
||||
}}
|
||||
onSelectAgents={() => void goAgents()}
|
||||
onSelectChannel={handleSidebarChannelSelect}
|
||||
onOpenSearchResult={handleOpenSearchResult}
|
||||
searchChannels={channels}
|
||||
searchFocusRequests={[
|
||||
searchFocusRequest,
|
||||
scopeSearchFocusRequest,
|
||||
]}
|
||||
onSelectHome={() => void goHome()}
|
||||
onSelectProjects={() => void goProjects()}
|
||||
onSelectPulse={() => void goPulse()}
|
||||
onSelectSettings={handleOpenSettings}
|
||||
onSelectWorkflows={() => void goWorkflows()}
|
||||
onSetPresenceStatus={(status) =>
|
||||
presenceSession.setStatus(status)
|
||||
}
|
||||
onSetUserStatus={(text, emoji) =>
|
||||
setUserStatusMutation.mutate({ text, emoji })
|
||||
}
|
||||
onClearUserStatus={() =>
|
||||
setUserStatusMutation.mutate({
|
||||
text: "",
|
||||
emoji: "",
|
||||
})
|
||||
}
|
||||
profile={profileQuery.data}
|
||||
projectsOverviewActive={
|
||||
location.pathname === "/projects"
|
||||
}
|
||||
selfUserStatus={
|
||||
deferredPubkey
|
||||
? (selfStatusQuery.data?.[
|
||||
deferredPubkey.toLowerCase()
|
||||
] ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
selectedChannelId={selectedChannelId}
|
||||
selectedView={selectedView}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
previewActivityChannelIds={unreadThreadChannelIds}
|
||||
unreadChannelCounts={unreadChannelCounts}
|
||||
mutedChannelIds={mutedChannelIds}
|
||||
onMuteChannel={muteChannel}
|
||||
onUnmuteChannel={unmuteChannel}
|
||||
starredChannelIds={starredChannelIds}
|
||||
onStarChannel={starChannel}
|
||||
onUnstarChannel={unstarChannel}
|
||||
/>
|
||||
) : null}
|
||||
<TerminalContextOverrideProvider
|
||||
onChange={setTerminalContextOverride}
|
||||
>
|
||||
<AppShellChannelSurface
|
||||
hasCommunityRail={hasCommunityRail}
|
||||
isHuddleRoom={isHuddleRoom}
|
||||
isHuddleRoomStarting={isHuddleRoomStarting}
|
||||
mainInsetRef={mainInsetRef}
|
||||
terminal={
|
||||
<TerminalBootstrap {...effectiveTerminalContext} />
|
||||
}
|
||||
handleSwitchCommunity(id);
|
||||
}}
|
||||
onAddCommunityOpenChange={
|
||||
addCommunityDialog.onOpenChange
|
||||
}
|
||||
onNewMessage={goNewMessage}
|
||||
onBackgroundClick={requestFocusedThreadClose}
|
||||
onCreateChannelOpenChange={setIsCreateChannelOpen}
|
||||
onOpenAddCommunity={addCommunityDialog.openDialog}
|
||||
onSendFeedback={() => setIsSendFeedbackOpen(true)}
|
||||
onUpdateCommunity={communitiesHook.updateCommunity}
|
||||
onRemoveCommunity={handleRemoveCommunity}
|
||||
onSwitchCommunity={handleSwitchCommunity}
|
||||
onCreateAgent={() => requestOpenCreateAgent()}
|
||||
selfPresenceStatus={presenceSession.currentStatus}
|
||||
communities={communitiesHook.communities}
|
||||
onCreateChannel={handleCreateChannel}
|
||||
onCreateForum={handleCreateForum}
|
||||
onHideDm={handleHideDm}
|
||||
onHuddleEnded={handleHuddleEnded}
|
||||
onMarkAllChannelsRead={markAllChannelsRead}
|
||||
onMarkChannelRead={markChannelRead}
|
||||
onMarkChannelUnread={markChannelUnread}
|
||||
onBrowseChannels={handleOpenBrowseChannels}
|
||||
onOpenDm={async ({ pubkeys }) => {
|
||||
const directMessage =
|
||||
await openDmMutation.mutateAsync({
|
||||
pubkeys,
|
||||
});
|
||||
await goChannel(directMessage.id);
|
||||
}}
|
||||
onSelectAgents={() => void goAgents()}
|
||||
onSelectChannel={handleSidebarChannelSelect}
|
||||
onOpenSearchResult={handleOpenSearchResult}
|
||||
searchChannels={channels}
|
||||
searchFocusRequests={[
|
||||
searchFocusRequest,
|
||||
scopeSearchFocusRequest,
|
||||
]}
|
||||
onSelectHome={() => void goHome()}
|
||||
onSelectProjects={() => void goProjects()}
|
||||
onSelectPulse={() => void goPulse()}
|
||||
onSelectSettings={handleOpenSettings}
|
||||
onSelectWorkflows={() => void goWorkflows()}
|
||||
onSetPresenceStatus={(status) =>
|
||||
presenceSession.setStatus(status)
|
||||
}
|
||||
onSetUserStatus={(text, emoji) =>
|
||||
setUserStatusMutation.mutate({ text, emoji })
|
||||
}
|
||||
onClearUserStatus={() =>
|
||||
setUserStatusMutation.mutate({
|
||||
text: "",
|
||||
emoji: "",
|
||||
})
|
||||
}
|
||||
profile={profileQuery.data}
|
||||
projectsOverviewActive={
|
||||
location.pathname === "/projects"
|
||||
}
|
||||
selfUserStatus={
|
||||
deferredPubkey
|
||||
? (selfStatusQuery.data?.[
|
||||
deferredPubkey.toLowerCase()
|
||||
] ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
selectedChannelId={selectedChannelId}
|
||||
selectedView={selectedView}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
previewActivityChannelIds={unreadThreadChannelIds}
|
||||
unreadChannelCounts={unreadChannelCounts}
|
||||
mutedChannelIds={mutedChannelIds}
|
||||
onMuteChannel={muteChannel}
|
||||
onUnmuteChannel={unmuteChannel}
|
||||
starredChannelIds={starredChannelIds}
|
||||
onStarChannel={starChannel}
|
||||
onUnstarChannel={unstarChannel}
|
||||
/>
|
||||
) : null}
|
||||
<TerminalContextOverrideProvider
|
||||
onChange={setTerminalContextOverride}
|
||||
>
|
||||
<Outlet />
|
||||
</AppShellChannelSurface>
|
||||
</TerminalContextOverrideProvider>
|
||||
{!isHuddleRoom ? (
|
||||
<RelayConnectionOverlay
|
||||
card={relayConnectionCard}
|
||||
errorMessage={channelsErrorMessage}
|
||||
hasCommunityRail={hasCommunityRail}
|
||||
isHuddleDrawerOpen={isHuddleDrawerOpen}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<RequestedAgentCreateDialogs />
|
||||
<AgentManagementDialogs />
|
||||
<AppShellOverlays
|
||||
activeChannel={managedChannel}
|
||||
browseDialogType={browseDialogType}
|
||||
channels={channels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
isChannelManagementOpen={isChannelManagementOpen}
|
||||
isCreatingBrowseChannel={
|
||||
createChannelMutation.isPending ||
|
||||
createForumMutation.isPending
|
||||
}
|
||||
onBrowseChannelJoin={handleBrowseChannelJoin}
|
||||
onBrowseChannelCreate={handleBrowseChannelCreate}
|
||||
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
|
||||
onChannelManagementOpenChange={(open) => {
|
||||
setIsChannelManagementOpen(open);
|
||||
if (!open) {
|
||||
setManagedChannelId(null);
|
||||
<AppShellChannelSurface
|
||||
hasCommunityRail={hasCommunityRail}
|
||||
isHuddleRoom={isHuddleRoom}
|
||||
isHuddleRoomStarting={isHuddleRoomStarting}
|
||||
mainInsetRef={mainInsetRef}
|
||||
terminal={
|
||||
<TerminalBootstrap {...effectiveTerminalContext} />
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</AppShellChannelSurface>
|
||||
</TerminalContextOverrideProvider>
|
||||
{!isHuddleRoom ? (
|
||||
<RelayConnectionOverlay
|
||||
card={relayConnectionCard}
|
||||
errorMessage={channelsErrorMessage}
|
||||
hasCommunityRail={hasCommunityRail}
|
||||
isHuddleDrawerOpen={isHuddleDrawerOpen}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<RequestedAgentCreateDialogs />
|
||||
<AgentManagementDialogs />
|
||||
<AppShellOverlays
|
||||
activeChannel={managedChannel}
|
||||
browseDialogType={browseDialogType}
|
||||
channels={channels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
isChannelManagementOpen={isChannelManagementOpen}
|
||||
isCreatingBrowseChannel={
|
||||
createChannelMutation.isPending ||
|
||||
createForumMutation.isPending
|
||||
}
|
||||
}}
|
||||
onDeleteActiveChannel={() => {
|
||||
setIsChannelManagementOpen(false);
|
||||
setManagedChannelId(null);
|
||||
void goHome({ replace: true });
|
||||
}}
|
||||
onSelectChannel={(channelId) => {
|
||||
void goChannel(channelId);
|
||||
}}
|
||||
relayUrl={communitiesHook.activeCommunity?.relayUrl}
|
||||
/>
|
||||
<SendFeedbackController
|
||||
onOpenChange={setIsSendFeedbackOpen}
|
||||
open={isSendFeedbackOpen}
|
||||
/>
|
||||
onBrowseChannelJoin={handleBrowseChannelJoin}
|
||||
onBrowseChannelCreate={handleBrowseChannelCreate}
|
||||
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
|
||||
onChannelManagementOpenChange={(open) => {
|
||||
setIsChannelManagementOpen(open);
|
||||
if (!open) {
|
||||
setManagedChannelId(null);
|
||||
}
|
||||
}}
|
||||
onDeleteActiveChannel={() => {
|
||||
setIsChannelManagementOpen(false);
|
||||
setManagedChannelId(null);
|
||||
void goHome({ replace: true });
|
||||
}}
|
||||
onSelectChannel={(channelId) => {
|
||||
void goChannel(channelId);
|
||||
}}
|
||||
relayUrl={communitiesHook.activeCommunity?.relayUrl}
|
||||
/>
|
||||
<SendFeedbackController
|
||||
onOpenChange={setIsSendFeedbackOpen}
|
||||
open={isSendFeedbackOpen}
|
||||
/>
|
||||
</AppWorkflowEditorOverlayProvider>
|
||||
</AppProfilePanelProvider>
|
||||
</SidebarProvider>
|
||||
</AppHuddleShell>
|
||||
|
||||
174
desktop/src/app/AppWorkflowEditorOverlayProvider.tsx
Normal file
174
desktop/src/app/AppWorkflowEditorOverlayProvider.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import * as React from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocation } from "@tanstack/react-router";
|
||||
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog";
|
||||
import {
|
||||
WorkflowEditorHost,
|
||||
type WorkflowEditorTarget,
|
||||
} from "@/features/workflows/ui/WorkflowEditorHost";
|
||||
import type { WorkflowEditorPane } from "@/features/workflows/ui/workflowEditorPane";
|
||||
import { deleteWorkflow, triggerWorkflow } from "@/shared/api/tauriWorkflows";
|
||||
import type { Workflow } from "@/shared/api/types";
|
||||
import { WorkflowEditorOverlayProvider } from "@/shared/context/WorkflowEditorOverlayContext";
|
||||
|
||||
const INITIAL_PANE: WorkflowEditorPane = { type: "trigger" };
|
||||
|
||||
/** Rebuilds a target with a new pane without widening its discriminant. */
|
||||
function withPane(
|
||||
target: WorkflowEditorTarget,
|
||||
pane: WorkflowEditorPane,
|
||||
): WorkflowEditorTarget {
|
||||
return target.mode === "create"
|
||||
? { initialChannelId: target.initialChannelId, mode: "create", pane }
|
||||
: { mode: target.mode, pane, workflowId: target.workflowId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the shared workflow editor as an overlay owned by the app shell, so
|
||||
* surfaces like channel settings can open a workflow without navigating away
|
||||
* from the channel. The Workflows route keeps its own URL-addressable host —
|
||||
* both render the same editor.
|
||||
*/
|
||||
export function AppWorkflowEditorOverlayProvider({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const queryClient = useQueryClient();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const memberChannels = React.useMemo(
|
||||
() => (channelsQuery.data ?? []).filter((channel) => channel.isMember),
|
||||
[channelsQuery.data],
|
||||
);
|
||||
|
||||
const [editor, setEditor] = React.useState<WorkflowEditorTarget | null>(null);
|
||||
const [workflowHint, setWorkflowHint] = React.useState<Workflow | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<Workflow | null>(null);
|
||||
|
||||
const handleOpenWorkflow = React.useCallback(
|
||||
(workflowId: string, workflow?: Workflow) => {
|
||||
setWorkflowHint(workflow);
|
||||
setEditor({ mode: "detail", pane: INITIAL_PANE, workflowId });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenNewWorkflow = React.useCallback((channelId?: string) => {
|
||||
setWorkflowHint(undefined);
|
||||
setEditor({
|
||||
initialChannelId: channelId,
|
||||
mode: "create",
|
||||
pane: INITIAL_PANE,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeEditor = React.useCallback(() => {
|
||||
setEditor(null);
|
||||
setWorkflowHint(undefined);
|
||||
}, []);
|
||||
|
||||
// This editor belongs to the surface that opened it. If the route leaves that
|
||||
// surface anyway, drop it rather than trailing the modal onto the next screen.
|
||||
// The editor's own dirty-exit guard runs first, so unsaved work still prompts.
|
||||
const { pathname } = useLocation();
|
||||
const lastPathnameRef = React.useRef(pathname);
|
||||
React.useEffect(() => {
|
||||
if (lastPathnameRef.current === pathname) return;
|
||||
lastPathnameRef.current = pathname;
|
||||
closeEditor();
|
||||
}, [closeEditor, pathname]);
|
||||
|
||||
const handleEditorPaneChange = React.useCallback(
|
||||
(pane: WorkflowEditorPane) => {
|
||||
setEditor((current) => (current ? withPane(current, pane) : current));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleEditWorkflow = React.useCallback((workflowId: string) => {
|
||||
setEditor({ mode: "edit", pane: INITIAL_PANE, workflowId });
|
||||
}, []);
|
||||
|
||||
const handleDuplicateWorkflow = React.useCallback((workflowId: string) => {
|
||||
setEditor({ mode: "duplicate", pane: INITIAL_PANE, workflowId });
|
||||
}, []);
|
||||
|
||||
const triggerMutation = useMutation({
|
||||
mutationFn: (workflowId: string) => triggerWorkflow(workflowId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) => query.queryKey[0] === "workflow-runs",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (workflowId: string) => deleteWorkflow(workflowId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === "workflows" ||
|
||||
query.queryKey[0] === "workflows-all",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const triggerOne = triggerMutation.mutate;
|
||||
const handleTriggerWorkflow = React.useCallback(
|
||||
(workflowId: string) => triggerOne(workflowId),
|
||||
[triggerOne],
|
||||
);
|
||||
|
||||
const deleteOne = deleteMutation.mutateAsync;
|
||||
const handleConfirmDelete = React.useCallback(
|
||||
async (workflow: Workflow) => {
|
||||
try {
|
||||
await deleteOne(workflow.id);
|
||||
setDeleteTarget(null);
|
||||
closeEditor();
|
||||
} catch {
|
||||
// React Query stores the error; keep the confirmation and editor open.
|
||||
}
|
||||
},
|
||||
[closeEditor, deleteOne],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkflowEditorOverlayProvider
|
||||
onOpenNewWorkflow={handleOpenNewWorkflow}
|
||||
onOpenWorkflow={handleOpenWorkflow}
|
||||
>
|
||||
{children}
|
||||
<WorkflowEditorHost
|
||||
channels={memberChannels}
|
||||
editor={editor}
|
||||
onClose={closeEditor}
|
||||
onDeleteWorkflow={setDeleteTarget}
|
||||
onDuplicateWorkflow={handleDuplicateWorkflow}
|
||||
onEditWorkflow={handleEditWorkflow}
|
||||
onEditorPaneChange={handleEditorPaneChange}
|
||||
onTriggerWorkflow={handleTriggerWorkflow}
|
||||
workflowHint={workflowHint}
|
||||
/>
|
||||
<WorkflowDeleteDialog
|
||||
error={
|
||||
deleteMutation.error instanceof Error
|
||||
? deleteMutation.error.message
|
||||
: null
|
||||
}
|
||||
isPending={deleteMutation.isPending}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
deleteMutation.reset();
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
open={deleteTarget !== null}
|
||||
workflow={deleteTarget}
|
||||
/>
|
||||
</WorkflowEditorOverlayProvider>
|
||||
);
|
||||
}
|
||||
@@ -169,6 +169,66 @@ export function useAppNavigation() {
|
||||
params: {
|
||||
workflowId,
|
||||
},
|
||||
search: { pane: "trigger" },
|
||||
state: { workflowEditorHasOrigin: true },
|
||||
},
|
||||
behavior,
|
||||
),
|
||||
[commitNavigation],
|
||||
);
|
||||
|
||||
const goNewWorkflow = React.useCallback(
|
||||
(behavior?: NavigationBehavior) =>
|
||||
commitNavigation(
|
||||
{
|
||||
to: "/workflows",
|
||||
search: { pane: "trigger", view: "create" },
|
||||
state: { workflowEditorHasOrigin: true },
|
||||
},
|
||||
behavior,
|
||||
),
|
||||
[commitNavigation],
|
||||
);
|
||||
|
||||
const goNewWorkflowForChannel = React.useCallback(
|
||||
(channelId: string, behavior?: NavigationBehavior) =>
|
||||
commitNavigation(
|
||||
{
|
||||
to: "/workflows",
|
||||
search: {
|
||||
channel: channelId,
|
||||
pane: "trigger",
|
||||
view: "create",
|
||||
},
|
||||
state: { workflowEditorHasOrigin: true },
|
||||
},
|
||||
behavior,
|
||||
),
|
||||
[commitNavigation],
|
||||
);
|
||||
|
||||
const goEditWorkflow = React.useCallback(
|
||||
(workflowId: string, behavior?: NavigationBehavior) =>
|
||||
commitNavigation(
|
||||
{
|
||||
to: "/workflows/$workflowId",
|
||||
params: { workflowId },
|
||||
search: { pane: "trigger", view: "edit" },
|
||||
state: { workflowEditorHasOrigin: true },
|
||||
},
|
||||
behavior,
|
||||
),
|
||||
[commitNavigation],
|
||||
);
|
||||
|
||||
const goDuplicateWorkflow = React.useCallback(
|
||||
(workflowId: string, behavior?: NavigationBehavior) =>
|
||||
commitNavigation(
|
||||
{
|
||||
to: "/workflows/$workflowId",
|
||||
params: { workflowId },
|
||||
search: { pane: "trigger", view: "duplicate" },
|
||||
state: { workflowEditorHasOrigin: true },
|
||||
},
|
||||
behavior,
|
||||
),
|
||||
@@ -330,9 +390,13 @@ export function useAppNavigation() {
|
||||
closeWorkflowDetail,
|
||||
goAgents,
|
||||
goChannel,
|
||||
goDuplicateWorkflow,
|
||||
goEditWorkflow,
|
||||
goForumPost,
|
||||
goHome,
|
||||
goNewMessage,
|
||||
goNewWorkflow,
|
||||
goNewWorkflowForChannel,
|
||||
goProject,
|
||||
goProjects,
|
||||
goPulse,
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import { WorkflowsScreen } from "@/features/workflows/ui/WorkflowsScreen";
|
||||
import {
|
||||
type WorkflowEditorRoute,
|
||||
WorkflowsScreen,
|
||||
} from "@/features/workflows/ui/WorkflowsScreen";
|
||||
import type { WorkflowEditorPane } from "@/features/workflows/ui/workflowEditorPane";
|
||||
|
||||
type WorkflowsRouteScreenProps = {
|
||||
selectedWorkflowId: string | null;
|
||||
editor?: WorkflowEditorRoute | null;
|
||||
onEditorPaneChange: (pane: WorkflowEditorPane) => void;
|
||||
};
|
||||
|
||||
export function WorkflowsRouteScreen({
|
||||
selectedWorkflowId,
|
||||
editor = null,
|
||||
onEditorPaneChange,
|
||||
}: WorkflowsRouteScreenProps) {
|
||||
const { closeWorkflowDetail, goWorkflow } = useAppNavigation();
|
||||
const {
|
||||
goDuplicateWorkflow,
|
||||
goEditWorkflow,
|
||||
goNewWorkflow,
|
||||
goWorkflow,
|
||||
goWorkflows,
|
||||
} = useAppNavigation();
|
||||
const closeEditor = React.useCallback(() => {
|
||||
if (editor?.hasOrigin) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
void goWorkflows({ replace: true });
|
||||
}, [editor?.hasOrigin, goWorkflows]);
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const channels = channelsQuery.data ?? [];
|
||||
const memberChannels = channels.filter((channel) => channel.isMember);
|
||||
@@ -17,11 +38,21 @@ export function WorkflowsRouteScreen({
|
||||
return (
|
||||
<WorkflowsScreen
|
||||
channels={memberChannels}
|
||||
onCloseWorkflow={closeWorkflowDetail}
|
||||
onSelectWorkflow={(workflowId) => {
|
||||
editor={editor}
|
||||
onCloseEditor={closeEditor}
|
||||
onCreateWorkflow={() => {
|
||||
void goNewWorkflow();
|
||||
}}
|
||||
onDuplicateWorkflow={(workflowId) => {
|
||||
void goDuplicateWorkflow(workflowId);
|
||||
}}
|
||||
onEditWorkflow={(workflowId) => {
|
||||
void goEditWorkflow(workflowId);
|
||||
}}
|
||||
onViewWorkflow={(workflowId) => {
|
||||
void goWorkflow(workflowId);
|
||||
}}
|
||||
selectedWorkflowId={selectedWorkflowId}
|
||||
onEditorPaneChange={onEditorPaneChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
6
desktop/src/app/routes/lazyWorkflowsRouteScreen.ts
Normal file
6
desktop/src/app/routes/lazyWorkflowsRouteScreen.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
export const LazyWorkflowsRouteScreen = React.lazy(async () => {
|
||||
const module = await import("./WorkflowsRouteScreen");
|
||||
return { default: module.WorkflowsRouteScreen };
|
||||
});
|
||||
@@ -1,25 +1,62 @@
|
||||
import * as React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, useLocation } from "@tanstack/react-router";
|
||||
|
||||
import {
|
||||
parseWorkflowEditorPane,
|
||||
serializeWorkflowEditorPane,
|
||||
} from "@/features/workflows/ui/workflowEditorPane";
|
||||
import { usePreviewFeatureWarning } from "@/shared/features";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
import { LazyWorkflowsRouteScreen } from "./lazyWorkflowsRouteScreen";
|
||||
|
||||
export const Route = createFileRoute("/workflows/$workflowId")({
|
||||
component: WorkflowDetailRouteComponent,
|
||||
component: WorkflowRouteComponent,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
pane: serializeWorkflowEditorPane(parseWorkflowEditorPane(search.pane)),
|
||||
view:
|
||||
search.view === "edit" || search.view === "duplicate"
|
||||
? search.view
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const WorkflowsRouteScreen = React.lazy(async () => {
|
||||
const module = await import("./WorkflowsRouteScreen");
|
||||
return { default: module.WorkflowsRouteScreen };
|
||||
});
|
||||
|
||||
function WorkflowDetailRouteComponent() {
|
||||
function WorkflowRouteComponent() {
|
||||
usePreviewFeatureWarning("workflows");
|
||||
const navigate = Route.useNavigate();
|
||||
const location = useLocation();
|
||||
const { workflowId } = Route.useParams();
|
||||
const { pane, view } = Route.useSearch();
|
||||
const hasOrigin =
|
||||
(location.state as { workflowEditorHasOrigin?: unknown } | undefined)
|
||||
?.workflowEditorHasOrigin === true;
|
||||
const editor: import("@/features/workflows/ui/WorkflowsScreen").WorkflowEditorRoute =
|
||||
{
|
||||
hasOrigin,
|
||||
mode:
|
||||
view === "duplicate"
|
||||
? "duplicate"
|
||||
: view === "edit"
|
||||
? "edit"
|
||||
: "detail",
|
||||
pane: parseWorkflowEditorPane(pane),
|
||||
workflowId,
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
|
||||
<WorkflowsRouteScreen selectedWorkflowId={workflowId} />
|
||||
<LazyWorkflowsRouteScreen
|
||||
editor={editor}
|
||||
onEditorPaneChange={(nextPane) => {
|
||||
void navigate({
|
||||
replace: true,
|
||||
resetScroll: false,
|
||||
search: {
|
||||
pane: serializeWorkflowEditorPane(nextPane),
|
||||
view,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, useLocation } from "@tanstack/react-router";
|
||||
|
||||
import {
|
||||
parseWorkflowEditorPane,
|
||||
serializeWorkflowEditorPane,
|
||||
} from "@/features/workflows/ui/workflowEditorPane";
|
||||
import { usePreviewFeatureWarning } from "@/shared/features";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
import { LazyWorkflowsRouteScreen } from "./lazyWorkflowsRouteScreen";
|
||||
|
||||
export const Route = createFileRoute("/workflows")({
|
||||
component: WorkflowsRouteComponent,
|
||||
});
|
||||
|
||||
const WorkflowsRouteScreen = React.lazy(async () => {
|
||||
const module = await import("./WorkflowsRouteScreen");
|
||||
return { default: module.WorkflowsRouteScreen };
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
channel: typeof search.channel === "string" ? search.channel : undefined,
|
||||
pane: serializeWorkflowEditorPane(parseWorkflowEditorPane(search.pane)),
|
||||
view: search.view === "create" ? search.view : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
function WorkflowsRouteComponent() {
|
||||
usePreviewFeatureWarning("workflows");
|
||||
const navigate = Route.useNavigate();
|
||||
const location = useLocation();
|
||||
const { channel, pane, view } = Route.useSearch();
|
||||
const hasOrigin =
|
||||
(location.state as { workflowEditorHasOrigin?: unknown } | undefined)
|
||||
?.workflowEditorHasOrigin === true;
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
|
||||
<WorkflowsRouteScreen selectedWorkflowId={null} />
|
||||
<LazyWorkflowsRouteScreen
|
||||
editor={
|
||||
view === "create"
|
||||
? {
|
||||
hasOrigin,
|
||||
initialChannelId: channel,
|
||||
mode: "create",
|
||||
pane: parseWorkflowEditorPane(pane),
|
||||
}
|
||||
: null
|
||||
}
|
||||
onEditorPaneChange={(nextPane) => {
|
||||
void navigate({
|
||||
replace: true,
|
||||
resetScroll: false,
|
||||
search: {
|
||||
channel,
|
||||
pane: serializeWorkflowEditorPane(nextPane),
|
||||
view,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DoorClosed,
|
||||
DoorOpen,
|
||||
Trash2,
|
||||
Workflow as WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
@@ -21,11 +22,15 @@ import {
|
||||
useUpdateChannelMutation,
|
||||
} from "@/features/channels/hooks";
|
||||
import { compareMembersByRole } from "@/features/channels/lib/memberUtils";
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useChannelWorkflowsQuery } from "@/features/workflows/hooks";
|
||||
import {
|
||||
DEFAULT_EPHEMERAL_TTL_SECONDS,
|
||||
formatTtlDuration,
|
||||
} from "@/features/channels/lib/ephemeralChannel";
|
||||
import type { Channel, ChannelMember } from "@/shared/api/types";
|
||||
import type { Channel, ChannelMember, Workflow } from "@/shared/api/types";
|
||||
import { useWorkflowEditorOverlay } from "@/shared/context/WorkflowEditorOverlayContext";
|
||||
import { useFeatureEnabled } from "@/shared/features";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useTheme } from "@/shared/theme/ThemeProvider";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -55,6 +60,7 @@ import {
|
||||
PANEL_OVERLAY_CLASS,
|
||||
} from "@/shared/ui/OverlayPanelBackdrop";
|
||||
import { ChannelCanvas } from "./ChannelCanvas";
|
||||
import { ChannelWorkflowsSection } from "./ChannelWorkflowsSection";
|
||||
import {
|
||||
CHANNEL_FORM_FIELD_CONTROL_CLASS,
|
||||
CHANNEL_FORM_FIELD_SHELL_CLASS,
|
||||
@@ -101,15 +107,24 @@ export function ChannelManagementSheet({
|
||||
transparentChrome = false,
|
||||
}: ChannelManagementSheetProps) {
|
||||
const { isDark } = useTheme();
|
||||
const { goNewWorkflowForChannel, goWorkflow } = useAppNavigation();
|
||||
const {
|
||||
openNewWorkflow: openNewWorkflowOverlay,
|
||||
openWorkflow: openWorkflowOverlay,
|
||||
} = useWorkflowEditorOverlay();
|
||||
const isSplitLayout = layout === "split";
|
||||
const auxiliaryPanelMode = getAuxiliaryPanelMode(
|
||||
isSplitLayout,
|
||||
!isSplitLayout,
|
||||
);
|
||||
const channelId = channel?.id ?? null;
|
||||
const workflowsEnabled = useFeatureEnabled("workflows");
|
||||
const detailsQuery = useChannelDetailsQuery(channelId, open);
|
||||
const membersQuery = useChannelMembersQuery(channelId, open);
|
||||
const canvasQuery = useCanvasQuery(channelId, channelId !== null && open);
|
||||
const workflowsQuery = useChannelWorkflowsQuery(
|
||||
workflowsEnabled && channelId !== null && open ? channelId : null,
|
||||
);
|
||||
const updateChannelDetailsMutation = useUpdateChannelMutation(channelId);
|
||||
const archiveChannelMutation = useArchiveChannelMutation(channelId);
|
||||
const unarchiveChannelMutation = useUnarchiveChannelMutation(channelId);
|
||||
@@ -160,9 +175,11 @@ export function ChannelManagementSheet({
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false);
|
||||
const [hasUserEditedChannelDraft, setHasUserEditedChannelDraft] =
|
||||
React.useState(false);
|
||||
const [activeView, setActiveView] = React.useState<"summary" | "canvas">(
|
||||
"summary",
|
||||
);
|
||||
const [activeView, setActiveView] = React.useState<
|
||||
"summary" | "canvas" | "workflows"
|
||||
>("summary");
|
||||
const visibleActiveView =
|
||||
workflowsEnabled || activeView !== "workflows" ? activeView : "summary";
|
||||
const { cancelDeferredModalOpen, openNextFrame: openModalNextFrame } =
|
||||
useDeferredModalOpen();
|
||||
|
||||
@@ -237,6 +254,33 @@ export function ChannelManagementSheet({
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
// Workflows open as a modal above the channel settings Workflows view. Keep
|
||||
// that view mounted behind the editor so every completed close path (clean,
|
||||
// dirty-discard, or create cancel) returns to the exact surface that opened
|
||||
// it. The navigation fallbacks still close the sheet before changing routes;
|
||||
// canonical /workflows deep links stay unchanged either way.
|
||||
function handleOpenWorkflow(workflow: Workflow) {
|
||||
if (openWorkflowOverlay) {
|
||||
openWorkflowOverlay(workflow.id, workflow);
|
||||
return;
|
||||
}
|
||||
|
||||
handlePanelOpenChange(false);
|
||||
void goWorkflow(workflow.id);
|
||||
}
|
||||
|
||||
function handleCreateWorkflow() {
|
||||
if (!channelId) return;
|
||||
|
||||
if (openNewWorkflowOverlay) {
|
||||
openNewWorkflowOverlay(channelId);
|
||||
return;
|
||||
}
|
||||
|
||||
handlePanelOpenChange(false);
|
||||
void goNewWorkflowForChannel(channelId);
|
||||
}
|
||||
|
||||
const currentVisibility = detail?.visibility ?? channel.visibility;
|
||||
const currentTtlSeconds = detail?.ttlSeconds ?? null;
|
||||
const nextVisibility: "open" | "private" = isPrivateDraft
|
||||
@@ -338,7 +382,7 @@ export function ChannelManagementSheet({
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<ChannelManagementPanelContent
|
||||
activeView={activeView}
|
||||
activeView={visibleActiveView}
|
||||
archiveChannelMutation={archiveChannelMutation}
|
||||
canEditNarrative={canEditNarrative}
|
||||
canEditChannel={canEditChannel}
|
||||
@@ -349,6 +393,10 @@ export function ChannelManagementSheet({
|
||||
canvasQuery={canvasQuery}
|
||||
channelId={channelId}
|
||||
currentPubkey={currentPubkey}
|
||||
workflowsEnabled={workflowsEnabled}
|
||||
workflowsQuery={workflowsQuery}
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
onOpenWorkflow={handleOpenWorkflow}
|
||||
deleteChannelMutation={deleteChannelMutation}
|
||||
detailsError={detailsQuery.error}
|
||||
handleDeleteChannel={handleDeleteChannel}
|
||||
@@ -386,7 +434,7 @@ export function ChannelManagementSheet({
|
||||
data-testid="channel-management-sheet"
|
||||
>
|
||||
<ChannelManagementPanelContent
|
||||
activeView={activeView}
|
||||
activeView={visibleActiveView}
|
||||
archiveChannelMutation={archiveChannelMutation}
|
||||
canEditNarrative={canEditNarrative}
|
||||
canEditChannel={canEditChannel}
|
||||
@@ -397,6 +445,10 @@ export function ChannelManagementSheet({
|
||||
canvasQuery={canvasQuery}
|
||||
channelId={channelId}
|
||||
currentPubkey={currentPubkey}
|
||||
workflowsEnabled={workflowsEnabled}
|
||||
workflowsQuery={workflowsQuery}
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
onOpenWorkflow={handleOpenWorkflow}
|
||||
deleteChannelMutation={deleteChannelMutation}
|
||||
detailsError={detailsQuery.error}
|
||||
handleDeleteChannel={handleDeleteChannel}
|
||||
@@ -569,7 +621,7 @@ type ChannelMutation<TArgs = void> = {
|
||||
};
|
||||
|
||||
type ChannelManagementPanelContentProps = {
|
||||
activeView: "summary" | "canvas";
|
||||
activeView: "summary" | "canvas" | "workflows";
|
||||
archiveChannelMutation: ChannelMutation;
|
||||
canEditChannel: boolean;
|
||||
canEditNarrative: boolean;
|
||||
@@ -580,6 +632,15 @@ type ChannelManagementPanelContentProps = {
|
||||
canvasQuery: { isLoading: boolean };
|
||||
channelId: string | null;
|
||||
currentPubkey?: string;
|
||||
workflowsEnabled: boolean;
|
||||
workflowsQuery: {
|
||||
data?: Workflow[];
|
||||
error: unknown;
|
||||
isLoading: boolean;
|
||||
refetch: () => Promise<unknown>;
|
||||
};
|
||||
onCreateWorkflow: () => void;
|
||||
onOpenWorkflow: (workflow: Workflow) => void;
|
||||
deleteChannelMutation: ChannelMutation;
|
||||
detailsError: unknown;
|
||||
handleDeleteChannel: () => Promise<void>;
|
||||
@@ -598,7 +659,9 @@ type ChannelManagementPanelContentProps = {
|
||||
onOpenMembers?: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
resolvedChannel: Channel;
|
||||
setActiveView: React.Dispatch<React.SetStateAction<"summary" | "canvas">>;
|
||||
setActiveView: React.Dispatch<
|
||||
React.SetStateAction<"summary" | "canvas" | "workflows">
|
||||
>;
|
||||
unarchiveChannelMutation: ChannelMutation;
|
||||
};
|
||||
|
||||
@@ -614,6 +677,10 @@ function ChannelManagementPanelContent({
|
||||
canvasQuery,
|
||||
channelId,
|
||||
currentPubkey,
|
||||
workflowsEnabled,
|
||||
workflowsQuery,
|
||||
onCreateWorkflow,
|
||||
onOpenWorkflow,
|
||||
deleteChannelMutation,
|
||||
detailsError,
|
||||
handleDeleteChannel,
|
||||
@@ -663,12 +730,18 @@ function ChannelManagementPanelContent({
|
||||
backButtonTestId="channel-management-back"
|
||||
mode={mode}
|
||||
onBack={
|
||||
activeView === "canvas" ? () => setActiveView("summary") : undefined
|
||||
activeView !== "summary"
|
||||
? () => setActiveView("summary")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<DialogPrimitive.Title asChild>
|
||||
<AuxiliaryPanelTitle>
|
||||
{activeView === "canvas" ? "Canvas" : "Channel Settings"}
|
||||
{activeView === "canvas"
|
||||
? "Canvas"
|
||||
: activeView === "workflows"
|
||||
? "Workflows"
|
||||
: "Channel Settings"}
|
||||
</AuxiliaryPanelTitle>
|
||||
</DialogPrimitive.Title>
|
||||
</AuxiliaryPanelHeaderGroup>
|
||||
@@ -749,14 +822,45 @@ function ChannelManagementPanelContent({
|
||||
</FieldGroup>
|
||||
|
||||
{canOpenCanvas ? (
|
||||
<div className="space-y-3">
|
||||
<IngressRow
|
||||
description={canvasPreview}
|
||||
helpText="Use the canvas as a shared space for notes, plans, and other channel information."
|
||||
icon={BookOpenText}
|
||||
label="Canvas"
|
||||
onClick={() => setActiveView("canvas")}
|
||||
testId="channel-canvas-ingress"
|
||||
trailing={canvasQuery.isLoading ? "Loading..." : undefined}
|
||||
/>
|
||||
{workflowsEnabled ? (
|
||||
<IngressRow
|
||||
description={
|
||||
workflowsQuery.isLoading
|
||||
? undefined
|
||||
: `${workflowsQuery.data?.length ?? 0} workflow${workflowsQuery.data?.length === 1 ? "" : "s"}`
|
||||
}
|
||||
icon={WorkflowIcon}
|
||||
label="Workflows"
|
||||
onClick={() => setActiveView("workflows")}
|
||||
testId="channel-workflows-ingress"
|
||||
trailing={
|
||||
workflowsQuery.isLoading ? "Loading..." : undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : workflowsEnabled ? (
|
||||
<IngressRow
|
||||
description={canvasPreview}
|
||||
helpText="Use the canvas as a shared space for notes, plans, and other channel information."
|
||||
icon={BookOpenText}
|
||||
label="Canvas"
|
||||
onClick={() => setActiveView("canvas")}
|
||||
testId="channel-canvas-ingress"
|
||||
trailing={canvasQuery.isLoading ? "Loading..." : undefined}
|
||||
description={
|
||||
workflowsQuery.isLoading
|
||||
? undefined
|
||||
: `${workflowsQuery.data?.length ?? 0} workflow${workflowsQuery.data?.length === 1 ? "" : "s"}`
|
||||
}
|
||||
icon={WorkflowIcon}
|
||||
label="Workflows"
|
||||
onClick={() => setActiveView("workflows")}
|
||||
testId="channel-workflows-ingress"
|
||||
trailing={workflowsQuery.isLoading ? "Loading..." : undefined}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -871,7 +975,7 @@ function ChannelManagementPanelContent({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
) : activeView === "canvas" ? (
|
||||
<div data-testid="channel-canvas-section">
|
||||
<ChannelCanvas
|
||||
canEdit={canEditNarrative}
|
||||
@@ -879,7 +983,16 @@ function ChannelManagementPanelContent({
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : activeView === "workflows" && workflowsEnabled ? (
|
||||
<ChannelWorkflowsSection
|
||||
error={workflowsQuery.error}
|
||||
loading={workflowsQuery.isLoading}
|
||||
onCreate={onCreateWorkflow}
|
||||
onOpen={onOpenWorkflow}
|
||||
onRetry={() => void workflowsQuery.refetch()}
|
||||
workflows={workflowsQuery.data ?? []}
|
||||
/>
|
||||
) : null}
|
||||
</AuxiliaryPanelBody>
|
||||
</AuxiliaryPanelContext.Provider>
|
||||
);
|
||||
|
||||
77
desktop/src/features/channels/ui/ChannelWorkflowsSection.tsx
Normal file
77
desktop/src/features/channels/ui/ChannelWorkflowsSection.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Plus, Workflow as WorkflowIcon } from "lucide-react";
|
||||
|
||||
import type { Workflow } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { FieldGroup } from "./ChannelManagementSheetRows";
|
||||
|
||||
export function ChannelWorkflowsSection({
|
||||
error,
|
||||
loading,
|
||||
onCreate,
|
||||
onOpen,
|
||||
onRetry,
|
||||
workflows,
|
||||
}: {
|
||||
error: unknown;
|
||||
loading: boolean;
|
||||
onCreate: () => void;
|
||||
onOpen: (workflow: Workflow) => void;
|
||||
onRetry: () => void;
|
||||
workflows: Workflow[];
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4 pt-3" data-testid="channel-workflows-section">
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Loading workflows...
|
||||
</p>
|
||||
) : error instanceof Error ? (
|
||||
<div className="space-y-3 rounded-xl border border-destructive/30 bg-destructive/10 p-4">
|
||||
<p className="text-sm text-destructive">{error.message}</p>
|
||||
<Button onClick={onRetry} size="sm" variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : workflows.length > 0 ? (
|
||||
<FieldGroup testId="channel-workflows-list">
|
||||
{workflows.map((workflow) => (
|
||||
<button
|
||||
className="flex min-h-16 w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
data-testid={`channel-workflow-${workflow.id}`}
|
||||
key={workflow.id}
|
||||
onClick={() => onOpen(workflow)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
||||
<WorkflowIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
{workflow.name}
|
||||
</span>
|
||||
<span className="block text-xs capitalize text-muted-foreground">
|
||||
{workflow.status}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</FieldGroup>
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No workflows in this channel yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid="channel-workflows-new"
|
||||
onClick={onCreate}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus />
|
||||
New workflow
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +1,137 @@
|
||||
import { Check, ChevronsUpDown, Search } from "lucide-react";
|
||||
import {
|
||||
Asterisk,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Hash,
|
||||
Lock,
|
||||
MessageSquareMore,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { PortalledScrollArea } from "@/shared/ui/PortalledScrollArea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
function formatChannelLabel(ch: Channel): string {
|
||||
return `${ch.name} · ${ch.channelType} · ${ch.visibility}`;
|
||||
function ChannelPrivacyIcon({ channel }: { channel: Channel }) {
|
||||
const Icon =
|
||||
channel.channelType === "dm"
|
||||
? MessageSquareMore
|
||||
: channel.visibility === "private"
|
||||
? Lock
|
||||
: Hash;
|
||||
|
||||
return (
|
||||
<Icon aria-hidden className="h-5 w-5 shrink-0 text-muted-foreground/60" />
|
||||
);
|
||||
}
|
||||
|
||||
type ChannelComboboxProps = {
|
||||
allowEmpty?: boolean;
|
||||
ariaLabel?: string;
|
||||
channels: Channel[];
|
||||
defaultOpen?: boolean;
|
||||
disabled?: boolean;
|
||||
emptyLabel?: string;
|
||||
id?: string;
|
||||
isChannelDisabled?: (channel: Channel) => boolean;
|
||||
onAutoOpen?: () => void;
|
||||
onChange: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
readOnlyTooltip?: string;
|
||||
required?: boolean;
|
||||
variant?: "header" | "field";
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function ChannelCombobox({
|
||||
allowEmpty = false,
|
||||
ariaLabel = "Channel",
|
||||
channels,
|
||||
defaultOpen = false,
|
||||
disabled,
|
||||
emptyLabel = "Choose a channel",
|
||||
id,
|
||||
isChannelDisabled,
|
||||
onAutoOpen,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
readOnlyTooltip = "The channel can't be changed after a workflow is created.",
|
||||
required = false,
|
||||
variant = "header",
|
||||
value,
|
||||
}: ChannelComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
|
||||
const autoOpenHandledRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!defaultOpen || autoOpenHandledRef.current) return;
|
||||
|
||||
// Let the pointer interaction that mounted the containing dialog finish
|
||||
// before installing Radix's outside-interaction listeners.
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
autoOpenHandledRef.current = true;
|
||||
setOpen(true);
|
||||
onAutoOpen?.();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [defaultOpen, onAutoOpen]);
|
||||
|
||||
const listboxId = `${id ?? "channel"}-listbox`;
|
||||
const selected = channels.find((c) => c.id === value);
|
||||
const currentPubkey = useIdentityQuery().data?.pubkey;
|
||||
const dmParticipantPubkeys = React.useMemo(() => {
|
||||
const visibleDmChannels = open
|
||||
? channels.filter((channel) => channel.channelType === "dm")
|
||||
: selected?.channelType === "dm"
|
||||
? [selected]
|
||||
: [];
|
||||
|
||||
return visibleDmChannels.flatMap((channel) =>
|
||||
channel.participantPubkeys.filter(
|
||||
(pubkey) => pubkey.toLowerCase() !== currentPubkey?.toLowerCase(),
|
||||
),
|
||||
);
|
||||
}, [channels, currentPubkey, open, selected]);
|
||||
const dmProfiles = useUsersBatchQuery(dmParticipantPubkeys, {
|
||||
enabled: dmParticipantPubkeys.length > 0,
|
||||
}).data?.profiles;
|
||||
const channelLabels = React.useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
channels.map((channel) => [
|
||||
channel.id,
|
||||
resolveChannelDisplayLabel(channel, currentPubkey, dmProfiles),
|
||||
]),
|
||||
),
|
||||
[channels, currentPubkey, dmProfiles],
|
||||
);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
if (!query) return channels;
|
||||
const q = query.toLowerCase();
|
||||
return channels.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
(channelLabels.get(c.id) ?? c.name).toLowerCase().includes(q) ||
|
||||
c.channelType?.toLowerCase().includes(q) ||
|
||||
c.id.toLowerCase().includes(q),
|
||||
);
|
||||
}, [channels, query]);
|
||||
}, [channelLabels, channels, query]);
|
||||
const selectable = React.useMemo(
|
||||
() => filtered.filter((channel) => !isChannelDisabled?.(channel)),
|
||||
[filtered, isChannelDisabled],
|
||||
);
|
||||
const highlightedChannel = selectable[highlightedIndex];
|
||||
const highlightedOptionId = highlightedChannel
|
||||
? `${listboxId}-option-${highlightedChannel.id}`
|
||||
: undefined;
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
setOpen(next);
|
||||
@@ -55,22 +147,24 @@ export function ChannelCombobox({
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (filtered.length === 0) return;
|
||||
if (selectable.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown": {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((i) => (i + 1) % filtered.length);
|
||||
setHighlightedIndex((i) => (i + 1) % selectable.length);
|
||||
break;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((i) => (i - 1 + filtered.length) % filtered.length);
|
||||
setHighlightedIndex(
|
||||
(i) => (i - 1 + selectable.length) % selectable.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "Enter": {
|
||||
e.preventDefault();
|
||||
const target = filtered[highlightedIndex];
|
||||
const target = selectable[highlightedIndex];
|
||||
if (target) selectChannel(target.id);
|
||||
break;
|
||||
}
|
||||
@@ -82,13 +176,54 @@ export function ChannelCombobox({
|
||||
}
|
||||
}
|
||||
|
||||
const selectedLabel = selected
|
||||
? (channelLabels.get(selected.id) ?? selected.name)
|
||||
: value
|
||||
? "Unavailable channel"
|
||||
: emptyLabel;
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-label={`${ariaLabel}: ${selectedLabel}. Read only.`}
|
||||
className={cn(
|
||||
"flex w-full cursor-default items-center rounded-lg px-3 py-2 text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
|
||||
variant === "header" &&
|
||||
"justify-center border-0 bg-transparent text-lg font-semibold",
|
||||
variant === "field" && "text-sm font-normal",
|
||||
)}
|
||||
id={id}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{selected ? <ChannelPrivacyIcon channel={selected} /> : null}
|
||||
<span className="truncate">{selectedLabel}</span>
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{readOnlyTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={handleOpenChange} open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-controls={listboxId}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={ariaLabel}
|
||||
aria-required={required}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 text-sm shadow-xs transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"group flex w-full items-center rounded-lg px-3 py-2 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variant === "header" &&
|
||||
"justify-center border-0 bg-transparent text-lg font-semibold",
|
||||
variant === "field" &&
|
||||
"justify-between border border-input/40 bg-background text-sm font-normal",
|
||||
!selected && "text-muted-foreground",
|
||||
)}
|
||||
disabled={disabled}
|
||||
@@ -96,19 +231,35 @@ export function ChannelCombobox({
|
||||
role="combobox"
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selected ? formatChannelLabel(selected) : "Select a channel..."}
|
||||
<span
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2",
|
||||
variant === "header" && "justify-center",
|
||||
)}
|
||||
>
|
||||
{selected ? (
|
||||
<ChannelPrivacyIcon channel={selected} />
|
||||
) : required && !value ? (
|
||||
<Asterisk aria-hidden className="h-5 w-5 shrink-0 text-primary" />
|
||||
) : null}
|
||||
<span className="truncate">{selectedLabel}</span>
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<ChevronDown className="ml-1 h-5 w-5 shrink-0 text-muted-foreground opacity-50 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-(--radix-popover-trigger-width) p-0"
|
||||
portalled={variant === "field"}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
aria-activedescendant={highlightedOptionId}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={listboxId}
|
||||
aria-expanded={open}
|
||||
aria-label={`Search ${ariaLabel.toLowerCase()}s`}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
@@ -120,44 +271,81 @@ export function ChannelCombobox({
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search channels..."
|
||||
role="combobox"
|
||||
spellCheck={false}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto p-1">
|
||||
<PortalledScrollArea
|
||||
aria-label={`${ariaLabel} options`}
|
||||
className="max-h-60 overflow-y-auto p-1"
|
||||
data-testid="channel-combobox-list"
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
>
|
||||
{allowEmpty && !query ? (
|
||||
<button
|
||||
aria-selected={!value}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
!value && "bg-accent/50",
|
||||
)}
|
||||
onClick={() => selectChannel("")}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<span className="h-5 w-5 shrink-0" />
|
||||
<span className="truncate">{emptyLabel}</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4 shrink-0",
|
||||
!value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
No channels found.
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((channel, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
channel.id === value && "bg-accent/50",
|
||||
index === highlightedIndex &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
key={channel.id}
|
||||
onClick={() => selectChannel(channel.id)}
|
||||
type="button"
|
||||
>
|
||||
<Check
|
||||
filtered.map((channel) => {
|
||||
const optionDisabled = isChannelDisabled?.(channel) ?? false;
|
||||
return (
|
||||
<button
|
||||
aria-selected={channel.id === value}
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
channel.id === value ? "opacity-100" : "opacity-0",
|
||||
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-transparent disabled:hover:text-foreground",
|
||||
channel.id === value && "bg-accent/50",
|
||||
channel.id === selectable[highlightedIndex]?.id &&
|
||||
!optionDisabled &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{channel.name}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
· {channel.channelType} · {channel.visibility}
|
||||
disabled={optionDisabled}
|
||||
id={`${listboxId}-option-${channel.id}`}
|
||||
key={channel.id}
|
||||
onClick={() => selectChannel(channel.id)}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<ChannelPrivacyIcon channel={channel} />
|
||||
<span className="truncate">
|
||||
{channelLabels.get(channel.id) ?? channel.name}
|
||||
{channel.channelType === "dm" ? (
|
||||
<span className="text-muted-foreground"> · dm</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4 shrink-0",
|
||||
channel.id === value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PortalledScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { WorkflowDialog } from "./WorkflowDialog";
|
||||
|
||||
type CreateWorkflowDialogProps = {
|
||||
channels: Channel[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
export function CreateWorkflowDialog({
|
||||
channels,
|
||||
onOpenChange,
|
||||
open,
|
||||
}: CreateWorkflowDialogProps) {
|
||||
return (
|
||||
<WorkflowDialog
|
||||
channels={channels}
|
||||
mode="create"
|
||||
onOpenChange={onOpenChange}
|
||||
open={open}
|
||||
/>
|
||||
);
|
||||
}
|
||||
169
desktop/src/features/workflows/ui/CronExpressionInput.tsx
Normal file
169
desktop/src/features/workflows/ui/CronExpressionInput.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
CRON_FIELD_DEFINITIONS,
|
||||
cronExpressionFromFields,
|
||||
cronFieldsFromExpression,
|
||||
cronFieldsFromPaste,
|
||||
normalizeCronExpression,
|
||||
validateCronFields,
|
||||
} from "./cronExpression";
|
||||
import type { CronFields } from "./cronExpression";
|
||||
|
||||
export function CronExpressionInput({
|
||||
disabled,
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
}) {
|
||||
const [fields, setFields] = React.useState<CronFields>(() =>
|
||||
cronFieldsFromExpression(value),
|
||||
);
|
||||
const [pasteError, setPasteError] = React.useState<string | null>(null);
|
||||
const inputRefs = React.useRef<Array<HTMLInputElement | null>>([]);
|
||||
const localValue = React.useRef(normalizeCronExpression(value));
|
||||
const validationErrors = validateCronFields(fields);
|
||||
const firstError = pasteError ?? validationErrors.find(Boolean) ?? null;
|
||||
const messageId = "wf-trigger-cron-message";
|
||||
|
||||
React.useEffect(() => {
|
||||
const nextValue = normalizeCronExpression(value);
|
||||
if (nextValue !== localValue.current) {
|
||||
setFields(cronFieldsFromExpression(value));
|
||||
localValue.current = nextValue;
|
||||
setPasteError(null);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const commitFields = (nextFields: CronFields) => {
|
||||
const expression = cronExpressionFromFields(nextFields);
|
||||
setFields(nextFields);
|
||||
setPasteError(null);
|
||||
localValue.current = normalizeCronExpression(expression);
|
||||
onChange(expression);
|
||||
};
|
||||
|
||||
const focusField = (index: number) => {
|
||||
inputRefs.current[index]?.focus();
|
||||
inputRefs.current[index]?.select();
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="text-xs font-medium text-muted-foreground">
|
||||
Cron expression
|
||||
</legend>
|
||||
<div aria-describedby={messageId} className="space-y-1">
|
||||
<div className="grid grid-cols-5 gap-px px-px">
|
||||
{CRON_FIELD_DEFINITIONS.map((definition, index) => (
|
||||
<label
|
||||
className={cn(
|
||||
"min-w-0 truncate px-1 text-center text-2xs text-muted-foreground",
|
||||
validationErrors[index] && "text-destructive",
|
||||
)}
|
||||
htmlFor={`wf-trigger-cron-${definition.label.toLowerCase()}`}
|
||||
key={definition.label}
|
||||
>
|
||||
{definition.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-5 overflow-hidden rounded-lg border border-input/50 bg-background",
|
||||
"focus-within:border-ring focus-within:ring-1 focus-within:ring-ring",
|
||||
disabled && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{CRON_FIELD_DEFINITIONS.map((definition, index) => (
|
||||
<input
|
||||
aria-describedby={messageId}
|
||||
aria-invalid={Boolean(validationErrors[index])}
|
||||
aria-label={definition.label}
|
||||
autoCapitalize="characters"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
"h-11 min-w-0 border-l border-input/50 bg-transparent px-1 text-center font-mono text-sm text-foreground outline-hidden first:border-l-0",
|
||||
"placeholder:text-muted-foreground/60 focus:z-10 focus:bg-muted/35",
|
||||
"disabled:cursor-not-allowed",
|
||||
validationErrors[index] &&
|
||||
"bg-destructive/5 text-destructive focus:bg-destructive/10",
|
||||
)}
|
||||
disabled={disabled}
|
||||
id={`wf-trigger-cron-${definition.label.toLowerCase()}`}
|
||||
key={definition.label}
|
||||
onChange={(event) => {
|
||||
const nextFields = [...fields] as CronFields;
|
||||
nextFields[index] = event.target.value.replace(/\s/g, "");
|
||||
commitFields(nextFields);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
const input = event.currentTarget;
|
||||
if (event.key === " " && index < fields.length - 1) {
|
||||
event.preventDefault();
|
||||
focusField(index + 1);
|
||||
} else if (
|
||||
event.key === "Backspace" &&
|
||||
!input.value &&
|
||||
index > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
focusField(index - 1);
|
||||
} else if (
|
||||
event.key === "ArrowLeft" &&
|
||||
input.selectionStart === 0 &&
|
||||
index > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
focusField(index - 1);
|
||||
} else if (
|
||||
event.key === "ArrowRight" &&
|
||||
input.selectionStart === input.value.length &&
|
||||
index < fields.length - 1
|
||||
) {
|
||||
event.preventDefault();
|
||||
focusField(index + 1);
|
||||
}
|
||||
}}
|
||||
onPaste={(event) => {
|
||||
const pastedValue =
|
||||
event.clipboardData.getData("text/plain") ||
|
||||
event.clipboardData.getData("text");
|
||||
if (!/\s/.test(pastedValue.trim())) return;
|
||||
|
||||
event.preventDefault();
|
||||
const result = cronFieldsFromPaste(pastedValue);
|
||||
if (!result.ok) {
|
||||
setPasteError(result.error);
|
||||
return;
|
||||
}
|
||||
commitFields(result.fields);
|
||||
}}
|
||||
placeholder="*"
|
||||
ref={(element) => {
|
||||
inputRefs.current[index] = element;
|
||||
}}
|
||||
spellCheck={false}
|
||||
value={fields[index]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs text-muted-foreground",
|
||||
firstError && "text-destructive",
|
||||
)}
|
||||
id={messageId}
|
||||
role={firstError ? "alert" : undefined}
|
||||
>
|
||||
{firstError ??
|
||||
"UTC · Paste all 5 fields, or use wildcards, lists, ranges, and steps."}
|
||||
</p>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -29,9 +29,8 @@ import {
|
||||
type WorkflowCardProps = {
|
||||
workflow: Workflow;
|
||||
channelName?: string;
|
||||
isActive?: boolean;
|
||||
isTogglingEnabled?: boolean;
|
||||
onSelect: (workflowId: string) => void;
|
||||
onView: (workflow: Workflow) => void;
|
||||
onTrigger: (workflowId: string) => void;
|
||||
onToggleEnabled: (workflow: Workflow) => void;
|
||||
onEdit: (workflow: Workflow) => void;
|
||||
@@ -81,9 +80,8 @@ function StatusBadge({ status }: { status: Workflow["status"] }) {
|
||||
export function WorkflowCard({
|
||||
workflow,
|
||||
channelName,
|
||||
isActive = false,
|
||||
isTogglingEnabled = false,
|
||||
onSelect,
|
||||
onView,
|
||||
onTrigger,
|
||||
onToggleEnabled,
|
||||
onEdit,
|
||||
@@ -102,14 +100,13 @@ export function WorkflowCard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative min-h-60 w-full overflow-hidden rounded-2xl border border-border/70 bg-muted/50 p-5 text-left text-foreground shadow-xs transition-all hover:-translate-y-0.5 hover:border-border hover:bg-muted/65 hover:shadow-md",
|
||||
isActive && "border-primary/50 bg-primary/5 ring-1 ring-primary/30",
|
||||
"group relative min-h-60 w-full overflow-hidden rounded-2xl bg-muted/50 p-5 text-left text-foreground shadow-xs transition-colors hover:bg-muted/65",
|
||||
)}
|
||||
data-testid={`workflow-card-${workflow.id}`}
|
||||
>
|
||||
<button
|
||||
className="absolute inset-0 z-0 rounded-2xl focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
onClick={() => onSelect(workflow.id)}
|
||||
onClick={() => onView(workflow)}
|
||||
type="button"
|
||||
>
|
||||
<span className="sr-only">View {workflow.name}</span>
|
||||
@@ -159,7 +156,7 @@ export function WorkflowCard({
|
||||
{triggerSummary}
|
||||
</p>
|
||||
) : null}
|
||||
<h3 className="mt-1 line-clamp-2 text-xl font-bold leading-tight tracking-tight">
|
||||
<h3 className="mt-1 line-clamp-4 text-xl font-bold leading-tight tracking-tight">
|
||||
{workflow.name}
|
||||
</h3>
|
||||
{description ? (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Workflow } from "@/shared/api/types";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
@@ -12,20 +11,29 @@ import {
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
type WorkflowDeleteDialogProps = {
|
||||
error?: string | null;
|
||||
isPending?: boolean;
|
||||
open: boolean;
|
||||
workflow: Workflow | null;
|
||||
onConfirm: (workflow: Workflow) => void;
|
||||
onConfirm: (workflow: Workflow) => Promise<void>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function WorkflowDeleteDialog({
|
||||
error,
|
||||
isPending = false,
|
||||
open,
|
||||
workflow,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
}: WorkflowDeleteDialogProps) {
|
||||
return (
|
||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||
<AlertDialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!isPending) onOpenChange(nextOpen);
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete workflow?</AlertDialogTitle>
|
||||
@@ -34,26 +42,35 @@ export function WorkflowDeleteDialog({
|
||||
? `Delete "${workflow.name}". This will stop all future triggers and remove the workflow permanently.`
|
||||
: "Delete this workflow."}
|
||||
</AlertDialogDescription>
|
||||
{error ? (
|
||||
<p
|
||||
aria-live="polite"
|
||||
className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
Couldn’t delete workflow. {error} Try again or cancel to keep
|
||||
editing.
|
||||
</p>
|
||||
) : null}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button type="button" variant="outline">
|
||||
<Button disabled={isPending} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (workflow) {
|
||||
onConfirm(workflow);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
<Button
|
||||
disabled={!workflow || isPending}
|
||||
onClick={() => {
|
||||
if (workflow) {
|
||||
void onConfirm(workflow);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
{isPending ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -20,14 +20,18 @@ import {
|
||||
|
||||
type WorkflowDetailPanelProps = {
|
||||
workflowId: string;
|
||||
onClose: () => void;
|
||||
onEdit: (workflow: Workflow) => void;
|
||||
onClose?: () => void;
|
||||
onEdit?: (workflow: Workflow) => void;
|
||||
showDefinition?: boolean;
|
||||
showHeader?: boolean;
|
||||
};
|
||||
|
||||
export function WorkflowDetailPanel({
|
||||
workflowId,
|
||||
onClose,
|
||||
onEdit,
|
||||
showDefinition = true,
|
||||
showHeader = true,
|
||||
}: WorkflowDetailPanelProps) {
|
||||
const workflowQuery = useWorkflowQuery(workflowId);
|
||||
const runsQuery = useWorkflowRunsQuery(workflowId);
|
||||
@@ -66,66 +70,76 @@ export function WorkflowDetailPanel({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col border-l bg-background pt-4"
|
||||
className={
|
||||
showHeader
|
||||
? "flex h-full flex-col border-l bg-background pt-4"
|
||||
: "flex h-full flex-col"
|
||||
}
|
||||
data-testid="workflow-detail-panel"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{workflow ? (
|
||||
<h3 className="truncate text-sm font-semibold">
|
||||
{workflow.name}
|
||||
</h3>
|
||||
) : (
|
||||
<Skeleton className="h-4 w-36" />
|
||||
)}
|
||||
{workflowStatus ? <RunStatusBadge status={workflowStatus} /> : null}
|
||||
{showHeader ? (
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{workflow ? (
|
||||
<h3 className="truncate text-sm font-semibold">
|
||||
{workflow.name}
|
||||
</h3>
|
||||
) : (
|
||||
<Skeleton className="h-4 w-36" />
|
||||
)}
|
||||
{workflowStatus ? (
|
||||
<RunStatusBadge status={workflowStatus} />
|
||||
) : null}
|
||||
</div>
|
||||
{workflowDescription ? (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{workflowDescription}
|
||||
</p>
|
||||
) : workflowQuery.isLoading ? (
|
||||
<Skeleton className="mt-1 h-3 w-full max-w-64" />
|
||||
) : null}
|
||||
{triggerSummary ? (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{triggerSummary}
|
||||
</p>
|
||||
) : workflowQuery.isLoading ? (
|
||||
<Skeleton className="mt-1 h-3 w-40" />
|
||||
) : null}
|
||||
</div>
|
||||
{workflowDescription ? (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{workflowDescription}
|
||||
</p>
|
||||
) : workflowQuery.isLoading ? (
|
||||
<Skeleton className="mt-1 h-3 w-full max-w-64" />
|
||||
) : null}
|
||||
{triggerSummary ? (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{triggerSummary}
|
||||
</p>
|
||||
) : workflowQuery.isLoading ? (
|
||||
<Skeleton className="mt-1 h-3 w-40" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{workflow ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{workflow && onEdit ? (
|
||||
<Button
|
||||
onClick={() => onEdit(workflow)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Pencil className="mr-1 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={() => onEdit(workflow)}
|
||||
disabled={triggerMutation.isPending || workflowQuery.isLoading}
|
||||
onClick={() => void handleTrigger()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Pencil className="mr-1 h-4 w-4" />
|
||||
Edit
|
||||
<Play className="mr-1 h-4 w-4" />
|
||||
{triggerMutation.isPending ? "Triggering..." : "Trigger"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={triggerMutation.isPending || workflowQuery.isLoading}
|
||||
onClick={() => void handleTrigger()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Play className="mr-1 h-4 w-4" />
|
||||
{triggerMutation.isPending ? "Triggering..." : "Trigger"}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Close detail panel"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
{onClose ? (
|
||||
<Button
|
||||
aria-label="Close detail panel"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{triggerMutation.isError ? (
|
||||
<div
|
||||
@@ -144,20 +158,28 @@ export function WorkflowDetailPanel({
|
||||
data-scroll-restoration-id={`workflow-detail:${workflowId}`}
|
||||
>
|
||||
{workflow ? (
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Definition
|
||||
</h4>
|
||||
<pre className="max-h-64 overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs leading-relaxed">
|
||||
{JSON.stringify(workflow.definition, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
showHeader ? "space-y-4 p-4" : "space-y-4 px-5 pb-5 pt-2"
|
||||
}
|
||||
>
|
||||
{showDefinition ? (
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Definition
|
||||
</h4>
|
||||
<pre className="max-h-64 overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs leading-relaxed">
|
||||
{JSON.stringify(workflow.definition, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Run History
|
||||
</h4>
|
||||
{showHeader ? (
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Run History
|
||||
</h4>
|
||||
) : null}
|
||||
{runsQuery.isError ? (
|
||||
<div
|
||||
className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
@@ -297,7 +319,11 @@ export function WorkflowDetailPanel({
|
||||
<p className="text-sm text-red-400">Failed to load workflow</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 p-4">
|
||||
<div
|
||||
className={
|
||||
showHeader ? "space-y-4 p-4" : "space-y-4 px-5 pb-5 pt-2"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<Skeleton className="mb-2 h-4 w-28" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
|
||||
@@ -1,32 +1,73 @@
|
||||
import * as React from "react";
|
||||
import { Check, Code, Pencil, X } from "lucide-react";
|
||||
import { useBlocker } from "@tanstack/react-router";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
|
||||
import {
|
||||
useCreateWorkflowMutation,
|
||||
useUpdateWorkflowMutation,
|
||||
} from "@/features/workflows/hooks";
|
||||
import { generateBackupPassphrase } from "@/shared/api/tauriIdentity";
|
||||
import type { Channel, Workflow } from "@/shared/api/types";
|
||||
import { getRelayHttpUrl } from "@/shared/api/tauri";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Popover, PopoverContent } from "@/shared/ui/popover";
|
||||
import { ChannelCombobox } from "./ChannelCombobox";
|
||||
import { WorkflowFormBuilder } from "./WorkflowFormBuilder";
|
||||
import { WorkflowActionsMenu } from "./WorkflowActionsMenu";
|
||||
import { WorkflowDetailPanel } from "./WorkflowDetailPanel";
|
||||
import {
|
||||
WorkflowFormBuilder,
|
||||
type WorkflowEditorMode,
|
||||
type WorkflowFormBuilderHandle,
|
||||
} from "./WorkflowFormBuilder";
|
||||
import { WorkflowWebhookSecretDialog } from "./WorkflowWebhookSecretDialog";
|
||||
import { FieldLabel } from "./workflowFormPrimitives";
|
||||
import { getWorkflowEnabled } from "./workflowDefinition";
|
||||
import type { WorkflowEditorPane } from "./workflowEditorPane";
|
||||
import {
|
||||
DEFAULT_FORM_STATE,
|
||||
formStateToYaml,
|
||||
yamlToFormState,
|
||||
} from "./workflowFormTypes";
|
||||
import {
|
||||
readWorkflowHeaderState,
|
||||
yamlWithWorkflowEnabled,
|
||||
yamlWithWorkflowName,
|
||||
} from "./workflowYamlDocument";
|
||||
|
||||
type DialogMode = "create" | "edit" | "duplicate";
|
||||
|
||||
type WorkflowDialogProps = {
|
||||
channels: Channel[];
|
||||
initialChannelId?: string;
|
||||
mode: DialogMode;
|
||||
onDeleteWorkflow: (workflow: Workflow) => void;
|
||||
onDuplicateWorkflow: (workflowId: string) => void;
|
||||
onEditWorkflow: (workflowId: string) => void;
|
||||
onEditorPaneChange: (pane: WorkflowEditorPane) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onTriggerWorkflow: (workflowId: string) => void;
|
||||
open: boolean;
|
||||
pane: WorkflowEditorPane;
|
||||
workflow?: Workflow | null;
|
||||
};
|
||||
|
||||
@@ -42,93 +83,323 @@ function getInitialYaml(
|
||||
return yamlStringify(def);
|
||||
}
|
||||
|
||||
function getInitialEditorMode(yaml: string): WorkflowEditorMode {
|
||||
if (!yaml) return "form";
|
||||
return yamlToFormState(yaml).ok ? "form" : "yaml";
|
||||
}
|
||||
|
||||
const TITLES: Record<DialogMode, string> = {
|
||||
create: "Create Workflow",
|
||||
edit: "Edit Workflow",
|
||||
duplicate: "Duplicate Workflow",
|
||||
create: "Create workflow",
|
||||
edit: "Edit workflow",
|
||||
duplicate: "Duplicate workflow",
|
||||
};
|
||||
|
||||
const SUBMIT_LABELS: Record<DialogMode, string> = {
|
||||
create: "Create",
|
||||
edit: "Save",
|
||||
duplicate: "Create Copy",
|
||||
create: "Create workflow",
|
||||
edit: "Save changes",
|
||||
duplicate: "Create copy",
|
||||
};
|
||||
|
||||
const PENDING_LABELS: Record<DialogMode, string> = {
|
||||
create: "Creating...",
|
||||
edit: "Saving...",
|
||||
duplicate: "Creating...",
|
||||
create: "Creating…",
|
||||
edit: "Saving…",
|
||||
duplicate: "Creating…",
|
||||
};
|
||||
|
||||
function WorkflowNameEditor({
|
||||
disabled,
|
||||
generating,
|
||||
name,
|
||||
onCommit,
|
||||
onEditingChange,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
generating: boolean;
|
||||
name: string;
|
||||
onCommit: (name: string) => boolean;
|
||||
onEditingChange: (editing: boolean) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
const [draft, setDraft] = React.useState(name);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!editing) setDraft(name);
|
||||
}, [editing, name]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (editing) inputRef.current?.select();
|
||||
}, [editing]);
|
||||
|
||||
const changeEditing = React.useCallback(
|
||||
(nextEditing: boolean) => {
|
||||
setEditing(nextEditing);
|
||||
onEditingChange(nextEditing);
|
||||
},
|
||||
[onEditingChange],
|
||||
);
|
||||
|
||||
const commit = React.useCallback(() => {
|
||||
const nextName = inputRef.current?.value.trim() ?? draft.trim();
|
||||
if (!nextName || !onCommit(nextName)) return;
|
||||
changeEditing(false);
|
||||
}, [changeEditing, draft, onCommit]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="flex h-6 items-center gap-1.5">
|
||||
<Input
|
||||
aria-label="Workflow name"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className="h-6 w-72 px-2 font-mono text-sm"
|
||||
disabled={disabled}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setDraft(name);
|
||||
changeEditing(false);
|
||||
}
|
||||
}}
|
||||
ref={inputRef}
|
||||
defaultValue={name}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Save workflow name"
|
||||
className="text-muted-foreground"
|
||||
disabled={disabled || !draft.trim()}
|
||||
onClick={commit}
|
||||
onPointerDown={(event) => {
|
||||
// Commit before pointer focus changes can re-render the portalled
|
||||
// header controls and restore the generated name.
|
||||
event.preventDefault();
|
||||
commit();
|
||||
}}
|
||||
size="icon-xs"
|
||||
title="Save workflow name"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Check />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline-flex h-6 max-w-full min-w-0 items-center gap-1.5 font-mono text-sm text-muted-foreground">
|
||||
<span className="min-w-0 truncate">
|
||||
{generating ? "Generating name…" : name || "Untitled workflow"}
|
||||
</span>
|
||||
<Button
|
||||
aria-label="Edit workflow name"
|
||||
className="text-muted-foreground [&_svg]:size-3"
|
||||
disabled={disabled || generating}
|
||||
onClick={() => changeEditing(true)}
|
||||
size="icon-xs"
|
||||
title="Edit workflow name"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowDialog({
|
||||
channels,
|
||||
initialChannelId,
|
||||
mode,
|
||||
onDeleteWorkflow,
|
||||
onDuplicateWorkflow,
|
||||
onEditWorkflow,
|
||||
onEditorPaneChange,
|
||||
onOpenChange,
|
||||
onTriggerWorkflow,
|
||||
open,
|
||||
pane,
|
||||
workflow,
|
||||
}: WorkflowDialogProps) {
|
||||
const formBuilderRef = React.useRef<WorkflowFormBuilderHandle>(null);
|
||||
const workflowSnapshotRef = React.useRef(workflow);
|
||||
const workflowSnapshot = workflowSnapshotRef.current;
|
||||
const channelId =
|
||||
mode === "edit" && workflow?.channelId
|
||||
? workflow.channelId
|
||||
: (channels[0]?.id ?? "");
|
||||
mode === "edit" && workflowSnapshot?.channelId
|
||||
? workflowSnapshot.channelId
|
||||
: mode === "create" &&
|
||||
initialChannelId &&
|
||||
channels.some((channel) => channel.id === initialChannelId)
|
||||
? initialChannelId
|
||||
: "";
|
||||
|
||||
const [selectedChannelId, setSelectedChannelId] = React.useState(channelId);
|
||||
const [yamlDefinition, setYamlDefinition] = React.useState(() =>
|
||||
getInitialYaml(mode, workflow),
|
||||
getInitialYaml(mode, workflowSnapshot),
|
||||
);
|
||||
const [editorMode, setEditorMode] = React.useState<WorkflowEditorMode>(() =>
|
||||
getInitialEditorMode(getInitialYaml(mode, workflowSnapshot)),
|
||||
);
|
||||
const [editorParseError, setEditorParseError] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [workflowNameEditing, setWorkflowNameEditing] = React.useState(false);
|
||||
const [historyOpen, setHistoryOpen] = React.useState(false);
|
||||
const [channelAutoOpenPending, setChannelAutoOpenPending] = React.useState(
|
||||
mode === "create" && !channelId,
|
||||
);
|
||||
const [nameLeadingElement, setNameLeadingElement] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
const [savedWebhookInfo, setSavedWebhookInfo] = React.useState<{
|
||||
relayHttpUrl: string;
|
||||
relayHttpUrl: string | null;
|
||||
relayUrlError: string | null;
|
||||
webhookSecret: string;
|
||||
workflowId: string;
|
||||
} | null>(null);
|
||||
const [discardConfirmationOpen, setDiscardConfirmationOpen] =
|
||||
React.useState(false);
|
||||
const [secretConfirmationOpen, setSecretConfirmationOpen] =
|
||||
React.useState(false);
|
||||
const [generatingName, setGeneratingName] = React.useState(false);
|
||||
const initialValuesRef = React.useRef({
|
||||
channelId,
|
||||
yaml: getInitialYaml(mode, workflowSnapshot),
|
||||
});
|
||||
const yamlDefinitionRef = React.useRef(yamlDefinition);
|
||||
const allowNavigationRef = React.useRef(false);
|
||||
const proceedingNavigationRef = React.useRef(false);
|
||||
const pendingEditorTransitionRef = React.useRef<(() => void) | null>(null);
|
||||
|
||||
const createMutation = useCreateWorkflowMutation(selectedChannelId);
|
||||
const updateMutation = useUpdateWorkflowMutation(
|
||||
workflow?.id ?? "",
|
||||
workflow?.revision ?? "",
|
||||
workflowSnapshot?.id ?? "",
|
||||
workflowSnapshot?.revision ?? "",
|
||||
);
|
||||
const mutation = mode === "edit" ? updateMutation : createMutation;
|
||||
|
||||
const selectedChannel =
|
||||
channels.find((c) => c.id === selectedChannelId) ?? null;
|
||||
const parsedDefinition = yamlDefinition.trim()
|
||||
? yamlToFormState(yamlDefinition)
|
||||
: null;
|
||||
const isAddingFirstStep =
|
||||
mode === "create" &&
|
||||
editorMode === "form" &&
|
||||
(parsedDefinition === null ||
|
||||
(parsedDefinition.ok && parsedDefinition.state.steps.length === 0));
|
||||
|
||||
const defaultChannelId = channels[0]?.id ?? "";
|
||||
const workflowChannelId = workflow?.channelId ?? null;
|
||||
const resetCreate = createMutation.reset;
|
||||
const resetUpdate = updateMutation.reset;
|
||||
|
||||
// Re-initialize when dialog opens or workflow/mode changes
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
const newChannelId =
|
||||
mode === "edit" && workflowChannelId
|
||||
? workflowChannelId
|
||||
: defaultChannelId;
|
||||
setSelectedChannelId(newChannelId);
|
||||
setYamlDefinition(getInitialYaml(mode, workflow));
|
||||
setSavedWebhookInfo(null);
|
||||
resetCreate();
|
||||
resetUpdate();
|
||||
let active = true;
|
||||
setSavedWebhookInfo(null);
|
||||
setDiscardConfirmationOpen(false);
|
||||
resetCreate();
|
||||
resetUpdate();
|
||||
|
||||
if (mode === "create" && !workflowSnapshot) {
|
||||
setGeneratingName(true);
|
||||
void generateBackupPassphrase({ words: 3, separator: "-" })
|
||||
.then((name) => {
|
||||
if (!active || yamlDefinitionRef.current.trim()) return;
|
||||
const generatedYaml = formStateToYaml({
|
||||
...DEFAULT_FORM_STATE,
|
||||
name,
|
||||
});
|
||||
yamlDefinitionRef.current = generatedYaml;
|
||||
initialValuesRef.current = {
|
||||
...initialValuesRef.current,
|
||||
yaml: generatedYaml,
|
||||
};
|
||||
setYamlDefinition(generatedYaml);
|
||||
formBuilderRef.current?.synchronizeYaml(generatedYaml);
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave the editable "Untitled workflow" fallback in place.
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setGeneratingName(false);
|
||||
});
|
||||
} else {
|
||||
setGeneratingName(false);
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
mode,
|
||||
workflow,
|
||||
workflowChannelId,
|
||||
defaultChannelId,
|
||||
resetCreate,
|
||||
resetUpdate,
|
||||
]);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [mode, resetCreate, resetUpdate, workflowSnapshot]);
|
||||
|
||||
const closeDialog = React.useCallback(() => {
|
||||
resetCreate();
|
||||
resetUpdate();
|
||||
setDiscardConfirmationOpen(false);
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange, resetCreate, resetUpdate]);
|
||||
|
||||
const isDirty =
|
||||
yamlDefinition !== initialValuesRef.current.yaml ||
|
||||
selectedChannelId !== initialValuesRef.current.channelId;
|
||||
const navigationBlocker = useBlocker({
|
||||
enableBeforeUnload: isDirty || savedWebhookInfo !== null,
|
||||
shouldBlockFn: ({ current, next }) => {
|
||||
const currentSearch = current.search as {
|
||||
pane?: unknown;
|
||||
view?: unknown;
|
||||
};
|
||||
const nextSearch = next.search as { pane?: unknown; view?: unknown };
|
||||
const isPaneOnlyNavigation =
|
||||
current.pathname === next.pathname &&
|
||||
currentSearch.view === nextSearch.view &&
|
||||
currentSearch.pane !== nextSearch.pane;
|
||||
return (
|
||||
(isDirty || savedWebhookInfo !== null) &&
|
||||
!allowNavigationRef.current &&
|
||||
!isPaneOnlyNavigation
|
||||
);
|
||||
},
|
||||
withResolver: true,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (navigationBlocker.status === "blocked") {
|
||||
if (savedWebhookInfo) {
|
||||
setSecretConfirmationOpen(true);
|
||||
} else {
|
||||
setDiscardConfirmationOpen(true);
|
||||
}
|
||||
}
|
||||
}, [navigationBlocker.status, savedWebhookInfo]);
|
||||
|
||||
const requestEditorTransition = React.useCallback(
|
||||
(transition: () => void) => {
|
||||
if (isDirty) {
|
||||
pendingEditorTransitionRef.current = transition;
|
||||
setDiscardConfirmationOpen(true);
|
||||
return;
|
||||
}
|
||||
transition();
|
||||
},
|
||||
[isDirty],
|
||||
);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
resetCreate();
|
||||
resetUpdate();
|
||||
if (nextOpen) {
|
||||
onOpenChange(true);
|
||||
} else if (savedWebhookInfo) {
|
||||
setSecretConfirmationOpen(true);
|
||||
} else if (isDirty) {
|
||||
setDiscardConfirmationOpen(true);
|
||||
} else {
|
||||
closeDialog();
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
},
|
||||
[onOpenChange, resetCreate, resetUpdate],
|
||||
[closeDialog, isDirty, onOpenChange, savedWebhookInfo],
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -136,118 +407,472 @@ export function WorkflowDialog({
|
||||
|
||||
try {
|
||||
const saved = await mutation.mutateAsync(yamlDefinition);
|
||||
handleOpenChange(false);
|
||||
initialValuesRef.current = {
|
||||
channelId: selectedChannelId,
|
||||
yaml: yamlDefinition,
|
||||
};
|
||||
if (saved.webhookSecret) {
|
||||
const relayHttpUrl = await getRelayHttpUrl();
|
||||
setSavedWebhookInfo({
|
||||
relayHttpUrl,
|
||||
allowNavigationRef.current = false;
|
||||
const webhookInfo = {
|
||||
relayHttpUrl: null,
|
||||
relayUrlError: null,
|
||||
webhookSecret: saved.webhookSecret,
|
||||
workflowId: saved.workflow.id,
|
||||
});
|
||||
};
|
||||
setSavedWebhookInfo(webhookInfo);
|
||||
try {
|
||||
const relayHttpUrl = await getRelayHttpUrl();
|
||||
setSavedWebhookInfo({ ...webhookInfo, relayHttpUrl });
|
||||
} catch (error) {
|
||||
setSavedWebhookInfo({
|
||||
...webhookInfo,
|
||||
relayUrlError:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Could not load the webhook URL",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
allowNavigationRef.current = true;
|
||||
closeDialog();
|
||||
}
|
||||
} catch {
|
||||
// React Query stores the error; keep the dialog open.
|
||||
// React Query stores the error; keep the dialog open and dirty.
|
||||
}
|
||||
}
|
||||
|
||||
const showChannelSelector = mode !== "edit" && channels.length > 1;
|
||||
const showChannelInfo = mode !== "edit" && channels.length === 1;
|
||||
const handleEditorModeChange = React.useCallback(
|
||||
(nextMode: string) => {
|
||||
if (nextMode === editorMode) return;
|
||||
|
||||
if (nextMode === "yaml") {
|
||||
setEditorParseError(null);
|
||||
setEditorMode("yaml");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!yamlDefinition.trim()) {
|
||||
setEditorParseError(null);
|
||||
setEditorMode("form");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = yamlToFormState(yamlDefinition);
|
||||
if (result.ok) {
|
||||
setEditorParseError(null);
|
||||
setEditorMode("form");
|
||||
} else {
|
||||
setEditorParseError(result.error);
|
||||
}
|
||||
},
|
||||
[editorMode, yamlDefinition],
|
||||
);
|
||||
|
||||
// Header state reads the YAML document directly rather than the fully
|
||||
// validated form state: a step that is still being filled in (a new
|
||||
// send_message with no text yet) fails form validation, and gating the name
|
||||
// on that made the title blank out as soon as a step pane opened.
|
||||
const {
|
||||
canEdit: canEditWorkflowName,
|
||||
enabled: workflowEnabled,
|
||||
name: workflowName,
|
||||
} = readWorkflowHeaderState(yamlDefinition, {
|
||||
enabled: workflowSnapshot
|
||||
? getWorkflowEnabled(workflowSnapshot.definition)
|
||||
: true,
|
||||
name: workflowSnapshot?.name,
|
||||
});
|
||||
const handleWorkflowNameCommit = React.useCallback(
|
||||
(name: string) => {
|
||||
const nextYaml = yamlWithWorkflowName(yamlDefinitionRef.current, name);
|
||||
if (nextYaml === null) return false;
|
||||
mutation.reset();
|
||||
yamlDefinitionRef.current = nextYaml;
|
||||
setYamlDefinition(nextYaml);
|
||||
return true;
|
||||
},
|
||||
[mutation.reset],
|
||||
);
|
||||
const handleToggleWorkflowEnabled = React.useCallback(() => {
|
||||
const nextYaml = yamlWithWorkflowEnabled(
|
||||
yamlDefinitionRef.current,
|
||||
!workflowEnabled,
|
||||
);
|
||||
if (nextYaml === null) return;
|
||||
mutation.reset();
|
||||
yamlDefinitionRef.current = nextYaml;
|
||||
setYamlDefinition(nextYaml);
|
||||
}, [mutation.reset, workflowEnabled]);
|
||||
const showChannelSelector = mode !== "edit";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-lg">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{TITLES[mode]}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === "edit"
|
||||
? "Modify the workflow definition."
|
||||
: channels.length === 1
|
||||
? "Create a workflow scoped to this channel."
|
||||
: "Define a workflow and assign it to a channel."}
|
||||
</DialogDescription>
|
||||
<Dialog
|
||||
onOpenChange={handleOpenChange}
|
||||
open={open && savedWebhookInfo === null}
|
||||
>
|
||||
<DialogContent
|
||||
className="flex h-[88vh] max-h-[88vh] w-[calc(100vw-2rem)] max-w-6xl flex-col gap-0 overflow-hidden p-0"
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (formBuilderRef.current?.closeInspector()) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader className="flex flex-shrink-0 flex-row items-center justify-between gap-6 space-y-0 px-6 pt-3 pb-2 text-left">
|
||||
<div className="space-y-0">
|
||||
<DialogTitle className="text-lg leading-tight">
|
||||
{TITLES[mode]}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{mode === "edit"
|
||||
? "Update when this workflow runs and what it does."
|
||||
: mode === "duplicate"
|
||||
? "Copy this workflow and adjust its details."
|
||||
: "Automate actions when something happens in a channel."}
|
||||
</DialogDescription>
|
||||
<div className="flex items-center gap-2">
|
||||
<WorkflowNameEditor
|
||||
disabled={mutation.isPending || !canEditWorkflowName}
|
||||
generating={generatingName}
|
||||
name={workflowName}
|
||||
onCommit={handleWorkflowNameCommit}
|
||||
onEditingChange={setWorkflowNameEditing}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
mode !== "edit" &&
|
||||
editorMode === "form" &&
|
||||
!workflowNameEditing
|
||||
? "flex items-center"
|
||||
: "hidden"
|
||||
}
|
||||
ref={setNameLeadingElement}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{mode === "edit" && workflowSnapshot ? (
|
||||
<>
|
||||
<Popover onOpenChange={setHistoryOpen} open={historyOpen}>
|
||||
{/* TODO(workflow-run-history-capability): Restore this
|
||||
icon-only entry point after Desktop gates it on the active
|
||||
relay's advertised NIP-11 capabilities.
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label="Run history"
|
||||
className="h-8 w-8"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant={historyOpen ? "secondary" : "outline"}
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
*/}
|
||||
<PopoverContent
|
||||
align="end"
|
||||
aria-label="Run history"
|
||||
className="flex max-h-[min(32rem,var(--radix-popover-content-available-height))] w-96 max-w-[calc(100vw-2rem)] flex-col overflow-hidden p-0"
|
||||
data-testid="workflow-history-dropdown"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="flex-shrink-0 border-b px-5 py-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Workflow
|
||||
</p>
|
||||
<h3 className="text-base font-semibold">Run history</h3>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<WorkflowDetailPanel
|
||||
showDefinition={false}
|
||||
showHeader={false}
|
||||
workflowId={workflowSnapshot.id}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<WorkflowActionsMenu
|
||||
isEnabled={workflowEnabled}
|
||||
isTogglingEnabled={
|
||||
mutation.isPending || !canEditWorkflowName
|
||||
}
|
||||
onDelete={() => onDeleteWorkflow(workflowSnapshot)}
|
||||
onDuplicate={() =>
|
||||
requestEditorTransition(() =>
|
||||
onDuplicateWorkflow(workflowSnapshot.id),
|
||||
)
|
||||
}
|
||||
onEdit={() =>
|
||||
requestEditorTransition(() =>
|
||||
onEditWorkflow(workflowSnapshot.id),
|
||||
)
|
||||
}
|
||||
onToggleEnabled={handleToggleWorkflowEnabled}
|
||||
onTrigger={() => onTriggerWorkflow(workflowSnapshot.id)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
aria-label="Close"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
|
||||
{showChannelSelector ? (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-channel-select">Channel</FieldLabel>
|
||||
<ChannelCombobox
|
||||
channels={channels}
|
||||
disabled={mutation.isPending}
|
||||
id="wf-channel-select"
|
||||
onChange={(value) => {
|
||||
mutation.reset();
|
||||
setSelectedChannelId(value);
|
||||
}}
|
||||
value={selectedChannelId}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedChannel
|
||||
? `New workflows will belong to ${selectedChannel.name}.`
|
||||
: "Join or create a channel before adding a workflow."}
|
||||
</p>
|
||||
</div>
|
||||
) : (showChannelInfo || mode === "edit") && selectedChannel ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{mode === "edit"
|
||||
? "Editing workflow in"
|
||||
: "This workflow will be created in"}{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{selectedChannel.name}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<WorkflowFormBuilder
|
||||
channels={channels}
|
||||
disabled={mutation.isPending}
|
||||
mode={editorMode}
|
||||
nameLeadingContainer={mode === "edit" ? null : nameLeadingElement}
|
||||
onChange={(yaml) => {
|
||||
mutation.reset();
|
||||
yamlDefinitionRef.current = yaml;
|
||||
setYamlDefinition(yaml);
|
||||
}}
|
||||
onSelectedNodeChange={onEditorPaneChange}
|
||||
parseError={editorParseError}
|
||||
ref={formBuilderRef}
|
||||
scopeField={
|
||||
showChannelSelector ? (
|
||||
<div className="space-y-1">
|
||||
<ChannelCombobox
|
||||
channels={channels}
|
||||
defaultOpen={
|
||||
channelAutoOpenPending &&
|
||||
editorMode === "form" &&
|
||||
!selectedChannelId
|
||||
}
|
||||
disabled={mutation.isPending}
|
||||
id="wf-channel-select"
|
||||
onAutoOpen={() => setChannelAutoOpenPending(false)}
|
||||
onChange={(value) => {
|
||||
mutation.reset();
|
||||
setSelectedChannelId(value);
|
||||
if (value) onEditorPaneChange({ type: "trigger" });
|
||||
}}
|
||||
required
|
||||
variant={editorMode === "yaml" ? "field" : "header"}
|
||||
value={selectedChannelId}
|
||||
/>
|
||||
{channels.length === 0 ? (
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Join or create a channel before adding a workflow.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : mode === "edit" && selectedChannel ? (
|
||||
<ChannelCombobox
|
||||
channels={channels}
|
||||
id="wf-channel-select"
|
||||
onChange={setSelectedChannelId}
|
||||
readOnly
|
||||
variant={editorMode === "yaml" ? "field" : "header"}
|
||||
value={selectedChannel.id}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
selectedNode={
|
||||
mode === "create" && !selectedChannelId ? null : pane
|
||||
}
|
||||
workflowChannelId={selectedChannelId || null}
|
||||
yaml={yamlDefinition}
|
||||
/>
|
||||
|
||||
{mutation.error instanceof Error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{mutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 justify-end gap-2 border-t border-border pt-4">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
{mutation.error instanceof Error ? (
|
||||
<p
|
||||
aria-live="polite"
|
||||
className="mx-6 mb-3 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!selectedChannelId ||
|
||||
!yamlDefinition.trim() ||
|
||||
mutation.isPending
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
type="button"
|
||||
>
|
||||
{mutation.isPending ? PENDING_LABELS[mode] : SUBMIT_LABELS[mode]}
|
||||
</Button>
|
||||
{mutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-shrink-0 items-center justify-between gap-4 px-6 pt-2 pb-4">
|
||||
<Tabs onValueChange={handleEditorModeChange} value={editorMode}>
|
||||
<TabsList aria-label="Workflow editor mode" className="h-8 p-0.5">
|
||||
<TabsTrigger
|
||||
className="h-7 px-3 text-xs"
|
||||
disabled={mutation.isPending}
|
||||
value="form"
|
||||
>
|
||||
Form
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
className="h-7 gap-1.5 px-3 text-xs"
|
||||
disabled={mutation.isPending}
|
||||
value="yaml"
|
||||
>
|
||||
<Code className="h-3.5 w-3.5" />
|
||||
YAML
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{isAddingFirstStep ? (
|
||||
<Button
|
||||
aria-label="Add first step"
|
||||
data-testid="workflow-dialog-primary-action"
|
||||
disabled={!selectedChannelId || mutation.isPending}
|
||||
onClick={() => formBuilderRef.current?.addFirstStep()}
|
||||
type="button"
|
||||
>
|
||||
Add step
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
data-testid="workflow-dialog-primary-action"
|
||||
disabled={
|
||||
!selectedChannelId ||
|
||||
!yamlDefinition.trim() ||
|
||||
mutation.isPending
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
type="button"
|
||||
>
|
||||
{mutation.isPending
|
||||
? PENDING_LABELS[mode]
|
||||
: SUBMIT_LABELS[mode]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
setDiscardConfirmationOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
pendingEditorTransitionRef.current = null;
|
||||
}
|
||||
if (
|
||||
!nextOpen &&
|
||||
navigationBlocker.status === "blocked" &&
|
||||
!proceedingNavigationRef.current
|
||||
) {
|
||||
navigationBlocker.reset();
|
||||
}
|
||||
}}
|
||||
open={discardConfirmationOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Discard changes?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Your unsaved workflow changes will be lost.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Keep editing
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const pendingEditorTransition =
|
||||
pendingEditorTransitionRef.current;
|
||||
pendingEditorTransitionRef.current = null;
|
||||
setDiscardConfirmationOpen(false);
|
||||
if (pendingEditorTransition) {
|
||||
allowNavigationRef.current = true;
|
||||
pendingEditorTransition();
|
||||
return;
|
||||
}
|
||||
if (navigationBlocker.status === "blocked") {
|
||||
proceedingNavigationRef.current = true;
|
||||
navigationBlocker.proceed();
|
||||
return;
|
||||
}
|
||||
allowNavigationRef.current = true;
|
||||
closeDialog();
|
||||
}}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
Discard changes
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
setSecretConfirmationOpen(nextOpen);
|
||||
if (
|
||||
!nextOpen &&
|
||||
navigationBlocker.status === "blocked" &&
|
||||
!proceedingNavigationRef.current
|
||||
) {
|
||||
navigationBlocker.reset();
|
||||
}
|
||||
}}
|
||||
open={secretConfirmationOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Continue without this secret?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This private webhook secret cannot be recovered. Copy and store it
|
||||
before continuing, or explicitly leave it behind.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Go back
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSecretConfirmationOpen(false);
|
||||
allowNavigationRef.current = true;
|
||||
if (navigationBlocker.status === "blocked") {
|
||||
proceedingNavigationRef.current = true;
|
||||
navigationBlocker.proceed();
|
||||
return;
|
||||
}
|
||||
closeDialog();
|
||||
}}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{savedWebhookInfo ? (
|
||||
<WorkflowWebhookSecretDialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setSavedWebhookInfo(null);
|
||||
}
|
||||
}}
|
||||
onContinue={() => setSecretConfirmationOpen(true)}
|
||||
open
|
||||
relayHttpUrl={savedWebhookInfo.relayHttpUrl}
|
||||
relayUrlError={savedWebhookInfo.relayUrlError}
|
||||
webhookSecret={savedWebhookInfo.webhookSecret}
|
||||
workflowId={savedWebhookInfo.workflowId}
|
||||
/>
|
||||
|
||||
95
desktop/src/features/workflows/ui/WorkflowDurationField.tsx
Normal file
95
desktop/src/features/workflows/ui/WorkflowDurationField.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { FieldLabel } from "./workflowFormPrimitives";
|
||||
import {
|
||||
DEFAULT_DURATION_SECONDS,
|
||||
DURATION_SLIDER_STOPS,
|
||||
durationSliderIndex,
|
||||
formatDurationSeconds,
|
||||
parseDurationSeconds,
|
||||
} from "./workflowDuration";
|
||||
|
||||
export function WorkflowDurationField({
|
||||
disabled,
|
||||
fallbackSeconds = DEFAULT_DURATION_SECONDS,
|
||||
hideLabel = false,
|
||||
id,
|
||||
label = "Duration",
|
||||
onChange,
|
||||
placeholder = "1s",
|
||||
value,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
fallbackSeconds?: number;
|
||||
hideLabel?: boolean;
|
||||
id: string;
|
||||
label?: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
}) {
|
||||
const parsedSeconds = parseDurationSeconds(value);
|
||||
const sliderIndex = durationSliderIndex(
|
||||
Math.max(DURATION_SLIDER_STOPS[0], parsedSeconds ?? fallbackSeconds),
|
||||
);
|
||||
const progress = (sliderIndex / (DURATION_SLIDER_STOPS.length - 1)) * 100;
|
||||
const sliderSeconds = DURATION_SLIDER_STOPS[sliderIndex];
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{hideLabel ? (
|
||||
<label className="sr-only" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
) : (
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex h-9 min-w-0 flex-1 items-center">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 h-1.5 overflow-hidden rounded-full bg-muted"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
aria-label={`${label} slider`}
|
||||
aria-valuetext={formatDurationSeconds(sliderSeconds)}
|
||||
className="absolute inset-x-0 h-9 w-full cursor-pointer appearance-none bg-transparent disabled:cursor-not-allowed disabled:opacity-50 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm [&::-moz-range-track]:bg-transparent [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent [&::-webkit-slider-thumb]:mt-[-0.3125rem] [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm focus-visible:outline-hidden focus-visible:[&::-moz-range-thumb]:ring-2 focus-visible:[&::-moz-range-thumb]:ring-ring focus-visible:[&::-moz-range-thumb]:ring-offset-2 focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-ring focus-visible:[&::-webkit-slider-thumb]:ring-offset-2"
|
||||
disabled={disabled}
|
||||
max={DURATION_SLIDER_STOPS.length - 1}
|
||||
min={0}
|
||||
onChange={(event) => {
|
||||
const seconds = DURATION_SLIDER_STOPS[Number(event.target.value)];
|
||||
onChange(formatDurationSeconds(seconds));
|
||||
}}
|
||||
type="range"
|
||||
value={sliderIndex}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className="w-28 shrink-0 text-center tabular-nums"
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
onBlur={() => {
|
||||
if (parsedSeconds !== null) {
|
||||
onChange(
|
||||
formatDurationSeconds(
|
||||
Math.max(DURATION_SLIDER_STOPS[0], parsedSeconds),
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
desktop/src/features/workflows/ui/WorkflowEditorHost.tsx
Normal file
110
desktop/src/features/workflows/ui/WorkflowEditorHost.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useWorkflowQuery } from "@/features/workflows/hooks";
|
||||
import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog";
|
||||
import { WorkflowUnavailableDialog } from "@/features/workflows/ui/WorkflowUnavailableDialog";
|
||||
import type { Channel, Workflow } from "@/shared/api/types";
|
||||
import type { WorkflowEditorPane } from "./workflowEditorPane";
|
||||
|
||||
/** Create target for the shared workflow editor. */
|
||||
export type WorkflowEditorCreateTarget = {
|
||||
initialChannelId?: string;
|
||||
mode: "create";
|
||||
pane: WorkflowEditorPane;
|
||||
};
|
||||
|
||||
/** Existing-workflow target for the shared workflow editor. */
|
||||
export type WorkflowEditorWorkflowTarget = {
|
||||
mode: "detail" | "duplicate" | "edit";
|
||||
pane: WorkflowEditorPane;
|
||||
workflowId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the workflow editor is currently pointed at, independent of how it was
|
||||
* opened. The Workflows route derives this from the URL; the channel-anchored
|
||||
* overlay derives it from local state so the channel stays behind the modal.
|
||||
*/
|
||||
export type WorkflowEditorTarget =
|
||||
| WorkflowEditorCreateTarget
|
||||
| WorkflowEditorWorkflowTarget;
|
||||
|
||||
type WorkflowEditorHostProps = {
|
||||
channels: Channel[];
|
||||
editor: WorkflowEditorTarget | null;
|
||||
onClose: () => void;
|
||||
onDeleteWorkflow: (workflow: Workflow) => void;
|
||||
onDuplicateWorkflow: (workflowId: string) => void;
|
||||
onEditWorkflow: (workflowId: string) => void;
|
||||
onEditorPaneChange: (pane: WorkflowEditorPane) => void;
|
||||
onTriggerWorkflow: (workflowId: string) => void;
|
||||
/**
|
||||
* Workflow the opening surface already holds for this target. Supplying it
|
||||
* skips the loading dialog the detail query would otherwise show first.
|
||||
*/
|
||||
workflowHint?: Workflow;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the shared workflow editor (or its non-disclosing loading /
|
||||
* unavailable stand-in) for a target. Every surface that can open the editor
|
||||
* mounts this so none of them fork the editor's lifecycle.
|
||||
*/
|
||||
export function WorkflowEditorHost({
|
||||
channels,
|
||||
editor,
|
||||
onClose,
|
||||
onDeleteWorkflow,
|
||||
onDuplicateWorkflow,
|
||||
onEditWorkflow,
|
||||
onEditorPaneChange,
|
||||
onTriggerWorkflow,
|
||||
workflowHint,
|
||||
}: WorkflowEditorHostProps) {
|
||||
const editorWorkflowId =
|
||||
editor && editor.mode !== "create" ? editor.workflowId : null;
|
||||
const editorWorkflowQuery = useWorkflowQuery(editorWorkflowId);
|
||||
const editorWorkflow =
|
||||
workflowHint?.id === editorWorkflowId
|
||||
? workflowHint
|
||||
: editorWorkflowQuery.data;
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
if (editor.mode !== "create" && editorWorkflow === undefined) {
|
||||
return (
|
||||
<WorkflowUnavailableDialog
|
||||
loading={editorWorkflowQuery.isLoading}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
onRetry={() => void editorWorkflowQuery.refetch()}
|
||||
open
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkflowDialog
|
||||
channels={channels}
|
||||
initialChannelId={
|
||||
editor.mode === "create" ? editor.initialChannelId : undefined
|
||||
}
|
||||
key={
|
||||
editor.mode === "create"
|
||||
? editor.mode
|
||||
: `${editor.mode}:${editor.workflowId}`
|
||||
}
|
||||
mode={editor.mode === "detail" ? "edit" : editor.mode}
|
||||
onDeleteWorkflow={onDeleteWorkflow}
|
||||
onDuplicateWorkflow={onDuplicateWorkflow}
|
||||
onEditWorkflow={onEditWorkflow}
|
||||
onEditorPaneChange={onEditorPaneChange}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
onTriggerWorkflow={onTriggerWorkflow}
|
||||
open
|
||||
pane={editor.pane}
|
||||
workflow={editorWorkflow}
|
||||
/>
|
||||
);
|
||||
}
|
||||
96
desktop/src/features/workflows/ui/WorkflowEmojiField.tsx
Normal file
96
desktop/src/features/workflows/ui/WorkflowEmojiField.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { SmilePlus, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
|
||||
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
|
||||
import { emojiDisplayName } from "@/shared/lib/emojiName";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
|
||||
/**
|
||||
* Emoji chooser for workflow editor fields that hold a single reaction.
|
||||
*
|
||||
* Reactions are stored as the reaction event's content: a native glyph (`👍`)
|
||||
* or a custom-emoji `:shortcode:`. The shared `EmojiPicker` emits exactly that
|
||||
* string, so the selection is stored verbatim — no translation layer, and the
|
||||
* value lines up with what the executor compares `trigger_emoji` against.
|
||||
*
|
||||
* A free-text field cannot express that contract (a typed `thumbsup` never
|
||||
* matches a `👍` reaction), which is why picking is the only input path here.
|
||||
* Clearing is separate from picking: the picker has no "no emoji" cell, so an
|
||||
* optional field gets an explicit clear button that emits `undefined`.
|
||||
*/
|
||||
type WorkflowEmojiFieldProps = {
|
||||
ariaLabel: string;
|
||||
/** Renders a clear button when set and a value is present. Omit for required fields. */
|
||||
clearAriaLabel?: string;
|
||||
disabled?: boolean;
|
||||
id: string;
|
||||
onChange: (emoji: string | undefined) => void;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export function WorkflowEmojiField({
|
||||
ariaLabel,
|
||||
clearAriaLabel,
|
||||
disabled,
|
||||
id,
|
||||
onChange,
|
||||
value,
|
||||
}: WorkflowEmojiFieldProps) {
|
||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Popover onOpenChange={setPickerOpen} open={pickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label={ariaLabel}
|
||||
className="flex-1 justify-start px-3 font-normal"
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{value ? (
|
||||
<>
|
||||
<StatusEmoji className="h-5 w-5 text-base" value={value} />
|
||||
<span>{emojiDisplayName(value)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SmilePlus className="text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Choose a reaction</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-auto overflow-hidden rounded-2xl border-0 bg-transparent p-0 shadow-none"
|
||||
sideOffset={4}
|
||||
>
|
||||
<EmojiPicker
|
||||
autoFocus
|
||||
onSelect={(emoji) => {
|
||||
onChange(emoji);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{value && clearAriaLabel ? (
|
||||
<Button
|
||||
aria-label={clearAriaLabel}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(undefined)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,290 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import {
|
||||
MESSAGE_TEXT_CONDITION_LABELS,
|
||||
MESSAGE_TEXT_CONDITION_OPERATORS,
|
||||
buildMessageTextCondition,
|
||||
messageTextConditionNeedsValue,
|
||||
parseMessageTextCondition,
|
||||
} from "./workflowMessageTextCondition";
|
||||
import type { MessageTextCondition } from "./workflowMessageTextCondition";
|
||||
|
||||
const DEFAULT_CONDITION: MessageTextCondition = {
|
||||
operator: "contains",
|
||||
value: "",
|
||||
};
|
||||
|
||||
type EditorMode = "basic" | "advanced";
|
||||
|
||||
function conditionSummary(condition: MessageTextCondition): string {
|
||||
const label = MESSAGE_TEXT_CONDITION_LABELS[condition.operator].toLowerCase();
|
||||
if (!messageTextConditionNeedsValue(condition.operator)) return label;
|
||||
const value = condition.value.trim();
|
||||
return value ? `${label} “${value}”` : "Any";
|
||||
}
|
||||
|
||||
export function WorkflowMessageTextCondition({
|
||||
allowAdvanced = true,
|
||||
disabled,
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
allowAdvanced?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
}) {
|
||||
const initialParsed = React.useRef(parseMessageTextCondition(value));
|
||||
const [mode, setMode] = React.useState<EditorMode>(() =>
|
||||
value && !initialParsed.current ? "advanced" : "basic",
|
||||
);
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
() => !value || initialParsed.current !== null,
|
||||
);
|
||||
const [replaceAdvancedOpen, setReplaceAdvancedOpen] = React.useState(false);
|
||||
const [condition, setCondition] = React.useState<MessageTextCondition>(
|
||||
initialParsed.current ?? DEFAULT_CONDITION,
|
||||
);
|
||||
const [advancedDraft, setAdvancedDraft] = React.useState(value);
|
||||
const localValueRef = React.useRef(value);
|
||||
const parsed = value ? parseMessageTextCondition(value) : null;
|
||||
const hasUnsupportedExpression = Boolean(value) && parsed === null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value === localValueRef.current) return;
|
||||
localValueRef.current = value;
|
||||
setAdvancedDraft(value);
|
||||
const nextParsed = parseMessageTextCondition(value);
|
||||
if (nextParsed) {
|
||||
setCondition(nextParsed);
|
||||
setMode("basic");
|
||||
setExpanded(true);
|
||||
} else if (!value) {
|
||||
setCondition(DEFAULT_CONDITION);
|
||||
setMode("basic");
|
||||
setExpanded(true);
|
||||
} else {
|
||||
setMode("advanced");
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const emit = (expression: string) => {
|
||||
localValueRef.current = expression;
|
||||
onChange(expression);
|
||||
};
|
||||
|
||||
const updateCondition = (next: MessageTextCondition) => {
|
||||
setCondition(next);
|
||||
emit(buildMessageTextCondition(next) ?? "");
|
||||
};
|
||||
|
||||
const openBasicEditor = () => {
|
||||
if (hasUnsupportedExpression) {
|
||||
setReplaceAdvancedOpen(true);
|
||||
return;
|
||||
}
|
||||
setExpanded((current) => !current);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
className="space-y-3"
|
||||
onValueChange={(nextMode) => {
|
||||
if (!allowAdvanced) return;
|
||||
const editorMode = nextMode as EditorMode;
|
||||
setMode(editorMode);
|
||||
setExpanded(false);
|
||||
if (editorMode === "advanced") setAdvancedDraft(value);
|
||||
}}
|
||||
value={allowAdvanced ? mode : "basic"}
|
||||
>
|
||||
{allowAdvanced ? (
|
||||
<TabsList
|
||||
aria-label="Condition editor mode"
|
||||
className="grid h-9 w-full grid-cols-2 p-0.5"
|
||||
>
|
||||
<TabsTrigger className="h-8" disabled={disabled} value="basic">
|
||||
Basic
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="h-8" disabled={disabled} value="advanced">
|
||||
Advanced
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
) : null}
|
||||
|
||||
{!allowAdvanced || mode === "basic" ? (
|
||||
<div>
|
||||
{hasUnsupportedExpression ? (
|
||||
<p className="border-b border-border/50 pb-3 text-xs text-muted-foreground">
|
||||
An advanced expression is active. Choosing a basic filter will
|
||||
replace it.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="divide-y divide-border/50">
|
||||
<div className={cn(expanded && "pb-4 last:pb-0")}>
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="flex min-h-12 w-full items-center gap-3 py-3 text-left transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onClick={openBasicEditor}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-base font-medium">
|
||||
Message text
|
||||
</span>
|
||||
<span className="max-w-40 truncate text-sm text-muted-foreground">
|
||||
{hasUnsupportedExpression
|
||||
? "Any"
|
||||
: conditionSummary(condition)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground/70 transition-transform duration-150 motion-reduce:transition-none",
|
||||
expanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{expanded && !hasUnsupportedExpression ? (
|
||||
<div className="animate-in space-y-4 pt-1 fade-in slide-in-from-top-1 duration-150 motion-reduce:animate-none">
|
||||
<fieldset>
|
||||
<legend className="sr-only">Match</legend>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{MESSAGE_TEXT_CONDITION_OPERATORS.map((operator) => {
|
||||
const id = `wf-trigger-filter-trigger-text-operator-${operator}`;
|
||||
return (
|
||||
<div className="relative" key={operator}>
|
||||
<input
|
||||
checked={condition.operator === operator}
|
||||
className="peer sr-only"
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
name="wf-trigger-filter-trigger-text-operator"
|
||||
onChange={() =>
|
||||
updateCondition({ ...condition, operator })
|
||||
}
|
||||
type="radio"
|
||||
value={operator}
|
||||
/>
|
||||
<label
|
||||
className={cn(
|
||||
"flex min-h-12 cursor-pointer items-center justify-center rounded-lg border px-3 py-2 text-center text-sm font-medium",
|
||||
"outline-2 outline-offset-2 outline-transparent transition-[background-color,border-color,color,outline-color]",
|
||||
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring",
|
||||
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
condition.operator === operator
|
||||
? "border-border/0 bg-transparent text-foreground outline-foreground/45"
|
||||
: "border-border/70 bg-background/35 text-muted-foreground hover:border-border hover:bg-muted/55 hover:text-foreground hover:outline-muted-foreground/20",
|
||||
)}
|
||||
htmlFor={id}
|
||||
>
|
||||
{MESSAGE_TEXT_CONDITION_LABELS[
|
||||
operator
|
||||
].toLowerCase()}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{messageTextConditionNeedsValue(condition.operator) ? (
|
||||
<Input
|
||||
aria-label="Text to match"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
disabled={disabled}
|
||||
id="wf-trigger-filter-trigger-text-value"
|
||||
onChange={(event) =>
|
||||
updateCondition({
|
||||
...condition,
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g. deploy"
|
||||
value={condition.value}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-in space-y-2 fade-in duration-150 motion-reduce:animate-none">
|
||||
<Input
|
||||
aria-label="Advanced expression"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
disabled={disabled}
|
||||
id="wf-trigger-filter-advanced-expression"
|
||||
onChange={(event) => {
|
||||
const expression = event.target.value;
|
||||
setAdvancedDraft(expression);
|
||||
emit(expression.trim());
|
||||
}}
|
||||
placeholder='e.g. str_contains(trigger_text, "deploy")'
|
||||
value={advancedDraft}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use an evalexpr expression with <code>trigger_text</code>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
<AlertDialog
|
||||
onOpenChange={setReplaceAdvancedOpen}
|
||||
open={replaceAdvancedOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Replace advanced expression?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Choosing a basic filter will replace the entire advanced
|
||||
expression. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Keep advanced expression
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCondition(DEFAULT_CONDITION);
|
||||
setAdvancedDraft("");
|
||||
setExpanded(true);
|
||||
emit("");
|
||||
}}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
Replace expression
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
258
desktop/src/features/workflows/ui/WorkflowScheduleFields.tsx
Normal file
258
desktop/src/features/workflows/ui/WorkflowScheduleFields.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { CronExpressionInput } from "./CronExpressionInput";
|
||||
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
|
||||
import type { TriggerConfig } from "./workflowFormTypes";
|
||||
import {
|
||||
SCHEDULE_FREQUENCIES,
|
||||
SCHEDULE_FREQUENCY_LABELS,
|
||||
scheduleFormFromTrigger,
|
||||
scheduleTriggerFromForm,
|
||||
scheduleWeekdaysFromCronField,
|
||||
} from "./workflowSchedule";
|
||||
import type { ScheduleFormState } from "./workflowSchedule";
|
||||
|
||||
const WEEKDAYS = [
|
||||
["0", "Sunday", "S"],
|
||||
["1", "Monday", "M"],
|
||||
["2", "Tuesday", "T"],
|
||||
["3", "Wednesday", "W"],
|
||||
["4", "Thursday", "T"],
|
||||
["5", "Friday", "F"],
|
||||
["6", "Saturday", "S"],
|
||||
] as const;
|
||||
|
||||
const MONTH_DAYS = Array.from({ length: 31 }, (_, index) => String(index + 1));
|
||||
|
||||
function monthlyDayWarning(monthDay: string): string | null {
|
||||
return Number(monthDay) > 28
|
||||
? "This schedule won’t run in some months."
|
||||
: null;
|
||||
}
|
||||
|
||||
function customCronSeed(schedule: ScheduleFormState): string {
|
||||
const currentTrigger = scheduleTriggerFromForm(schedule);
|
||||
if (currentTrigger.cron) return currentTrigger.cron;
|
||||
|
||||
switch (currentTrigger.interval) {
|
||||
case "15m":
|
||||
return "*/15 * * * *";
|
||||
case "30m":
|
||||
return "*/30 * * * *";
|
||||
case "1h":
|
||||
return "0 * * * *";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function WorkflowScheduleFields({
|
||||
disabled,
|
||||
onUpdate,
|
||||
trigger,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onUpdate: (trigger: TriggerConfig) => void;
|
||||
trigger: TriggerConfig;
|
||||
}) {
|
||||
const [forceCustomCron, setForceCustomCron] = React.useState(false);
|
||||
const parsedSchedule = scheduleFormFromTrigger(trigger);
|
||||
const schedule: ScheduleFormState = forceCustomCron
|
||||
? {
|
||||
...parsedSchedule,
|
||||
customCron: trigger.cron ?? parsedSchedule.customCron,
|
||||
frequency: "custom_cron",
|
||||
}
|
||||
: parsedSchedule;
|
||||
const updateSchedule = (updates: Partial<ScheduleFormState>) => {
|
||||
onUpdate(scheduleTriggerFromForm({ ...schedule, ...updates }));
|
||||
};
|
||||
const usesTime = ["daily", "weekly", "monthly"].includes(schedule.frequency);
|
||||
const selectedWeekdays = new Set(
|
||||
scheduleWeekdaysFromCronField(schedule.weekday),
|
||||
);
|
||||
const warning = monthlyDayWarning(schedule.monthDay);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<fieldset>
|
||||
<legend className="sr-only">Repeats</legend>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{SCHEDULE_FREQUENCIES.map((frequency) => {
|
||||
const id = `wf-trigger-frequency-${frequency}`;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
frequency === "custom_cron" && "col-span-2",
|
||||
)}
|
||||
key={frequency}
|
||||
>
|
||||
<input
|
||||
checked={schedule.frequency === frequency}
|
||||
className="peer sr-only"
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
name="wf-trigger-frequency"
|
||||
onChange={() => {
|
||||
const isCustom = frequency === "custom_cron";
|
||||
setForceCustomCron(isCustom);
|
||||
updateSchedule({
|
||||
customCron: isCustom
|
||||
? customCronSeed(schedule)
|
||||
: schedule.customCron,
|
||||
frequency,
|
||||
});
|
||||
}}
|
||||
type="radio"
|
||||
value={frequency}
|
||||
/>
|
||||
<label
|
||||
className={cn(
|
||||
"flex min-h-12 cursor-pointer items-center justify-center rounded-lg border px-3 py-2 text-center text-sm font-medium",
|
||||
"outline-2 outline-offset-2 outline-transparent transition-[background-color,border-color,color,outline-color]",
|
||||
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring",
|
||||
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
schedule.frequency === frequency
|
||||
? "border-border/0 bg-transparent text-foreground outline-foreground/45"
|
||||
: "border-border/70 bg-background/35 text-muted-foreground hover:border-border hover:bg-muted/55 hover:text-foreground hover:outline-muted-foreground/20",
|
||||
)}
|
||||
htmlFor={id}
|
||||
>
|
||||
{SCHEDULE_FREQUENCY_LABELS[frequency]}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{schedule.frequency === "weekly" ? (
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="text-xs font-medium text-muted-foreground">
|
||||
Repeat on
|
||||
</legend>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{WEEKDAYS.map(([value, label, shortLabel]) => {
|
||||
const id = `wf-trigger-weekday-${value}`;
|
||||
return (
|
||||
<div className="relative" key={value}>
|
||||
<input
|
||||
aria-label={label}
|
||||
checked={selectedWeekdays.has(value)}
|
||||
className="peer sr-only"
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
name="wf-trigger-weekday"
|
||||
onChange={() => {
|
||||
const nextWeekdays = new Set(selectedWeekdays);
|
||||
if (nextWeekdays.has(value)) {
|
||||
if (nextWeekdays.size === 1) return;
|
||||
nextWeekdays.delete(value);
|
||||
} else {
|
||||
nextWeekdays.add(value);
|
||||
}
|
||||
updateSchedule({
|
||||
weekday: WEEKDAYS.map(([day]) => day)
|
||||
.filter((day) => nextWeekdays.has(day))
|
||||
.join(","),
|
||||
});
|
||||
}}
|
||||
type="checkbox"
|
||||
value={value}
|
||||
/>
|
||||
<label
|
||||
className={cn(
|
||||
"flex aspect-square cursor-pointer items-center justify-center rounded-full text-xs font-medium transition-colors",
|
||||
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2",
|
||||
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
selectedWeekdays.has(value)
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/70 hover:text-foreground",
|
||||
)}
|
||||
htmlFor={id}
|
||||
>
|
||||
{shortLabel}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
{schedule.frequency === "monthly" ? (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-trigger-month-day">Day of month</FieldLabel>
|
||||
<FormSelect
|
||||
disabled={disabled}
|
||||
id="wf-trigger-month-day"
|
||||
onChange={(monthDay) => updateSchedule({ monthDay })}
|
||||
value={schedule.monthDay}
|
||||
>
|
||||
{MONTH_DAYS.map((day) => (
|
||||
<option key={day} value={day}>
|
||||
{day}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
{warning ? (
|
||||
<div
|
||||
className="flex gap-2 rounded-lg border border-warning/30 bg-warning-bg px-3 py-2"
|
||||
role="status"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<p className="text-xs leading-5 text-warning">{warning}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{usesTime ? (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-trigger-time">Run time (UTC)</FieldLabel>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
id="wf-trigger-time"
|
||||
onChange={(event) => updateSchedule({ time: event.target.value })}
|
||||
type="time"
|
||||
value={schedule.time}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{schedule.frequency === "custom_cron" ? (
|
||||
<CronExpressionInput
|
||||
disabled={disabled}
|
||||
onChange={(customCron) => updateSchedule({ customCron })}
|
||||
value={schedule.customCron}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{schedule.frequency === "custom_interval" ? (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-trigger-interval">
|
||||
Existing interval
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
disabled={disabled}
|
||||
id="wf-trigger-interval"
|
||||
onChange={(event) =>
|
||||
updateSchedule({ customInterval: event.target.value })
|
||||
}
|
||||
value={schedule.customInterval}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Keep this legacy interval or choose a repeat option above. All
|
||||
schedules use UTC.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,80 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { ChevronRight, Trash2 } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { WorkflowDurationField } from "./WorkflowDurationField";
|
||||
import { WorkflowMessageTextCondition } from "./WorkflowMessageTextConditionEditor";
|
||||
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
|
||||
import { ACTION_LABELS, ACTION_TYPES } from "./workflowFormTypes";
|
||||
import { WorkflowWebhookHeadersEditor } from "./WorkflowWebhookHeadersEditor";
|
||||
import type {
|
||||
ActionType,
|
||||
StepFormState,
|
||||
TriggerType,
|
||||
import {
|
||||
supportsMessageTextCondition,
|
||||
type StepFormState,
|
||||
type TriggerType,
|
||||
} from "./workflowFormTypes";
|
||||
|
||||
const DEFAULT_STEP_TIMEOUT_SECONDS = 5 * 60;
|
||||
|
||||
type StepSetting = "run-controls" | "details";
|
||||
|
||||
function StepSettingAccordion({
|
||||
children,
|
||||
disabled,
|
||||
expanded,
|
||||
label,
|
||||
onToggle,
|
||||
summary,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
expanded: boolean;
|
||||
label: string;
|
||||
onToggle: () => void;
|
||||
summary: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="flex min-h-12 w-full items-center gap-3 py-3 text-left transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-base font-medium">
|
||||
{label}
|
||||
</span>
|
||||
<span className="max-w-40 truncate text-sm text-muted-foreground">
|
||||
{summary}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground/70 transition-transform duration-150 motion-reduce:transition-none",
|
||||
expanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{expanded ? (
|
||||
<div className="animate-in pb-4 pt-1 fade-in slide-in-from-top-1 duration-150 motion-reduce:animate-none">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function runControlsSummary(step: StepFormState): string {
|
||||
const hasCondition = Boolean(step.condition?.trim());
|
||||
const timeout = step.timeoutSecs?.trim();
|
||||
if (hasCondition && timeout) return `Conditional · ${timeout}`;
|
||||
if (hasCondition) return "Conditional";
|
||||
if (timeout) return timeout;
|
||||
return "Default";
|
||||
}
|
||||
|
||||
function BackendSupportHint({ action }: { action: StepFormState["action"] }) {
|
||||
switch (action) {
|
||||
case "send_dm":
|
||||
@@ -45,12 +108,14 @@ function StepConfigFields({
|
||||
prefix,
|
||||
disabled,
|
||||
triggerType,
|
||||
workflowChannelId,
|
||||
onUpdate,
|
||||
}: {
|
||||
step: StepFormState;
|
||||
prefix: string;
|
||||
disabled?: boolean;
|
||||
triggerType: TriggerType;
|
||||
workflowChannelId?: string | null;
|
||||
onUpdate: (step: StepFormState) => void;
|
||||
}) {
|
||||
switch (step.action) {
|
||||
@@ -87,31 +152,37 @@ function StepConfigFields({
|
||||
value={step.text ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-channel`}>
|
||||
Channel override (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-channel`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, channel: event.target.value })
|
||||
}
|
||||
placeholder="Channel UUID"
|
||||
value={step.channel ?? ""}
|
||||
/>
|
||||
{workflowChannelId ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to use the trigger channel. Webhook runs and manual
|
||||
Trigger runs need an explicit channel override.
|
||||
Messages post to the workflow channel selected above.
|
||||
</p>
|
||||
{triggerType === "webhook" && !(step.channel ?? "").trim() ? (
|
||||
<p className="text-xs text-amber-700">
|
||||
This step will fail for webhook-triggered runs until a channel
|
||||
override is set.
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-channel`}>
|
||||
Channel override (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-channel`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, channel: event.target.value })
|
||||
}
|
||||
placeholder="Channel UUID"
|
||||
value={step.channel ?? ""}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Defaults to the channel that triggered the workflow. Webhook and
|
||||
manual triggers require a channel.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{triggerType === "webhook" && !(step.channel ?? "").trim() ? (
|
||||
<p className="text-xs text-amber-700">
|
||||
This step will fail for webhook-triggered runs until a channel
|
||||
override is set.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "send_dm":
|
||||
@@ -149,9 +220,9 @@ function StepConfigFields({
|
||||
);
|
||||
case "call_webhook":
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-url`}>URL</FieldLabel>
|
||||
<FieldLabel htmlFor={`${prefix}-url`}>Endpoint URL</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
@@ -169,9 +240,7 @@ function StepConfigFields({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-method`}>
|
||||
Method (optional)
|
||||
</FieldLabel>
|
||||
<FieldLabel htmlFor={`${prefix}-method`}>HTTP method</FieldLabel>
|
||||
<FormSelect
|
||||
disabled={disabled}
|
||||
id={`${prefix}-method`}
|
||||
@@ -192,7 +261,9 @@ function StepConfigFields({
|
||||
stepId={step.id || prefix}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-body`}>Body (optional)</FieldLabel>
|
||||
<FieldLabel htmlFor={`${prefix}-body`}>
|
||||
Request body (optional)
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
autoCapitalize="off"
|
||||
className="min-h-[60px] resize-y font-mono text-xs"
|
||||
@@ -295,133 +366,143 @@ function StepConfigFields({
|
||||
}
|
||||
|
||||
export function WorkflowStepCard({
|
||||
bare = false,
|
||||
showHeader = true,
|
||||
index,
|
||||
disabled,
|
||||
onRemove,
|
||||
onUpdate,
|
||||
step,
|
||||
triggerType,
|
||||
workflowChannelId,
|
||||
}: {
|
||||
bare?: boolean;
|
||||
showHeader?: boolean;
|
||||
index: number;
|
||||
disabled?: boolean;
|
||||
onRemove: () => void;
|
||||
onUpdate: (step: StepFormState) => void;
|
||||
step: StepFormState;
|
||||
triggerType: TriggerType;
|
||||
workflowChannelId?: string | null;
|
||||
}) {
|
||||
const prefix = `wf-step-${index}`;
|
||||
const [expandedSetting, setExpandedSetting] = useState<StepSetting | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const toggleSetting = (setting: StepSetting) => {
|
||||
setExpandedSetting((current) => (current === setting ? null : setting));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-border/70 bg-muted/10 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Step {index + 1}
|
||||
</span>
|
||||
<Button
|
||||
aria-label="Remove step"
|
||||
className="h-7 w-7"
|
||||
disabled={disabled}
|
||||
onClick={onRemove}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-id`}>Step ID</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-0",
|
||||
!bare && "rounded-lg border border-border/70 bg-muted/10 p-3",
|
||||
)}
|
||||
>
|
||||
{showHeader ? (
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Step {index + 1}
|
||||
</span>
|
||||
<Button
|
||||
aria-label="Remove step"
|
||||
className="h-7 w-7"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-id`}
|
||||
onChange={(event) => onUpdate({ ...step, id: event.target.value })}
|
||||
placeholder="unique_step_id"
|
||||
value={step.id}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-name`}>
|
||||
Step name (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-name`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, name: event.target.value })
|
||||
}
|
||||
placeholder="Human-friendly label"
|
||||
value={step.name ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-action`}>Action</FieldLabel>
|
||||
<FormSelect
|
||||
disabled={disabled}
|
||||
id={`${prefix}-action`}
|
||||
onChange={(value) => {
|
||||
const next = { ...step, action: value as ActionType };
|
||||
if (value === "call_webhook" && !next.method) {
|
||||
next.method = "POST";
|
||||
}
|
||||
onUpdate(next);
|
||||
}}
|
||||
value={step.action}
|
||||
onClick={onRemove}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{ACTION_TYPES.map((action) => (
|
||||
<option key={action} value={action}>
|
||||
{ACTION_LABELS[action]}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-timeout-secs`}>
|
||||
Timeout seconds (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-timeout-secs`}
|
||||
inputMode="numeric"
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, timeoutSecs: event.target.value })
|
||||
}
|
||||
placeholder="e.g. 300"
|
||||
value={step.timeoutSecs ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-condition`}>
|
||||
Run condition (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
<section className="space-y-4 pb-5">
|
||||
<StepConfigFields
|
||||
disabled={disabled}
|
||||
id={`${prefix}-condition`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, condition: event.target.value })
|
||||
}
|
||||
placeholder='e.g. str_contains(trigger_text, "deploy")'
|
||||
value={step.condition ?? ""}
|
||||
onUpdate={onUpdate}
|
||||
prefix={prefix}
|
||||
step={step}
|
||||
triggerType={triggerType}
|
||||
workflowChannelId={workflowChannelId}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<StepConfigFields
|
||||
disabled={disabled}
|
||||
onUpdate={onUpdate}
|
||||
prefix={prefix}
|
||||
step={step}
|
||||
triggerType={triggerType}
|
||||
/>
|
||||
<section className="divide-y divide-border/50 border-t border-border/50">
|
||||
<StepSettingAccordion
|
||||
disabled={disabled}
|
||||
expanded={expandedSetting === "run-controls"}
|
||||
label="Run controls"
|
||||
onToggle={() => toggleSetting("run-controls")}
|
||||
summary={runControlsSummary(step)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{supportsMessageTextCondition(triggerType) ? (
|
||||
<WorkflowMessageTextCondition
|
||||
allowAdvanced={false}
|
||||
disabled={disabled}
|
||||
onChange={(condition) => onUpdate({ ...step, condition })}
|
||||
value={step.condition ?? ""}
|
||||
/>
|
||||
) : null}
|
||||
<WorkflowDurationField
|
||||
disabled={disabled}
|
||||
fallbackSeconds={DEFAULT_STEP_TIMEOUT_SECONDS}
|
||||
id={`${prefix}-timeout-secs`}
|
||||
label="Timeout"
|
||||
onChange={(timeoutSecs) => onUpdate({ ...step, timeoutSecs })}
|
||||
placeholder="5m"
|
||||
value={step.timeoutSecs ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</StepSettingAccordion>
|
||||
|
||||
<StepSettingAccordion
|
||||
disabled={disabled}
|
||||
expanded={expandedSetting === "details"}
|
||||
label="Step details"
|
||||
onToggle={() => toggleSetting("details")}
|
||||
summary={step.name?.trim() || step.id}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-name`}>
|
||||
Name (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-name`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, name: event.target.value })
|
||||
}
|
||||
placeholder="e.g. Notify deployment channel"
|
||||
value={step.name ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-id`}>Step ID</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-id`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, id: event.target.value })
|
||||
}
|
||||
placeholder="unique_step_id"
|
||||
value={step.id}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used in configuration and run history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</StepSettingAccordion>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
type WorkflowUnavailableDialogProps = {
|
||||
loading: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onRetry: () => void;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
export function WorkflowUnavailableDialog({
|
||||
loading,
|
||||
onOpenChange,
|
||||
onRetry,
|
||||
open,
|
||||
}: WorkflowUnavailableDialogProps) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="sm:max-w-lg" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{loading ? "Loading workflow…" : "Workflow unavailable"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{loading
|
||||
? "Resolving workflow details."
|
||||
: "This workflow could not be loaded. It may no longer be available to you."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{loading ? (
|
||||
<div
|
||||
aria-label="Loading workflow"
|
||||
className="space-y-3"
|
||||
role="status"
|
||||
>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
</div>
|
||||
) : (
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
<X className="h-4 w-4" />
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button onClick={onRetry} type="button">
|
||||
Retry
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,47 @@
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { CopyButton } from "@/features/agents/ui/CopyButton";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
|
||||
type WorkflowWebhookSecretDialogProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onContinue: () => void;
|
||||
open: boolean;
|
||||
relayHttpUrl: string;
|
||||
relayHttpUrl: string | null;
|
||||
relayUrlError: string | null;
|
||||
webhookSecret: string;
|
||||
workflowId: string;
|
||||
};
|
||||
|
||||
export function WorkflowWebhookSecretDialog({
|
||||
onOpenChange,
|
||||
onContinue,
|
||||
open,
|
||||
relayHttpUrl,
|
||||
relayUrlError,
|
||||
webhookSecret,
|
||||
workflowId,
|
||||
}: WorkflowWebhookSecretDialogProps) {
|
||||
const webhookUrl = `${relayHttpUrl}/hooks/${workflowId}`;
|
||||
const [revealed, setRevealed] = React.useState(false);
|
||||
const webhookUrl = relayHttpUrl
|
||||
? `${relayHttpUrl}/hooks/${workflowId}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<Dialog onOpenChange={(nextOpen) => !nextOpen && onContinue()} open={open}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Webhook Ready</DialogTitle>
|
||||
<DialogTitle>Webhook ready</DialogTitle>
|
||||
<DialogDescription>
|
||||
This secret is only shown now. If it is lost, re-save the workflow
|
||||
to generate a new one.
|
||||
This private secret is shown once and cannot be recovered. Copy and
|
||||
store it before continuing.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -40,22 +50,56 @@ export function WorkflowWebhookSecretDialog({
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Webhook URL
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-md bg-muted/50 p-3 font-mono text-xs">
|
||||
{webhookUrl}
|
||||
</pre>
|
||||
<CopyButton label="Copy URL" value={webhookUrl} />
|
||||
{webhookUrl ? (
|
||||
<>
|
||||
<pre className="overflow-x-auto rounded-md bg-muted/50 p-3 font-mono text-xs">
|
||||
{webhookUrl}
|
||||
</pre>
|
||||
<CopyButton label="Copy URL" value={webhookUrl} />
|
||||
</>
|
||||
) : (
|
||||
<p
|
||||
className={
|
||||
relayUrlError
|
||||
? "rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
: "rounded-md bg-muted/50 p-3 text-sm text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{relayUrlError
|
||||
? `The workflow was saved, but its webhook URL could not be loaded: ${relayUrlError}`
|
||||
: "Loading webhook URL…"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
`X-Webhook-Secret`
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-md bg-muted/50 p-3 font-mono text-xs">
|
||||
{webhookSecret}
|
||||
</pre>
|
||||
<div className="flex items-center gap-2 rounded-md bg-muted/50 p-3">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto font-mono text-xs">
|
||||
{revealed ? webhookSecret : "•".repeat(24)}
|
||||
</code>
|
||||
<Button
|
||||
aria-label={
|
||||
revealed ? "Hide webhook secret" : "Reveal webhook secret"
|
||||
}
|
||||
onClick={() => setRevealed((value) => !value)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{revealed ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
</div>
|
||||
<CopyButton label="Copy Secret" value={webhookSecret} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={onContinue} type="button">
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,40 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
WorkflowEditorCreateTarget,
|
||||
WorkflowEditorWorkflowTarget,
|
||||
} from "@/features/workflows/ui/WorkflowEditorHost";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
import type { WorkflowEditorPane } from "./workflowEditorPane";
|
||||
|
||||
const WorkflowsView = React.lazy(async () => {
|
||||
const module = await import("@/features/workflows/ui/WorkflowsView");
|
||||
return { default: module.WorkflowsView };
|
||||
});
|
||||
|
||||
/** URL-addressable editor target: a shared editor target plus the history
|
||||
* origin the Workflows route needs to close back to where the user came from. */
|
||||
export type WorkflowEditorRoute =
|
||||
| (WorkflowEditorCreateTarget & { hasOrigin: boolean })
|
||||
| (WorkflowEditorWorkflowTarget & { hasOrigin: boolean });
|
||||
|
||||
type WorkflowsScreenProps = {
|
||||
channels: Channel[];
|
||||
onCloseWorkflow: () => void;
|
||||
onSelectWorkflow: (workflowId: string) => void;
|
||||
selectedWorkflowId: string | null;
|
||||
editor: WorkflowEditorRoute | null;
|
||||
onCloseEditor: () => void;
|
||||
onCreateWorkflow: () => void;
|
||||
onDuplicateWorkflow: (workflowId: string) => void;
|
||||
onEditWorkflow: (workflowId: string) => void;
|
||||
onViewWorkflow: (workflowId: string) => void;
|
||||
onEditorPaneChange: (pane: WorkflowEditorPane) => void;
|
||||
};
|
||||
|
||||
export function WorkflowsScreen({
|
||||
channels,
|
||||
onCloseWorkflow,
|
||||
onSelectWorkflow,
|
||||
selectedWorkflowId,
|
||||
}: WorkflowsScreenProps) {
|
||||
export function WorkflowsScreen(props: WorkflowsScreenProps) {
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
|
||||
<WorkflowsView
|
||||
channels={channels}
|
||||
onCloseWorkflow={onCloseWorkflow}
|
||||
onSelectWorkflow={onSelectWorkflow}
|
||||
selectedWorkflowId={selectedWorkflowId}
|
||||
/>
|
||||
<WorkflowsView {...props} />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
allWorkflowsQueryKey,
|
||||
@@ -11,8 +11,13 @@ import {
|
||||
} from "@/features/workflows/hooks";
|
||||
import { WorkflowCard } from "@/features/workflows/ui/WorkflowCard";
|
||||
import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog";
|
||||
import { WorkflowDetailPanel } from "@/features/workflows/ui/WorkflowDetailPanel";
|
||||
import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog";
|
||||
import { WorkflowEditorHost } from "@/features/workflows/ui/WorkflowEditorHost";
|
||||
import type { WorkflowEditorRoute } from "@/features/workflows/ui/WorkflowsScreen";
|
||||
import type { WorkflowEditorPane } from "@/features/workflows/ui/workflowEditorPane";
|
||||
import {
|
||||
getWorkflowEnabled,
|
||||
withWorkflowEnabled,
|
||||
} from "@/features/workflows/ui/workflowDefinition";
|
||||
import type { Channel, Workflow } from "@/shared/api/types";
|
||||
import {
|
||||
deleteWorkflow,
|
||||
@@ -23,13 +28,16 @@ import {
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { PageHeader } from "@/shared/ui/PageHeader";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { getWorkflowEnabled, withWorkflowEnabled } from "./workflowDefinition";
|
||||
|
||||
type WorkflowsViewProps = {
|
||||
channels: Channel[];
|
||||
onCloseWorkflow: () => void;
|
||||
onSelectWorkflow: (workflowId: string) => void;
|
||||
selectedWorkflowId: string | null;
|
||||
editor: WorkflowEditorRoute | null;
|
||||
onCloseEditor: () => void;
|
||||
onCreateWorkflow: () => void;
|
||||
onDuplicateWorkflow: (workflowId: string) => void;
|
||||
onEditWorkflow: (workflowId: string) => void;
|
||||
onViewWorkflow: (workflowId: string) => void;
|
||||
onEditorPaneChange: (pane: WorkflowEditorPane) => void;
|
||||
};
|
||||
|
||||
type WorkflowWithChannel = {
|
||||
@@ -37,30 +45,28 @@ type WorkflowWithChannel = {
|
||||
channelName: string;
|
||||
};
|
||||
|
||||
type DialogState =
|
||||
| { mode: "closed" }
|
||||
| { mode: "create" }
|
||||
| { mode: "edit"; workflow: Workflow }
|
||||
| { mode: "duplicate"; workflow: Workflow };
|
||||
|
||||
const WORKFLOW_CARD_GRID_CLASS =
|
||||
"grid grid-cols-1 gap-3 [@container(min-width:38rem)]:grid-cols-2 [@container(min-width:54rem)]:grid-cols-3";
|
||||
"grid grid-cols-1 gap-3 [@container(min-width:42rem)]:grid-cols-2 [@container(min-width:63rem)]:grid-cols-3";
|
||||
|
||||
function WorkflowsListSkeleton() {
|
||||
return (
|
||||
<div className={WORKFLOW_CARD_GRID_CLASS}>
|
||||
{["first", "second", "third", "fourth"].map((card) => (
|
||||
<div
|
||||
className="flex min-h-60 flex-col rounded-2xl border bg-card p-5"
|
||||
className="flex min-h-60 flex-col rounded-2xl bg-muted/50 p-5 shadow-xs"
|
||||
key={card}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<Skeleton className="h-9 w-9 rounded-xl" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-9 w-9 rounded-xl" />
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-9 w-9 rounded-xl" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="mt-5 h-6 w-3/4" />
|
||||
<Skeleton className="mt-3 h-4 w-full" />
|
||||
<Skeleton className="mt-2 h-4 w-4/5" />
|
||||
<Skeleton className="mt-5 h-3 w-28" />
|
||||
<Skeleton className="mt-2 h-6 w-full" />
|
||||
<Skeleton className="mt-2 h-6 w-4/5" />
|
||||
<Skeleton className="mt-auto h-4 w-32" />
|
||||
</div>
|
||||
))}
|
||||
@@ -84,16 +90,20 @@ function CreateWorkflowCard({ onClick }: { onClick: () => void }) {
|
||||
|
||||
export function WorkflowsView({
|
||||
channels,
|
||||
onCloseWorkflow,
|
||||
onSelectWorkflow,
|
||||
selectedWorkflowId,
|
||||
editor,
|
||||
onCloseEditor,
|
||||
onCreateWorkflow,
|
||||
onDuplicateWorkflow,
|
||||
onEditWorkflow,
|
||||
onViewWorkflow,
|
||||
onEditorPaneChange,
|
||||
}: WorkflowsViewProps) {
|
||||
const [dialogState, setDialogState] = React.useState<DialogState>({
|
||||
mode: "closed",
|
||||
});
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<Workflow | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const editorWorkflowId =
|
||||
editor && editor.mode !== "create" ? editor.workflowId : null;
|
||||
|
||||
const memberChannels = channels.filter((c) => c.isMember);
|
||||
const channelIds = memberChannels.map((c) => c.id).sort();
|
||||
const channelIdKey = channelIds.join(",");
|
||||
@@ -135,10 +145,7 @@ export function WorkflowsView({
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (workflowId: string) => deleteWorkflow(workflowId),
|
||||
onSuccess: (_data, workflowId) => {
|
||||
if (selectedWorkflowId === workflowId) {
|
||||
onCloseWorkflow();
|
||||
}
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === "workflows" ||
|
||||
@@ -190,23 +197,35 @@ export function WorkflowsView({
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteOne = deleteMutation.mutate;
|
||||
const deleteOne = deleteMutation.mutateAsync;
|
||||
const handleConfirmDelete = React.useCallback(
|
||||
(workflow: Workflow) => {
|
||||
deleteOne(workflow.id);
|
||||
setDeleteTarget(null);
|
||||
async (workflow: Workflow) => {
|
||||
try {
|
||||
await deleteOne(workflow.id);
|
||||
setDeleteTarget(null);
|
||||
// Deleting the workflow the editor is pointed at would otherwise leave
|
||||
// that editor open on a workflow that no longer exists.
|
||||
if (workflow.id === editorWorkflowId) onCloseEditor();
|
||||
} catch {
|
||||
// React Query stores the error; keep the confirmation and editor open.
|
||||
}
|
||||
},
|
||||
[deleteOne],
|
||||
[deleteOne, editorWorkflowId, onCloseEditor],
|
||||
);
|
||||
|
||||
const handleView = React.useCallback(
|
||||
(workflow: Workflow) => onViewWorkflow(workflow.id),
|
||||
[onViewWorkflow],
|
||||
);
|
||||
|
||||
const handleEdit = React.useCallback(
|
||||
(workflow: Workflow) => setDialogState({ mode: "edit", workflow }),
|
||||
[],
|
||||
(workflow: Workflow) => onEditWorkflow(workflow.id),
|
||||
[onEditWorkflow],
|
||||
);
|
||||
|
||||
const handleDuplicate = React.useCallback(
|
||||
(workflow: Workflow) => setDialogState({ mode: "duplicate", workflow }),
|
||||
[],
|
||||
(workflow: Workflow) => onDuplicateWorkflow(workflow.id),
|
||||
[onDuplicateWorkflow],
|
||||
);
|
||||
|
||||
const toggleEnabled = toggleEnabledMutation.mutate;
|
||||
@@ -215,11 +234,9 @@ export function WorkflowsView({
|
||||
[toggleEnabled],
|
||||
);
|
||||
|
||||
const handleDialogOpenChange = React.useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setDialogState({ mode: "closed" });
|
||||
}
|
||||
}, []);
|
||||
const editorWorkflowHint = allWorkflows.find(
|
||||
({ workflow }) => workflow.id === editorWorkflowId,
|
||||
)?.workflow;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -264,13 +281,10 @@ export function WorkflowsView({
|
||||
</div>
|
||||
) : (
|
||||
<div className={WORKFLOW_CARD_GRID_CLASS}>
|
||||
<CreateWorkflowCard
|
||||
onClick={() => setDialogState({ mode: "create" })}
|
||||
/>
|
||||
<CreateWorkflowCard onClick={onCreateWorkflow} />
|
||||
{allWorkflows.map(({ workflow, channelName }) => (
|
||||
<WorkflowCard
|
||||
channelName={channelName}
|
||||
isActive={selectedWorkflowId === workflow.id}
|
||||
isTogglingEnabled={
|
||||
toggleEnabledMutation.isPending &&
|
||||
toggleEnabledMutation.variables?.id === workflow.id
|
||||
@@ -279,9 +293,9 @@ export function WorkflowsView({
|
||||
onDelete={handleDelete}
|
||||
onDuplicate={handleDuplicate}
|
||||
onEdit={handleEdit}
|
||||
onSelect={onSelectWorkflow}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
onTrigger={handleTrigger}
|
||||
onView={handleView}
|
||||
workflow={workflow}
|
||||
/>
|
||||
))}
|
||||
@@ -290,33 +304,31 @@ export function WorkflowsView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedWorkflowId ? (
|
||||
<div className="w-[400px] shrink-0">
|
||||
<WorkflowDetailPanel
|
||||
key={selectedWorkflowId}
|
||||
onClose={onCloseWorkflow}
|
||||
onEdit={handleEdit}
|
||||
workflowId={selectedWorkflowId}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<WorkflowDialog
|
||||
<WorkflowEditorHost
|
||||
channels={memberChannels}
|
||||
mode={dialogState.mode === "closed" ? "create" : dialogState.mode}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
open={dialogState.mode !== "closed"}
|
||||
workflow={
|
||||
dialogState.mode === "edit" || dialogState.mode === "duplicate"
|
||||
? dialogState.workflow
|
||||
: null
|
||||
}
|
||||
editor={editor}
|
||||
onClose={onCloseEditor}
|
||||
onDeleteWorkflow={handleDelete}
|
||||
onDuplicateWorkflow={onDuplicateWorkflow}
|
||||
onEditWorkflow={onEditWorkflow}
|
||||
onEditorPaneChange={onEditorPaneChange}
|
||||
onTriggerWorkflow={handleTrigger}
|
||||
workflowHint={editorWorkflowHint}
|
||||
/>
|
||||
|
||||
<WorkflowDeleteDialog
|
||||
error={
|
||||
deleteMutation.error instanceof Error
|
||||
? deleteMutation.error.message
|
||||
: null
|
||||
}
|
||||
isPending={deleteMutation.isPending}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
if (!open) {
|
||||
deleteMutation.reset();
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
open={deleteTarget !== null}
|
||||
workflow={deleteTarget}
|
||||
|
||||
54
desktop/src/features/workflows/ui/cronExpression.test.mjs
Normal file
54
desktop/src/features/workflows/ui/cronExpression.test.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
CRON_FIELD_DEFINITIONS,
|
||||
cronExpressionError,
|
||||
cronExpressionFromFields,
|
||||
cronFieldsFromPaste,
|
||||
validateCronField,
|
||||
validateCronFields,
|
||||
} from "./cronExpression.ts";
|
||||
|
||||
test("accepts supported five-field cron syntax", () => {
|
||||
for (const expression of [
|
||||
"0 9 * * 1-5",
|
||||
"*/15 * * * *",
|
||||
"0 */2 1,15 JAN,MAR MON-FRI",
|
||||
]) {
|
||||
const result = cronFieldsFromPaste(expression);
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(validateCronFields(result.fields), [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
assert.equal(cronExpressionFromFields(result.fields), expression);
|
||||
assert.equal(cronExpressionError(expression), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("validates cron field ranges and structure locally", () => {
|
||||
assert.equal(
|
||||
validateCronField("60", CRON_FIELD_DEFINITIONS[0]),
|
||||
"Minute must be between 0 and 59.",
|
||||
);
|
||||
assert.equal(
|
||||
validateCronField("5-2", CRON_FIELD_DEFINITIONS[2]),
|
||||
"Day range must go from lower to higher.",
|
||||
);
|
||||
assert.equal(
|
||||
validateCronField("*/0", CRON_FIELD_DEFINITIONS[1]),
|
||||
"Hour step must be a positive whole number.",
|
||||
);
|
||||
});
|
||||
|
||||
test("whole-expression validation requires exactly five fields", () => {
|
||||
assert.deepEqual(cronFieldsFromPaste("0 9 * *"), {
|
||||
error: "Paste a 5-field cron expression. Found 4 fields.",
|
||||
ok: false,
|
||||
});
|
||||
assert.match(cronExpressionError("not-a-cron"), /Found 1 field/);
|
||||
});
|
||||
156
desktop/src/features/workflows/ui/cronExpression.ts
Normal file
156
desktop/src/features/workflows/ui/cronExpression.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
export const CRON_FIELD_DEFINITIONS = [
|
||||
{ label: "Minute", max: 59, min: 0 },
|
||||
{ label: "Hour", max: 23, min: 0 },
|
||||
{ label: "Day", max: 31, min: 1 },
|
||||
{
|
||||
aliases: [
|
||||
"JAN",
|
||||
"FEB",
|
||||
"MAR",
|
||||
"APR",
|
||||
"MAY",
|
||||
"JUN",
|
||||
"JUL",
|
||||
"AUG",
|
||||
"SEP",
|
||||
"OCT",
|
||||
"NOV",
|
||||
"DEC",
|
||||
],
|
||||
label: "Month",
|
||||
max: 12,
|
||||
min: 1,
|
||||
},
|
||||
{
|
||||
aliases: ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"],
|
||||
label: "Weekday",
|
||||
max: 7,
|
||||
min: 0,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type CronFields = [string, string, string, string, string];
|
||||
|
||||
export function cronFieldsFromExpression(expression: string): CronFields {
|
||||
const values = expression.trim() ? expression.trim().split(/\s+/) : [];
|
||||
return [
|
||||
values[0] ?? "",
|
||||
values[1] ?? "",
|
||||
values[2] ?? "",
|
||||
values[3] ?? "",
|
||||
values[4] ?? "",
|
||||
];
|
||||
}
|
||||
|
||||
export function cronExpressionFromFields(fields: CronFields): string {
|
||||
return fields.join(" ");
|
||||
}
|
||||
|
||||
export function normalizeCronExpression(expression: string): string {
|
||||
return expression.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
export function cronFieldsFromPaste(
|
||||
pastedValue: string,
|
||||
): { fields: CronFields; ok: true } | { error: string; ok: false } {
|
||||
const values = pastedValue.trim().split(/\s+/);
|
||||
if (values.length !== CRON_FIELD_DEFINITIONS.length) {
|
||||
return {
|
||||
error: `Paste a 5-field cron expression. Found ${values.length} field${values.length === 1 ? "" : "s"}.`,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
return { fields: values as CronFields, ok: true };
|
||||
}
|
||||
|
||||
type CronFieldDefinition = (typeof CRON_FIELD_DEFINITIONS)[number];
|
||||
|
||||
function atomError(
|
||||
atom: string,
|
||||
definition: CronFieldDefinition,
|
||||
): string | null {
|
||||
const upperAtom = atom.toUpperCase();
|
||||
if (
|
||||
"aliases" in definition &&
|
||||
definition.aliases.includes(upperAtom as never)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!/^\d+$/.test(atom)) {
|
||||
return `${definition.label} contains “${atom}”, which is not a supported value.`;
|
||||
}
|
||||
|
||||
const value = Number(atom);
|
||||
if (value < definition.min || value > definition.max) {
|
||||
return `${definition.label} must be between ${definition.min} and ${definition.max}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function segmentError(
|
||||
segment: string,
|
||||
definition: CronFieldDefinition,
|
||||
): string | null {
|
||||
const stepParts = segment.split("/");
|
||||
if (stepParts.length > 2 || stepParts.some((part) => !part)) {
|
||||
return `${definition.label} has an invalid step.`;
|
||||
}
|
||||
|
||||
const [base, step] = stepParts;
|
||||
if (step !== undefined) {
|
||||
if (!/^\d+$/.test(step) || Number(step) < 1) {
|
||||
return `${definition.label} step must be a positive whole number.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (base === "*") return null;
|
||||
|
||||
const rangeParts = base.split("-");
|
||||
if (rangeParts.length > 2 || rangeParts.some((part) => !part)) {
|
||||
return `${definition.label} has an invalid range.`;
|
||||
}
|
||||
|
||||
const startError = atomError(rangeParts[0], definition);
|
||||
if (startError) return startError;
|
||||
if (rangeParts.length === 1) return null;
|
||||
|
||||
const endError = atomError(rangeParts[1], definition);
|
||||
if (endError) return endError;
|
||||
|
||||
const start = Number(rangeParts[0]);
|
||||
const end = Number(rangeParts[1]);
|
||||
if (Number.isFinite(start) && Number.isFinite(end) && start > end) {
|
||||
return `${definition.label} range must go from lower to higher.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateCronField(
|
||||
value: string,
|
||||
definition: CronFieldDefinition,
|
||||
): string | null {
|
||||
if (!value) return `${definition.label} is required.`;
|
||||
|
||||
const segments = value.split(",");
|
||||
if (segments.some((segment) => !segment)) {
|
||||
return `${definition.label} has an empty list item.`;
|
||||
}
|
||||
|
||||
for (const segment of segments) {
|
||||
const error = segmentError(segment, definition);
|
||||
if (error) return error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateCronFields(fields: CronFields): Array<string | null> {
|
||||
return fields.map((field, index) =>
|
||||
validateCronField(field, CRON_FIELD_DEFINITIONS[index]),
|
||||
);
|
||||
}
|
||||
|
||||
export function cronExpressionError(expression: string): string | null {
|
||||
const parsed = cronFieldsFromPaste(expression);
|
||||
if (!parsed.ok) return parsed.error;
|
||||
return validateCronFields(parsed.fields).find(Boolean) ?? null;
|
||||
}
|
||||
55
desktop/src/features/workflows/ui/workflowDuration.test.mjs
Normal file
55
desktop/src/features/workflows/ui/workflowDuration.test.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DURATION_SLIDER_STOPS,
|
||||
durationSliderIndex,
|
||||
formatDurationSeconds,
|
||||
formatDurationSecondsVerbose,
|
||||
parseDurationSeconds,
|
||||
} from "./workflowDuration.ts";
|
||||
|
||||
test("parseDurationSeconds accepts compact and combined whole-second durations", () => {
|
||||
assert.equal(parseDurationSeconds("5s"), 5);
|
||||
assert.equal(parseDurationSeconds("90m"), 5_400);
|
||||
assert.equal(parseDurationSeconds("1h 2s"), 3_602);
|
||||
assert.equal(parseDurationSeconds("1H2M3S"), 3_723);
|
||||
assert.equal(parseDurationSeconds("2d"), 172_800);
|
||||
assert.equal(parseDurationSeconds("2w 3d 4h 5m 6s"), 1_483_506);
|
||||
assert.equal(parseDurationSeconds("42"), 42);
|
||||
assert.equal(parseDurationSeconds("0s"), 0);
|
||||
});
|
||||
|
||||
test("parseDurationSeconds rejects empty, malformed, and fractional values", () => {
|
||||
assert.equal(parseDurationSeconds(""), null);
|
||||
assert.equal(parseDurationSeconds("1.5m"), null);
|
||||
assert.equal(parseDurationSeconds("1m later"), null);
|
||||
});
|
||||
|
||||
test("formatDurationSeconds produces compact labels with significant units", () => {
|
||||
assert.equal(formatDurationSeconds(0), "0s");
|
||||
assert.equal(formatDurationSeconds(5), "5s");
|
||||
assert.equal(formatDurationSeconds(62), "1m 2s");
|
||||
assert.equal(formatDurationSeconds(3_602), "1h 2s");
|
||||
assert.equal(formatDurationSeconds(7_323), "2h 2m 3s");
|
||||
assert.equal(formatDurationSeconds(172_800), "2d");
|
||||
assert.equal(formatDurationSeconds(1_483_506), "2w 3d 4h 5m 6s");
|
||||
});
|
||||
|
||||
test("formatDurationSecondsVerbose spells out units with correct plurals", () => {
|
||||
assert.equal(formatDurationSecondsVerbose(0), "0 seconds");
|
||||
assert.equal(formatDurationSecondsVerbose(300), "5 minutes");
|
||||
assert.equal(formatDurationSecondsVerbose(604_800), "1 week");
|
||||
assert.equal(
|
||||
formatDurationSecondsVerbose(1_483_506),
|
||||
"2 weeks 3 days 4 hours 5 minutes 6 seconds",
|
||||
);
|
||||
});
|
||||
|
||||
test("duration slider starts at one second, keeps fine short-delay stops, and reaches three hours", () => {
|
||||
assert.deepEqual(DURATION_SLIDER_STOPS.slice(0, 3), [1, 2, 3]);
|
||||
assert.equal(DURATION_SLIDER_STOPS.at(-1), 10_800);
|
||||
assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(62)], 62);
|
||||
assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(300)], 300);
|
||||
assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(3_602)], 3_600);
|
||||
});
|
||||
127
desktop/src/features/workflows/ui/workflowDuration.ts
Normal file
127
desktop/src/features/workflows/ui/workflowDuration.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
const DURATION_PARTS_PATTERN =
|
||||
/^\s*(?:(\d+)\s*w)?\s*(?:(\d+)\s*d)?\s*(?:(\d+)\s*h)?\s*(?:(\d+)\s*m)?\s*(?:(\d+)\s*s)?\s*$/i;
|
||||
|
||||
const SECONDS_PER_MINUTE = 60;
|
||||
const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE;
|
||||
const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR;
|
||||
const SECONDS_PER_WEEK = 7 * SECONDS_PER_DAY;
|
||||
|
||||
/** Parse compact durations such as `5s`, `1h 2s`, `2d`, or `3w`. */
|
||||
export function parseDurationSeconds(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const seconds = Number(trimmed);
|
||||
return Number.isSafeInteger(seconds) ? seconds : null;
|
||||
}
|
||||
|
||||
const match = DURATION_PARTS_PATTERN.exec(trimmed);
|
||||
if (!match || match.slice(1).every((part) => part === undefined)) return null;
|
||||
|
||||
const weeks = Number(match[1] ?? 0);
|
||||
const days = Number(match[2] ?? 0);
|
||||
const hours = Number(match[3] ?? 0);
|
||||
const minutes = Number(match[4] ?? 0);
|
||||
const seconds = Number(match[5] ?? 0);
|
||||
const total =
|
||||
weeks * SECONDS_PER_WEEK +
|
||||
days * SECONDS_PER_DAY +
|
||||
hours * SECONDS_PER_HOUR +
|
||||
minutes * SECONDS_PER_MINUTE +
|
||||
seconds;
|
||||
|
||||
return Number.isSafeInteger(total) ? total : null;
|
||||
}
|
||||
|
||||
/** Format whole seconds as a compact duration, omitting empty units. */
|
||||
export function formatDurationSeconds(totalSeconds: number): string {
|
||||
if (!Number.isSafeInteger(totalSeconds) || totalSeconds < 0) return "";
|
||||
if (totalSeconds === 0) return "0s";
|
||||
|
||||
const weeks = Math.floor(totalSeconds / SECONDS_PER_WEEK);
|
||||
const days = Math.floor((totalSeconds % SECONDS_PER_WEEK) / SECONDS_PER_DAY);
|
||||
const hours = Math.floor((totalSeconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR);
|
||||
const minutes = Math.floor(
|
||||
(totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE,
|
||||
);
|
||||
const seconds = totalSeconds % SECONDS_PER_MINUTE;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (weeks > 0) parts.push(`${weeks}w`);
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (seconds > 0) parts.push(`${seconds}s`);
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function verboseUnit(value: number, unit: string): string {
|
||||
return `${value} ${unit}${value === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/** Format whole seconds with fully spelled-out units for summary UI. */
|
||||
export function formatDurationSecondsVerbose(totalSeconds: number): string {
|
||||
if (!Number.isSafeInteger(totalSeconds) || totalSeconds < 0) return "";
|
||||
if (totalSeconds === 0) return "0 seconds";
|
||||
|
||||
const weeks = Math.floor(totalSeconds / SECONDS_PER_WEEK);
|
||||
const days = Math.floor((totalSeconds % SECONDS_PER_WEEK) / SECONDS_PER_DAY);
|
||||
const hours = Math.floor((totalSeconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR);
|
||||
const minutes = Math.floor(
|
||||
(totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE,
|
||||
);
|
||||
const seconds = totalSeconds % SECONDS_PER_MINUTE;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (weeks > 0) parts.push(verboseUnit(weeks, "week"));
|
||||
if (days > 0) parts.push(verboseUnit(days, "day"));
|
||||
if (hours > 0) parts.push(verboseUnit(hours, "hour"));
|
||||
if (minutes > 0) parts.push(verboseUnit(minutes, "minute"));
|
||||
if (seconds > 0) parts.push(verboseUnit(seconds, "second"));
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function steppedRange(start: number, end: number, step: number): number[] {
|
||||
const values: number[] = [];
|
||||
for (let value = start; value <= end; value += step) values.push(value);
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slider stops favor the short delays people use most, then relax precision as
|
||||
* the duration grows. The typed field still accepts exact values between stops.
|
||||
*/
|
||||
export const DURATION_SLIDER_STOPS = [
|
||||
...steppedRange(1, 120, 1),
|
||||
...steppedRange(125, 600, 5),
|
||||
...steppedRange(615, 1_800, 15),
|
||||
...steppedRange(1_860, 7_200, 60),
|
||||
...steppedRange(7_500, 10_800, 300),
|
||||
];
|
||||
|
||||
export const DEFAULT_DURATION_SECONDS = 1;
|
||||
|
||||
export function durationSliderIndex(totalSeconds: number): number {
|
||||
if (totalSeconds <= DURATION_SLIDER_STOPS[0]) return 0;
|
||||
|
||||
const lastIndex = DURATION_SLIDER_STOPS.length - 1;
|
||||
if (totalSeconds >= DURATION_SLIDER_STOPS[lastIndex]) return lastIndex;
|
||||
|
||||
let low = 0;
|
||||
let high = lastIndex;
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
const value = DURATION_SLIDER_STOPS[middle];
|
||||
if (value === totalSeconds) return middle;
|
||||
if (value < totalSeconds) low = middle + 1;
|
||||
else high = middle - 1;
|
||||
}
|
||||
|
||||
return totalSeconds - DURATION_SLIDER_STOPS[high] <=
|
||||
DURATION_SLIDER_STOPS[low] - totalSeconds
|
||||
? high
|
||||
: low;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const sourcePath = new URL("./workflowEditorPane.ts", import.meta.url);
|
||||
const source = await import("node:fs/promises").then((fs) =>
|
||||
fs.readFile(sourcePath, "utf8"),
|
||||
);
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.ESNext,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText;
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(compiled).toString("base64")}`;
|
||||
const { parseWorkflowEditorPane, serializeWorkflowEditorPane } = await import(
|
||||
moduleUrl
|
||||
);
|
||||
|
||||
test("parses trigger and stable step-id panes", () => {
|
||||
assert.deepEqual(parseWorkflowEditorPane("trigger"), { type: "trigger" });
|
||||
assert.deepEqual(parseWorkflowEditorPane("step:notify_team"), {
|
||||
type: "step",
|
||||
stepId: "notify_team",
|
||||
});
|
||||
const maxId = "a".repeat(64);
|
||||
assert.deepEqual(parseWorkflowEditorPane(`step:${maxId}`), {
|
||||
type: "step",
|
||||
stepId: maxId,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects malformed pane parameters", () => {
|
||||
for (const value of [
|
||||
undefined,
|
||||
null,
|
||||
"",
|
||||
"step:",
|
||||
"step:%E0%A4%A",
|
||||
"step-0",
|
||||
"step:notify%20team",
|
||||
"step:bad-id",
|
||||
`step:${"a".repeat(65)}`,
|
||||
`step:${"%41".repeat(65)}`,
|
||||
])
|
||||
assert.equal(parseWorkflowEditorPane(value), null);
|
||||
});
|
||||
|
||||
test("serializes stable step-id panes", () => {
|
||||
assert.equal(serializeWorkflowEditorPane(null), undefined);
|
||||
assert.equal(serializeWorkflowEditorPane({ type: "trigger" }), "trigger");
|
||||
assert.equal(
|
||||
serializeWorkflowEditorPane({ type: "step", stepId: "notify_team" }),
|
||||
"step:notify_team",
|
||||
);
|
||||
assert.equal(
|
||||
serializeWorkflowEditorPane({ type: "step", stepId: "bad-id" }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
32
desktop/src/features/workflows/ui/workflowEditorPane.ts
Normal file
32
desktop/src/features/workflows/ui/workflowEditorPane.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type WorkflowEditorPane =
|
||||
| { type: "trigger" }
|
||||
| { type: "step"; stepId: string }
|
||||
| null;
|
||||
|
||||
const STEP_PANE_PREFIX = "step:";
|
||||
const STEP_ID_PATTERN = /^[A-Za-z0-9_]{1,64}$/;
|
||||
|
||||
export function parseWorkflowEditorPane(value: unknown): WorkflowEditorPane {
|
||||
if (value === "trigger") return { type: "trigger" };
|
||||
if (typeof value !== "string" || !value.startsWith(STEP_PANE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encodedStepId = value.slice(STEP_PANE_PREFIX.length);
|
||||
if (!encodedStepId || encodedStepId.length > 192) return null;
|
||||
try {
|
||||
const stepId = decodeURIComponent(encodedStepId);
|
||||
return STEP_ID_PATTERN.test(stepId) ? { type: "step", stepId } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeWorkflowEditorPane(
|
||||
pane: WorkflowEditorPane,
|
||||
): string | undefined {
|
||||
if (pane === null) return undefined;
|
||||
if (pane.type === "trigger") return "trigger";
|
||||
if (!STEP_ID_PATTERN.test(pane.stepId)) return undefined;
|
||||
return `${STEP_PANE_PREFIX}${encodeURIComponent(pane.stepId)}`;
|
||||
}
|
||||
198
desktop/src/features/workflows/ui/workflowFormTypes.test.mjs
Normal file
198
desktop/src/features/workflows/ui/workflowFormTypes.test.mjs
Normal file
@@ -0,0 +1,198 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import {
|
||||
formStateToYaml,
|
||||
supportsMessageTextCondition,
|
||||
yamlToFormState,
|
||||
} from "./workflowFormTypes.ts";
|
||||
|
||||
function accepted(yaml) {
|
||||
const result = yamlToFormState(yaml);
|
||||
assert.equal(result.ok, true, result.ok ? undefined : result.error);
|
||||
return result.state;
|
||||
}
|
||||
|
||||
function normalizeBackendDefaults(value) {
|
||||
const copy = structuredClone(value);
|
||||
if (copy.enabled === undefined) copy.enabled = true;
|
||||
for (const step of copy.steps ?? []) {
|
||||
if (step.action === "call_webhook" && step.method === undefined) {
|
||||
step.method = "POST";
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
test("message-text conditions are limited to message-bearing triggers", () => {
|
||||
assert.equal(supportsMessageTextCondition("message_posted"), true);
|
||||
assert.equal(supportsMessageTextCondition("diff_posted"), true);
|
||||
assert.equal(supportsMessageTextCondition("reaction_added"), false);
|
||||
assert.equal(supportsMessageTextCondition("webhook"), false);
|
||||
assert.equal(supportsMessageTextCondition("schedule"), false);
|
||||
});
|
||||
|
||||
const acceptedFixtures = [
|
||||
`name: Notify\ntrigger:\n on: message_posted\nsteps:\n - id: notify_1\n action: send_message\n text: hello\n`,
|
||||
`name: React\ndescription: React to a message\nenabled: false\ntrigger:\n on: reaction_added\n emoji: eyes\n filter: trigger_message_id == "abc123"\nsteps:\n - id: react\n name: Add reaction\n timeout_secs: 30\n action: add_reaction\n emoji: white_check_mark\n`,
|
||||
`name: Webhook\ntrigger:\n on: webhook\nsteps:\n - id: call\n action: call_webhook\n url: https://example.com/hook\n method: PATCH\n headers:\n Authorization: secret\n X-Trace: trace\n body: '{"ok":true}'\n`,
|
||||
`name: Legacy actions\ntrigger:\n on: diff_posted\n filter: str_contains(trigger_text, "deploy")\nsteps:\n - id: dm\n action: send_dm\n to: abc123\n text: hello\n - id: approval\n action: request_approval\n from: manager\n message: Approve?\n timeout: 24h\n - id: topic\n action: set_channel_topic\n topic: Deployed\n - id: wait\n action: delay\n duration: 5m\n`,
|
||||
`name: Scheduled preset\ntrigger:\n on: schedule\n interval: 15m\nsteps:\n - id: notify\n action: send_message\n text: hello\n`,
|
||||
`name: Scheduled custom\ntrigger:\n on: schedule\n cron: 0 */2 * * 1,3,5\nsteps:\n - id: notify\n action: send_message\n text: hello\n`,
|
||||
`name: Scheduled legacy interval\ntrigger:\n on: schedule\n interval: 2h30m\nsteps:\n - id: notify\n action: send_message\n text: hello\n`,
|
||||
];
|
||||
|
||||
test("accepted Form fixtures survive a semantic YAML round trip", () => {
|
||||
for (const fixture of acceptedFixtures) {
|
||||
const generated = formStateToYaml(accepted(fixture));
|
||||
assert.deepEqual(
|
||||
normalizeBackendDefaults(parseYaml(generated)),
|
||||
normalizeBackendDefaults(parseYaml(fixture)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("recognized nodes with unknown fields are refused without touching YAML", () => {
|
||||
const fixtures = [
|
||||
`name: Test\nunknown: true\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
|
||||
`name: Test\ntrigger: { on: message_posted, future_filter: x }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi, retry: 3 }]\n`,
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, auth: bearer }]\n`,
|
||||
];
|
||||
|
||||
for (const yaml of fixtures) {
|
||||
const original = yaml;
|
||||
const result = yamlToFormState(yaml);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /YAML editor/);
|
||||
assert.equal(yaml, original);
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid IDs, shapes, and scalar types are refused", () => {
|
||||
const cases = [
|
||||
[
|
||||
"missing ID",
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: [{ action: send_message, text: hi }]\n`,
|
||||
],
|
||||
[
|
||||
"duplicate ID",
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: same, action: send_message, text: hi }, { id: same, action: delay, duration: 5m }]\n`,
|
||||
],
|
||||
[
|
||||
"invalid ID",
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: bad-id, action: send_message, text: hi }]\n`,
|
||||
],
|
||||
[
|
||||
"oversize ID",
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: ${"a".repeat(65)}, action: send_message, text: hi }]\n`,
|
||||
],
|
||||
[
|
||||
"steps object",
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: { id: s1, action: send_message, text: hi }\n`,
|
||||
],
|
||||
[
|
||||
"missing required action field",
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message }]\n`,
|
||||
],
|
||||
[
|
||||
"numeric text",
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: 42 }]\n`,
|
||||
],
|
||||
[
|
||||
"numeric header",
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, headers: { X-Retry: 3 } }]\n`,
|
||||
],
|
||||
[
|
||||
"zero timeout",
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, timeout_secs: 0, action: send_message, text: hi }]\n`,
|
||||
],
|
||||
[
|
||||
"fractional timeout",
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, timeout_secs: 1.5, action: send_message, text: hi }]\n`,
|
||||
],
|
||||
[
|
||||
"unsupported method",
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, method: OPTIONS }]\n`,
|
||||
],
|
||||
];
|
||||
|
||||
for (const [name, yaml] of cases) {
|
||||
assert.equal(yamlToFormState(yaml).ok, false, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("step condition capabilities stay in YAML mode", () => {
|
||||
const condition = `name: Conditional\ntrigger: { on: webhook }\nsteps: [{ id: s1, if: trigger_author == "abc", action: send_message, text: hi }]\n`;
|
||||
|
||||
const conditionResult = yamlToFormState(condition);
|
||||
assert.equal(conditionResult.ok, false);
|
||||
assert.match(conditionResult.error, /conditions.*YAML editor/);
|
||||
});
|
||||
|
||||
test("malformed and unowned schedule definitions stay losslessly in YAML mode", () => {
|
||||
const fixtures = [
|
||||
`name: Missing\ntrigger: { on: schedule }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
|
||||
`name: Both\ntrigger: { on: schedule, cron: "0 9 * * *", interval: 1h }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
|
||||
`name: Numeric\ntrigger: { on: schedule, interval: 30 }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
|
||||
`name: Unknown\ntrigger: { on: schedule, cron: "0 9 * * *", timezone: UTC }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
|
||||
`name: Invalid cron\ntrigger: { on: schedule, cron: "60 9 * * *" }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
|
||||
];
|
||||
|
||||
for (const yaml of fixtures) {
|
||||
const original = yaml;
|
||||
const result = yamlToFormState(yaml);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /YAML editor/);
|
||||
assert.equal(yaml, original);
|
||||
}
|
||||
});
|
||||
|
||||
test("the serializer emits only one schedule representation", () => {
|
||||
const yaml = formStateToYaml({
|
||||
name: "Exclusive",
|
||||
description: "",
|
||||
enabled: true,
|
||||
trigger: { on: "schedule", cron: "0 9 * * *", interval: "1h" },
|
||||
steps: [{ id: "s1", action: "send_message", text: "hi" }],
|
||||
});
|
||||
assert.deepEqual(parseYaml(yaml).trigger, {
|
||||
on: "schedule",
|
||||
cron: "0 9 * * *",
|
||||
});
|
||||
});
|
||||
|
||||
test("presents step timeout seconds as durations and serializes them numerically", () => {
|
||||
const yaml = `name: Timed\ntrigger: { on: webhook }\nsteps: [{ id: s1, timeout_secs: 3602, action: send_message, text: hi }]\n`;
|
||||
const state = accepted(yaml);
|
||||
|
||||
assert.equal(state.steps[0].timeoutSecs, "1h 2s");
|
||||
state.steps[0].timeoutSecs = "5m";
|
||||
assert.equal(parseYaml(formStateToYaml(state)).steps[0].timeout_secs, 300);
|
||||
});
|
||||
|
||||
test("advanced message expressions survive unrelated Form serialization", () => {
|
||||
const filter =
|
||||
'str_contains(trigger_text, "deploy") && trigger_author == "abc"';
|
||||
const yaml = `name: Advanced\ndescription: Before\ntrigger:\n on: message_posted\n filter: '${filter}'\nsteps:\n - id: s1\n action: send_message\n text: hi\n`;
|
||||
const state = accepted(yaml);
|
||||
state.description = "After";
|
||||
const generated = parseYaml(formStateToYaml(state));
|
||||
|
||||
assert.equal(generated.description, "After");
|
||||
assert.equal(generated.trigger.filter, filter);
|
||||
});
|
||||
|
||||
test("values the Form serializer would normalize are refused", () => {
|
||||
const fixtures = [
|
||||
`name: Test\ndescription: " spaced "\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
|
||||
`name: Test\ndescription: ""\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
|
||||
`name: Test\ntrigger: { on: reaction_added, emoji: "" }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, name: " spaced ", action: send_message, text: hi }]\n`,
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi, channel: "" }]\n`,
|
||||
`name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, headers: { " padded ": value } }]\n`,
|
||||
];
|
||||
|
||||
for (const yaml of fixtures) assert.equal(yamlToFormState(yaml).ok, false);
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { stringify as yamlStringify, parse as yamlParse } from "yaml";
|
||||
|
||||
import { cronExpressionError } from "./cronExpression";
|
||||
import {
|
||||
formatDurationSeconds,
|
||||
parseDurationSeconds,
|
||||
} from "./workflowDuration";
|
||||
|
||||
export const TRIGGER_TYPES = [
|
||||
"message_posted",
|
||||
"reaction_added",
|
||||
@@ -9,6 +15,20 @@ export const TRIGGER_TYPES = [
|
||||
] as const;
|
||||
export type TriggerType = (typeof TRIGGER_TYPES)[number];
|
||||
|
||||
export function supportsMessageTextCondition(
|
||||
triggerType: TriggerType,
|
||||
): boolean {
|
||||
return triggerType === "message_posted" || triggerType === "diff_posted";
|
||||
}
|
||||
|
||||
export const SELECTABLE_TRIGGER_TYPES = [
|
||||
"message_posted",
|
||||
"reaction_added",
|
||||
"diff_posted",
|
||||
"webhook",
|
||||
"schedule",
|
||||
] as const satisfies readonly TriggerType[];
|
||||
|
||||
export const ACTION_TYPES = [
|
||||
"delay",
|
||||
"send_message",
|
||||
@@ -20,6 +40,12 @@ export const ACTION_TYPES = [
|
||||
] as const;
|
||||
export type ActionType = (typeof ACTION_TYPES)[number];
|
||||
|
||||
export const SELECTABLE_ACTION_TYPES = [
|
||||
"send_message",
|
||||
"delay",
|
||||
"call_webhook",
|
||||
] as const satisfies readonly ActionType[];
|
||||
|
||||
export type TriggerConfig = {
|
||||
on: TriggerType;
|
||||
filter?: string;
|
||||
@@ -121,10 +147,8 @@ function headersToRecord(
|
||||
|
||||
function parseTimeoutSecs(timeoutSecs: string | undefined): number | undefined {
|
||||
if (!timeoutSecs) return undefined;
|
||||
const trimmed = timeoutSecs.trim();
|
||||
if (!/^\d+$/.test(trimmed)) return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
return parsed > 0 ? parsed : undefined;
|
||||
const parsed = parseDurationSeconds(timeoutSecs);
|
||||
return parsed !== null && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
|
||||
@@ -174,7 +198,8 @@ export function formStateToYaml(state: WorkflowFormState): string {
|
||||
const trigger: Record<string, unknown> = { on: state.trigger.on };
|
||||
if (
|
||||
(state.trigger.on === "message_posted" ||
|
||||
state.trigger.on === "diff_posted") &&
|
||||
state.trigger.on === "diff_posted" ||
|
||||
state.trigger.on === "reaction_added") &&
|
||||
state.trigger.filter
|
||||
) {
|
||||
trigger.filter = state.trigger.filter;
|
||||
@@ -183,8 +208,11 @@ export function formStateToYaml(state: WorkflowFormState): string {
|
||||
trigger.emoji = state.trigger.emoji;
|
||||
}
|
||||
if (state.trigger.on === "schedule") {
|
||||
if (state.trigger.cron) trigger.cron = state.trigger.cron;
|
||||
if (state.trigger.interval) trigger.interval = state.trigger.interval;
|
||||
if (state.trigger.cron) {
|
||||
trigger.cron = state.trigger.cron;
|
||||
} else if (state.trigger.interval) {
|
||||
trigger.interval = state.trigger.interval;
|
||||
}
|
||||
}
|
||||
|
||||
const steps = state.steps.map((step) => ({
|
||||
@@ -223,78 +251,352 @@ export function nextStepId(existingSteps: StepFormState[]): string {
|
||||
return `step_${n}`;
|
||||
}
|
||||
|
||||
const TOP_LEVEL_KEYS = new Set([
|
||||
"name",
|
||||
"description",
|
||||
"enabled",
|
||||
"trigger",
|
||||
"steps",
|
||||
]);
|
||||
const TRIGGER_KEYS: Record<TriggerType, ReadonlySet<string>> = {
|
||||
message_posted: new Set(["on", "filter"]),
|
||||
reaction_added: new Set(["on", "emoji", "filter"]),
|
||||
diff_posted: new Set(["on", "filter"]),
|
||||
webhook: new Set(["on"]),
|
||||
schedule: new Set(["on", "cron", "interval"]),
|
||||
};
|
||||
const COMMON_STEP_KEYS = ["id", "name", "action", "if", "timeout_secs"];
|
||||
const ACTION_STEP_KEYS: Record<ActionType, ReadonlySet<string>> = {
|
||||
delay: new Set([...COMMON_STEP_KEYS, "duration"]),
|
||||
send_message: new Set([...COMMON_STEP_KEYS, "text", "channel"]),
|
||||
send_dm: new Set([...COMMON_STEP_KEYS, "to", "text"]),
|
||||
call_webhook: new Set([
|
||||
...COMMON_STEP_KEYS,
|
||||
"url",
|
||||
"method",
|
||||
"headers",
|
||||
"body",
|
||||
]),
|
||||
request_approval: new Set([
|
||||
...COMMON_STEP_KEYS,
|
||||
"from",
|
||||
"message",
|
||||
"timeout",
|
||||
]),
|
||||
add_reaction: new Set([...COMMON_STEP_KEYS, "emoji"]),
|
||||
set_channel_topic: new Set([...COMMON_STEP_KEYS, "topic"]),
|
||||
};
|
||||
const REQUIRED_ACTION_STRING_KEYS: Record<ActionType, readonly string[]> = {
|
||||
delay: ["duration"],
|
||||
send_message: ["text"],
|
||||
send_dm: ["to", "text"],
|
||||
call_webhook: ["url"],
|
||||
request_approval: ["from", "message"],
|
||||
add_reaction: ["emoji"],
|
||||
set_channel_topic: ["topic"],
|
||||
};
|
||||
const OPTIONAL_ACTION_STRING_KEYS: Record<ActionType, readonly string[]> = {
|
||||
delay: [],
|
||||
send_message: ["channel"],
|
||||
send_dm: [],
|
||||
call_webhook: ["method", "body"],
|
||||
request_approval: ["timeout"],
|
||||
add_reaction: [],
|
||||
set_channel_topic: [],
|
||||
};
|
||||
const WEBHOOK_METHODS = new Set(["POST", "GET", "PUT", "PATCH", "DELETE"]);
|
||||
const STEP_ID_PATTERN_STRICT = /^[A-Za-z0-9_]{1,64}$/;
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
function objectRecord(value: unknown): UnknownRecord | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as UnknownRecord)
|
||||
: null;
|
||||
}
|
||||
|
||||
function unknownKey(
|
||||
record: UnknownRecord,
|
||||
allowed: ReadonlySet<string>,
|
||||
): string | null {
|
||||
return Object.keys(record).find((key) => !allowed.has(key)) ?? null;
|
||||
}
|
||||
|
||||
function requireNonEmptyString(
|
||||
record: UnknownRecord,
|
||||
key: string,
|
||||
label: string,
|
||||
): string | { error: string } {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
return { error: `${label} must be a non-empty string` };
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalOwnedStringError(
|
||||
record: UnknownRecord,
|
||||
key: string,
|
||||
label: string,
|
||||
): string | null {
|
||||
const value = record[key];
|
||||
if (value === undefined) return null;
|
||||
if (typeof value !== "string") return `${label} must be a string`;
|
||||
if (value.length === 0) {
|
||||
return `${label} cannot be empty in Form mode — use the YAML editor`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function yamlToFormState(
|
||||
yaml: string,
|
||||
): { ok: true; state: WorkflowFormState } | { ok: false; error: string } {
|
||||
try {
|
||||
const parsed = yamlParse(yaml);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { ok: false, error: "YAML must be an object" };
|
||||
}
|
||||
const parsed = objectRecord(yamlParse(yaml));
|
||||
if (!parsed) return { ok: false, error: "YAML must be an object" };
|
||||
|
||||
const triggerOn = parsed.trigger?.on;
|
||||
if (triggerOn && !TRIGGER_TYPES.includes(triggerOn as TriggerType)) {
|
||||
const topUnknown = unknownKey(parsed, TOP_LEVEL_KEYS);
|
||||
if (topUnknown) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unsupported trigger type "${triggerOn}" — use the YAML editor`,
|
||||
error: `Unsupported workflow field "${topUnknown}" — use the YAML editor`,
|
||||
};
|
||||
}
|
||||
const trigger: TriggerConfig = {
|
||||
on: (triggerOn as TriggerType) ?? TRIGGER_TYPES[0],
|
||||
filter: parsed.trigger?.filter ?? undefined,
|
||||
emoji: parsed.trigger?.emoji ?? undefined,
|
||||
cron: parsed.trigger?.cron ?? undefined,
|
||||
interval: parsed.trigger?.interval ?? undefined,
|
||||
};
|
||||
|
||||
const rawSteps = parsed.steps ?? [];
|
||||
if (!Array.isArray(rawSteps)) {
|
||||
return { ok: false, error: "steps must be a list" };
|
||||
if (typeof parsed.name !== "string") {
|
||||
return { ok: false, error: "name must be a string" };
|
||||
}
|
||||
|
||||
for (const step of rawSteps) {
|
||||
if (step.action && !ACTION_TYPES.includes(step.action as ActionType)) {
|
||||
if (parsed.description !== undefined) {
|
||||
if (typeof parsed.description !== "string") {
|
||||
return { ok: false, error: "description must be a string" };
|
||||
}
|
||||
if (
|
||||
parsed.description.length === 0 ||
|
||||
parsed.description.trim() !== parsed.description
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unsupported action type "${step.action}" — use the YAML editor`,
|
||||
error:
|
||||
"description cannot be empty or have surrounding whitespace in Form mode — use the YAML editor",
|
||||
};
|
||||
}
|
||||
}
|
||||
if (parsed.enabled !== undefined && typeof parsed.enabled !== "boolean") {
|
||||
return { ok: false, error: "enabled must be a boolean" };
|
||||
}
|
||||
|
||||
const steps: StepFormState[] = rawSteps.map(
|
||||
(step: Record<string, unknown>, index: number) => ({
|
||||
id: (step.id as string) ?? `step_${index + 1}`,
|
||||
const rawTrigger = objectRecord(parsed.trigger);
|
||||
if (!rawTrigger || typeof rawTrigger.on !== "string") {
|
||||
return { ok: false, error: "trigger.on is required" };
|
||||
}
|
||||
if (!TRIGGER_TYPES.includes(rawTrigger.on as TriggerType)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unsupported trigger type "${rawTrigger.on}" — use the YAML editor`,
|
||||
};
|
||||
}
|
||||
const triggerOn = rawTrigger.on as TriggerType;
|
||||
const triggerUnknown = unknownKey(rawTrigger, TRIGGER_KEYS[triggerOn]);
|
||||
if (triggerUnknown) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unsupported ${triggerOn} trigger field "${triggerUnknown}" — use the YAML editor`,
|
||||
};
|
||||
}
|
||||
for (const key of ["filter", "emoji", "cron", "interval"] as const) {
|
||||
const error = optionalOwnedStringError(rawTrigger, key, `trigger.${key}`);
|
||||
if (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
triggerOn === "schedule" && !error.includes("YAML editor")
|
||||
? `${error} — use the YAML editor`
|
||||
: error,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (triggerOn === "schedule") {
|
||||
const hasCron = rawTrigger.cron !== undefined;
|
||||
const hasInterval = rawTrigger.interval !== undefined;
|
||||
if (hasCron === hasInterval) {
|
||||
return {
|
||||
ok: false,
|
||||
error: hasCron
|
||||
? "Schedule triggers cannot specify both cron and interval — use the YAML editor"
|
||||
: "Schedule triggers require either cron or interval — use the YAML editor",
|
||||
};
|
||||
}
|
||||
if (typeof rawTrigger.cron === "string") {
|
||||
const error = cronExpressionError(rawTrigger.cron);
|
||||
if (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unsupported cron expression: ${error} Use the YAML editor`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const trigger: TriggerConfig = {
|
||||
on: triggerOn,
|
||||
filter: rawTrigger.filter as string | undefined,
|
||||
emoji: rawTrigger.emoji as string | undefined,
|
||||
cron: rawTrigger.cron as string | undefined,
|
||||
interval: rawTrigger.interval as string | undefined,
|
||||
};
|
||||
|
||||
if (!Array.isArray(parsed.steps)) {
|
||||
return { ok: false, error: "steps must be a list" };
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
const steps: StepFormState[] = [];
|
||||
for (const [index, value] of parsed.steps.entries()) {
|
||||
const number = index + 1;
|
||||
const step = objectRecord(value);
|
||||
if (!step)
|
||||
return { ok: false, error: `Step ${number} must be an object` };
|
||||
if (
|
||||
typeof step.id !== "string" ||
|
||||
!STEP_ID_PATTERN_STRICT.test(step.id)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Step ${number} requires a unique 1–64 character alphanumeric or underscore ID`,
|
||||
};
|
||||
}
|
||||
if (ids.has(step.id)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Duplicate step ID "${step.id}" — use the YAML editor`,
|
||||
};
|
||||
}
|
||||
ids.add(step.id);
|
||||
|
||||
if (
|
||||
typeof step.action !== "string" ||
|
||||
!ACTION_TYPES.includes(step.action as ActionType)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unsupported action type "${String(step.action)}" — use the YAML editor`,
|
||||
};
|
||||
}
|
||||
const action = step.action as ActionType;
|
||||
const stepUnknown = unknownKey(step, ACTION_STEP_KEYS[action]);
|
||||
if (stepUnknown) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unsupported ${action} step field "${stepUnknown}" — use the YAML editor`,
|
||||
};
|
||||
}
|
||||
if (step.if !== undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Step conditions are only available in the YAML editor",
|
||||
};
|
||||
}
|
||||
const nameError = optionalOwnedStringError(
|
||||
step,
|
||||
"name",
|
||||
`Step ${number} name`,
|
||||
);
|
||||
if (nameError) return { ok: false, error: nameError };
|
||||
if (typeof step.name === "string" && step.name.trim() !== step.name) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Step ${number} name has surrounding whitespace — use the YAML editor`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
step.timeout_secs !== undefined &&
|
||||
(!Number.isSafeInteger(step.timeout_secs) ||
|
||||
(step.timeout_secs as number) <= 0)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Step ${number} timeout_secs must be a positive integer`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const key of REQUIRED_ACTION_STRING_KEYS[action]) {
|
||||
const required = requireNonEmptyString(
|
||||
step,
|
||||
key,
|
||||
`Step ${number} ${key}`,
|
||||
);
|
||||
if (typeof required !== "string")
|
||||
return { ok: false, error: required.error };
|
||||
}
|
||||
for (const key of OPTIONAL_ACTION_STRING_KEYS[action]) {
|
||||
const error = optionalOwnedStringError(
|
||||
step,
|
||||
key,
|
||||
`Step ${number} ${key}`,
|
||||
);
|
||||
if (error) return { ok: false, error };
|
||||
}
|
||||
if (
|
||||
action === "call_webhook" &&
|
||||
step.method !== undefined &&
|
||||
!WEBHOOK_METHODS.has(step.method as string)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unsupported webhook method "${String(step.method)}" — use the YAML editor`,
|
||||
};
|
||||
}
|
||||
if (step.headers !== undefined) {
|
||||
const headers = objectRecord(step.headers);
|
||||
if (
|
||||
!headers ||
|
||||
Object.keys(headers).length === 0 ||
|
||||
Object.values(headers).some((header) => typeof header !== "string")
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Webhook headers must be a non-empty object containing string values",
|
||||
};
|
||||
}
|
||||
const unsafeHeader = Object.keys(headers).find(
|
||||
(key) => key.length === 0 || key.trim() !== key,
|
||||
);
|
||||
if (unsafeHeader !== undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Webhook header names cannot be empty or have surrounding whitespace in Form mode",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
steps.push({
|
||||
id: step.id,
|
||||
name: step.name as string | undefined,
|
||||
action: (step.action as ActionType) ?? ACTION_TYPES[0],
|
||||
condition: step.if as string | undefined,
|
||||
action,
|
||||
timeoutSecs:
|
||||
step.timeout_secs !== undefined
|
||||
? String(step.timeout_secs)
|
||||
: undefined,
|
||||
step.timeout_secs === undefined
|
||||
? undefined
|
||||
: formatDurationSeconds(step.timeout_secs as number),
|
||||
duration: step.duration as string | undefined,
|
||||
text: step.text as string | undefined,
|
||||
channel: step.channel as string | undefined,
|
||||
to: step.to as string | undefined,
|
||||
url: step.url as string | undefined,
|
||||
method: step.method as string | undefined,
|
||||
headers: toHeaderRows(
|
||||
step.headers,
|
||||
(step.id as string) ?? `step_${index + 1}`,
|
||||
),
|
||||
headers: toHeaderRows(step.headers, step.id),
|
||||
body: step.body as string | undefined,
|
||||
emoji: step.emoji as string | undefined,
|
||||
topic: step.topic as string | undefined,
|
||||
from: step.from as string | undefined,
|
||||
message: step.message as string | undefined,
|
||||
timeout: step.timeout as string | undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
state: {
|
||||
name: (parsed.name as string) ?? "",
|
||||
description: (parsed.description as string) ?? "",
|
||||
name: parsed.name,
|
||||
description: (parsed.description as string | undefined) ?? "",
|
||||
enabled: parsed.enabled !== false,
|
||||
trigger,
|
||||
steps,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
MESSAGE_TEXT_CONDITION_OPERATORS,
|
||||
buildMessageTextCondition,
|
||||
parseMessageTextCondition,
|
||||
} from "./workflowMessageTextCondition.ts";
|
||||
|
||||
test("builds and parses every structured message-text condition", () => {
|
||||
for (const operator of MESSAGE_TEXT_CONDITION_OPERATORS) {
|
||||
const condition = { operator, value: "deploy" };
|
||||
const expression = buildMessageTextCondition(condition);
|
||||
assert.notEqual(expression, null);
|
||||
assert.deepEqual(parseMessageTextCondition(expression), {
|
||||
operator,
|
||||
value:
|
||||
operator === "is_not_empty" || operator === "is_empty" ? "" : "deploy",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("escapes and restores evalexpr quote and backslash literals", () => {
|
||||
const condition = {
|
||||
operator: "equals",
|
||||
value: 'say "hello"\\world',
|
||||
};
|
||||
const expression = buildMessageTextCondition(condition);
|
||||
|
||||
assert.equal(expression, 'trigger_text == "say \\"hello\\"\\\\world"');
|
||||
assert.deepEqual(parseMessageTextCondition(expression), condition);
|
||||
});
|
||||
|
||||
test("parses only exact expressions emitted by the structured form", () => {
|
||||
for (const expression of [
|
||||
' str_contains(trigger_text, "deploy")',
|
||||
'str_contains(trigger_text,"deploy")',
|
||||
'str_contains(other_text, "deploy")',
|
||||
'str_contains(trigger_text, "one") && trigger_author == "abc"',
|
||||
'trigger_text == "bad\\nvalue"',
|
||||
]) {
|
||||
assert.equal(parseMessageTextCondition(expression), null, expression);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not build value operators until text is present", () => {
|
||||
assert.equal(
|
||||
buildMessageTextCondition({ operator: "contains", value: " " }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
buildMessageTextCondition({ operator: "is_empty", value: "ignored" }),
|
||||
"str_len(trigger_text) == 0",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
export const MESSAGE_TEXT_CONDITION_OPERATORS = [
|
||||
"contains",
|
||||
"not_contains",
|
||||
"starts_with",
|
||||
"ends_with",
|
||||
"equals",
|
||||
"not_equals",
|
||||
"is_not_empty",
|
||||
"is_empty",
|
||||
] as const;
|
||||
|
||||
export type MessageTextConditionOperator =
|
||||
(typeof MESSAGE_TEXT_CONDITION_OPERATORS)[number];
|
||||
|
||||
export type MessageTextCondition = {
|
||||
operator: MessageTextConditionOperator;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const MESSAGE_TEXT_CONDITION_LABELS: Record<
|
||||
MessageTextConditionOperator,
|
||||
string
|
||||
> = {
|
||||
contains: "Contains",
|
||||
not_contains: "Does not contain",
|
||||
starts_with: "Starts with",
|
||||
ends_with: "Ends with",
|
||||
equals: "Equals",
|
||||
not_equals: "Does not equal",
|
||||
is_not_empty: "Has text",
|
||||
is_empty: "Has no text",
|
||||
};
|
||||
|
||||
export function messageTextConditionNeedsValue(
|
||||
operator: MessageTextConditionOperator,
|
||||
): boolean {
|
||||
return operator !== "is_not_empty" && operator !== "is_empty";
|
||||
}
|
||||
|
||||
function escapeEvalexprString(value: string): string {
|
||||
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
||||
}
|
||||
|
||||
export function buildMessageTextCondition(
|
||||
condition: MessageTextCondition,
|
||||
): string | null {
|
||||
if (condition.operator === "is_not_empty") {
|
||||
return "str_len(trigger_text) > 0";
|
||||
}
|
||||
if (condition.operator === "is_empty") {
|
||||
return "str_len(trigger_text) == 0";
|
||||
}
|
||||
if (!condition.value.trim()) return null;
|
||||
|
||||
const quotedValue = `"${escapeEvalexprString(condition.value)}"`;
|
||||
switch (condition.operator) {
|
||||
case "contains":
|
||||
return `str_contains(trigger_text, ${quotedValue})`;
|
||||
case "not_contains":
|
||||
return `!str_contains(trigger_text, ${quotedValue})`;
|
||||
case "starts_with":
|
||||
return `str_starts_with(trigger_text, ${quotedValue})`;
|
||||
case "ends_with":
|
||||
return `str_ends_with(trigger_text, ${quotedValue})`;
|
||||
case "equals":
|
||||
return `trigger_text == ${quotedValue}`;
|
||||
case "not_equals":
|
||||
return `trigger_text != ${quotedValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
function unescapeEvalexprString(value: string): string {
|
||||
return value.replaceAll(/\\(["\\])/g, "$1");
|
||||
}
|
||||
|
||||
/** Parse only the exact expression shapes emitted by the structured editor. */
|
||||
export function parseMessageTextCondition(
|
||||
expression: string,
|
||||
): MessageTextCondition | null {
|
||||
let parsed: MessageTextCondition | null = null;
|
||||
if (expression === "str_len(trigger_text) > 0") {
|
||||
parsed = { operator: "is_not_empty", value: "" };
|
||||
} else if (expression === "str_len(trigger_text) == 0") {
|
||||
parsed = { operator: "is_empty", value: "" };
|
||||
} else {
|
||||
const stringLiteral = '"((?:\\\\["\\\\]|[^"\\\\])*)"';
|
||||
const functionMatch = new RegExp(
|
||||
`^(!)?str_(contains|starts_with|ends_with)\\(trigger_text, ${stringLiteral}\\)$`,
|
||||
).exec(expression);
|
||||
if (functionMatch) {
|
||||
parsed = {
|
||||
operator: functionMatch[1]
|
||||
? "not_contains"
|
||||
: functionMatch[2] === "starts_with"
|
||||
? "starts_with"
|
||||
: functionMatch[2] === "ends_with"
|
||||
? "ends_with"
|
||||
: "contains",
|
||||
value: unescapeEvalexprString(functionMatch[3]),
|
||||
};
|
||||
} else {
|
||||
const equalityMatch = new RegExp(
|
||||
`^trigger_text (!=|==) ${stringLiteral}$`,
|
||||
).exec(expression);
|
||||
if (equalityMatch) {
|
||||
parsed = {
|
||||
operator: equalityMatch[1] === "==" ? "equals" : "not_equals",
|
||||
value: unescapeEvalexprString(equalityMatch[2]),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsed && buildMessageTextCondition(parsed) === expression
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
89
desktop/src/features/workflows/ui/workflowSchedule.test.mjs
Normal file
89
desktop/src/features/workflows/ui/workflowSchedule.test.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
defaultScheduleTrigger,
|
||||
scheduleFormFromTrigger,
|
||||
scheduleTriggerFromForm,
|
||||
scheduleWeekdaysFromCronField,
|
||||
} from "./workflowSchedule.ts";
|
||||
|
||||
test("new schedules default to daily at 09:00 UTC", () => {
|
||||
assert.deepEqual(defaultScheduleTrigger(), {
|
||||
on: "schedule",
|
||||
cron: "0 9 * * *",
|
||||
});
|
||||
});
|
||||
|
||||
test("recognizes the 15m, 30m, and hourly presets", () => {
|
||||
assert.equal(
|
||||
scheduleFormFromTrigger({ on: "schedule", interval: "15m" }).frequency,
|
||||
"every_15_minutes",
|
||||
);
|
||||
assert.equal(
|
||||
scheduleFormFromTrigger({ on: "schedule", interval: "30m" }).frequency,
|
||||
"every_30_minutes",
|
||||
);
|
||||
assert.equal(
|
||||
scheduleFormFromTrigger({ on: "schedule", interval: "1h" }).frequency,
|
||||
"hourly",
|
||||
);
|
||||
});
|
||||
|
||||
test("round-trips daily, weekly, monthly, and custom cron schedules", () => {
|
||||
for (const cron of [
|
||||
"30 14 * * *",
|
||||
"30 14 * * 1-5",
|
||||
"30 14 * * 1,3,5",
|
||||
"30 14 * * 4",
|
||||
"30 14 23 * *",
|
||||
"0 */2 * * 1,3,5",
|
||||
]) {
|
||||
const form = scheduleFormFromTrigger({ on: "schedule", cron });
|
||||
assert.deepEqual(scheduleTriggerFromForm(form), { on: "schedule", cron });
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves arbitrary custom cron and legacy interval strings exactly", () => {
|
||||
const cron = " 0 */2 * * 1,3,5 ";
|
||||
const cronForm = scheduleFormFromTrigger({ on: "schedule", cron });
|
||||
assert.equal(cronForm.frequency, "custom_cron");
|
||||
assert.equal(cronForm.customCron, cron);
|
||||
assert.deepEqual(scheduleTriggerFromForm(cronForm), {
|
||||
on: "schedule",
|
||||
cron,
|
||||
});
|
||||
|
||||
const interval = "2h30m";
|
||||
const intervalForm = scheduleFormFromTrigger({ on: "schedule", interval });
|
||||
assert.equal(intervalForm.frequency, "custom_interval");
|
||||
assert.equal(intervalForm.customInterval, interval);
|
||||
assert.deepEqual(scheduleTriggerFromForm(intervalForm), {
|
||||
on: "schedule",
|
||||
interval,
|
||||
});
|
||||
});
|
||||
|
||||
test("expands numeric weekday lists and ranges for the weekly picker", () => {
|
||||
assert.deepEqual(scheduleWeekdaysFromCronField("1-5"), [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
]);
|
||||
assert.deepEqual(scheduleWeekdaysFromCronField("1,3,5"), ["1", "3", "5"]);
|
||||
assert.deepEqual(scheduleWeekdaysFromCronField("MON-FRI"), []);
|
||||
});
|
||||
|
||||
test("switching schedule modes never emits cron and interval together", () => {
|
||||
const form = scheduleFormFromTrigger({ on: "schedule", interval: "30m" });
|
||||
const custom = scheduleTriggerFromForm({
|
||||
...form,
|
||||
customCron: "0 8 * * 6",
|
||||
frequency: "custom_cron",
|
||||
});
|
||||
|
||||
assert.deepEqual(custom, { on: "schedule", cron: "0 8 * * 6" });
|
||||
assert.equal("interval" in custom, false);
|
||||
});
|
||||
181
desktop/src/features/workflows/ui/workflowSchedule.ts
Normal file
181
desktop/src/features/workflows/ui/workflowSchedule.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { TriggerConfig } from "./workflowFormTypes";
|
||||
|
||||
export const SCHEDULE_FREQUENCIES = [
|
||||
"every_15_minutes",
|
||||
"every_30_minutes",
|
||||
"hourly",
|
||||
"daily",
|
||||
"weekly",
|
||||
"monthly",
|
||||
"custom_cron",
|
||||
] as const;
|
||||
|
||||
export type ScheduleFrequency = (typeof SCHEDULE_FREQUENCIES)[number];
|
||||
type ScheduleFormFrequency = ScheduleFrequency | "custom_interval";
|
||||
|
||||
export type ScheduleFormState = {
|
||||
customCron: string;
|
||||
customInterval: string;
|
||||
frequency: ScheduleFormFrequency;
|
||||
monthDay: string;
|
||||
time: string;
|
||||
weekday: string;
|
||||
};
|
||||
|
||||
export const SCHEDULE_FREQUENCY_LABELS: Record<ScheduleFrequency, string> = {
|
||||
every_15_minutes: "Every 15 minutes",
|
||||
every_30_minutes: "Every 30 minutes",
|
||||
hourly: "Every hour",
|
||||
daily: "Daily",
|
||||
weekly: "Weekly",
|
||||
monthly: "Monthly",
|
||||
custom_cron: "Custom cron",
|
||||
};
|
||||
|
||||
const INTERVAL_FREQUENCIES = {
|
||||
"15m": "every_15_minutes",
|
||||
"30m": "every_30_minutes",
|
||||
"1h": "hourly",
|
||||
} as const;
|
||||
|
||||
const FREQUENCY_INTERVALS: Partial<Record<ScheduleFrequency, string>> = {
|
||||
every_15_minutes: "15m",
|
||||
every_30_minutes: "30m",
|
||||
hourly: "1h",
|
||||
};
|
||||
|
||||
const DEFAULT_TIME = "09:00";
|
||||
const DEFAULT_WEEKDAY = "1";
|
||||
const DEFAULT_MONTH_DAY = "1";
|
||||
|
||||
type ParsedCommonCron = {
|
||||
frequency: "daily" | "weekly" | "monthly";
|
||||
monthDay?: string;
|
||||
time: string;
|
||||
weekday?: string;
|
||||
};
|
||||
|
||||
export function scheduleWeekdaysFromCronField(field: string): string[] {
|
||||
const weekdays = new Set<number>();
|
||||
for (const segment of field.split(",")) {
|
||||
if (/^[0-6]$/.test(segment)) {
|
||||
weekdays.add(Number(segment));
|
||||
continue;
|
||||
}
|
||||
|
||||
const range = /^([0-6])-([0-6])$/.exec(segment);
|
||||
if (!range || Number(range[1]) > Number(range[2])) return [];
|
||||
for (let day = Number(range[1]); day <= Number(range[2]); day += 1) {
|
||||
weekdays.add(day);
|
||||
}
|
||||
}
|
||||
return [...weekdays].sort((left, right) => left - right).map(String);
|
||||
}
|
||||
|
||||
function parseCommonCron(cron: string): ParsedCommonCron | null {
|
||||
const fields = cron.trim().split(/\s+/);
|
||||
if (fields.length !== 5) return null;
|
||||
|
||||
const [minute, hour, monthDay, month, weekday] = fields;
|
||||
const parsedMinute = Number(minute);
|
||||
const parsedHour = Number(hour);
|
||||
if (
|
||||
!/^\d+$/.test(minute) ||
|
||||
!/^\d+$/.test(hour) ||
|
||||
parsedMinute < 0 ||
|
||||
parsedMinute > 59 ||
|
||||
parsedHour < 0 ||
|
||||
parsedHour > 23 ||
|
||||
month !== "*"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const time = `${String(parsedHour).padStart(2, "0")}:${String(parsedMinute).padStart(2, "0")}`;
|
||||
if (monthDay === "*" && weekday === "*") {
|
||||
return { frequency: "daily", time };
|
||||
}
|
||||
if (monthDay === "*" && scheduleWeekdaysFromCronField(weekday).length > 0) {
|
||||
return { frequency: "weekly", time, weekday };
|
||||
}
|
||||
if (weekday === "*" && /^(?:[1-9]|[12]\d|3[01])$/.test(monthDay)) {
|
||||
return { frequency: "monthly", monthDay, time };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function scheduleFormFromTrigger(
|
||||
trigger: TriggerConfig,
|
||||
): ScheduleFormState {
|
||||
const interval = trigger.interval;
|
||||
if (interval) {
|
||||
const normalizedInterval = interval.trim();
|
||||
const frequency =
|
||||
INTERVAL_FREQUENCIES[
|
||||
normalizedInterval as keyof typeof INTERVAL_FREQUENCIES
|
||||
];
|
||||
return {
|
||||
customCron: "",
|
||||
customInterval: frequency ? "" : interval,
|
||||
frequency: frequency ?? "custom_interval",
|
||||
monthDay: DEFAULT_MONTH_DAY,
|
||||
time: DEFAULT_TIME,
|
||||
weekday: DEFAULT_WEEKDAY,
|
||||
};
|
||||
}
|
||||
|
||||
const cron = trigger.cron ?? "";
|
||||
const commonCron = cron ? parseCommonCron(cron) : null;
|
||||
return {
|
||||
customCron: commonCron ? "" : cron,
|
||||
customInterval: "",
|
||||
frequency: commonCron?.frequency ?? (cron ? "custom_cron" : "daily"),
|
||||
monthDay: commonCron?.monthDay ?? DEFAULT_MONTH_DAY,
|
||||
time: commonCron?.time ?? DEFAULT_TIME,
|
||||
weekday: commonCron?.weekday ?? DEFAULT_WEEKDAY,
|
||||
};
|
||||
}
|
||||
|
||||
function cronTrigger(cron: string): TriggerConfig {
|
||||
return { on: "schedule", cron };
|
||||
}
|
||||
|
||||
function intervalTrigger(interval: string): TriggerConfig {
|
||||
return { on: "schedule", interval };
|
||||
}
|
||||
|
||||
function cronTime(time: string): { hour: string; minute: string } {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(time);
|
||||
if (!match) return { hour: "9", minute: "0" };
|
||||
return {
|
||||
hour: String(Number(match[1])),
|
||||
minute: String(Number(match[2])),
|
||||
};
|
||||
}
|
||||
|
||||
export function scheduleTriggerFromForm(
|
||||
form: ScheduleFormState,
|
||||
): TriggerConfig {
|
||||
const interval = FREQUENCY_INTERVALS[form.frequency as ScheduleFrequency];
|
||||
if (interval) return intervalTrigger(interval);
|
||||
if (form.frequency === "custom_interval") {
|
||||
return intervalTrigger(form.customInterval);
|
||||
}
|
||||
if (form.frequency === "custom_cron") {
|
||||
return cronTrigger(form.customCron);
|
||||
}
|
||||
|
||||
const { hour, minute } = cronTime(form.time);
|
||||
switch (form.frequency) {
|
||||
case "weekly":
|
||||
return cronTrigger(`${minute} ${hour} * * ${form.weekday}`);
|
||||
case "monthly":
|
||||
return cronTrigger(`${minute} ${hour} ${form.monthDay} * *`);
|
||||
default:
|
||||
return cronTrigger(`${minute} ${hour} * * *`);
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultScheduleTrigger(): TriggerConfig {
|
||||
return scheduleTriggerFromForm(scheduleFormFromTrigger({ on: "schedule" }));
|
||||
}
|
||||
148
desktop/src/features/workflows/ui/workflowYamlDocument.test.mjs
Normal file
148
desktop/src/features/workflows/ui/workflowYamlDocument.test.mjs
Normal file
@@ -0,0 +1,148 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
readWorkflowDocumentFields,
|
||||
readWorkflowHeaderState,
|
||||
yamlWithWorkflowEnabled,
|
||||
yamlWithWorkflowName,
|
||||
} from "./workflowYamlDocument.ts";
|
||||
|
||||
/** The name the dialog header would render for `yaml`. */
|
||||
function headerName(yaml, fallbackName) {
|
||||
return readWorkflowHeaderState(yaml, { enabled: true, name: fallbackName })
|
||||
.name;
|
||||
}
|
||||
|
||||
/** The enabled state the dialog header would render for `yaml`. */
|
||||
function headerEnabled(yaml, fallbackEnabled) {
|
||||
return readWorkflowHeaderState(yaml, {
|
||||
enabled: fallbackEnabled,
|
||||
name: undefined,
|
||||
}).enabled;
|
||||
}
|
||||
|
||||
// A step that has just been added from the builder carries no message text yet,
|
||||
// so the definition fails full form validation while the user is still on the
|
||||
// step pane. The header must keep working against that document.
|
||||
const INCOMPLETE_STEP_YAML = `name: mock-horse-battery
|
||||
trigger:
|
||||
on: message_posted
|
||||
steps:
|
||||
- id: step_1
|
||||
action: send_message
|
||||
`;
|
||||
|
||||
test("reads the name of a definition whose steps are still incomplete", () => {
|
||||
assert.deepEqual(readWorkflowDocumentFields(INCOMPLETE_STEP_YAML), {
|
||||
editable: true,
|
||||
enabled: null,
|
||||
name: "mock-horse-battery",
|
||||
});
|
||||
assert.equal(
|
||||
headerName(INCOMPLETE_STEP_YAML, undefined),
|
||||
"mock-horse-battery",
|
||||
);
|
||||
});
|
||||
|
||||
test("treats an empty definition as an editable blank name", () => {
|
||||
assert.deepEqual(readWorkflowDocumentFields(""), {
|
||||
editable: true,
|
||||
enabled: null,
|
||||
name: null,
|
||||
});
|
||||
assert.deepEqual(readWorkflowDocumentFields(" \n"), {
|
||||
editable: true,
|
||||
enabled: null,
|
||||
name: null,
|
||||
});
|
||||
assert.equal(headerName("", undefined), "");
|
||||
});
|
||||
|
||||
test("falls back to the saved name only when the document has none", () => {
|
||||
assert.equal(headerName("", "Saved name"), "Saved name");
|
||||
assert.equal(
|
||||
headerName("name: ''\ntrigger:\n on: webhook\n", "Saved name"),
|
||||
"Saved name",
|
||||
);
|
||||
assert.equal(
|
||||
headerName(INCOMPLETE_STEP_YAML, "Saved name"),
|
||||
"mock-horse-battery",
|
||||
);
|
||||
});
|
||||
|
||||
test("trims the rendered name and the saved fallback", () => {
|
||||
assert.equal(headerName('name: " spaced "\n', undefined), "spaced");
|
||||
assert.equal(headerName("", " saved "), "saved");
|
||||
});
|
||||
|
||||
test("marks unparseable or non-map documents as uneditable", () => {
|
||||
assert.deepEqual(readWorkflowDocumentFields("name: [unclosed\n"), {
|
||||
editable: false,
|
||||
enabled: null,
|
||||
name: null,
|
||||
});
|
||||
assert.deepEqual(readWorkflowDocumentFields("- just\n- a list\n"), {
|
||||
editable: false,
|
||||
enabled: null,
|
||||
name: null,
|
||||
});
|
||||
assert.equal(yamlWithWorkflowName("- just\n- a list\n", "next"), null);
|
||||
assert.equal(yamlWithWorkflowEnabled("- just\n- a list\n", false), null);
|
||||
});
|
||||
|
||||
test("ignores a non-string name rather than rendering it", () => {
|
||||
assert.equal(readWorkflowDocumentFields("name: 42\n").name, null);
|
||||
assert.equal(headerName("name: 42\n", "Saved name"), "Saved name");
|
||||
});
|
||||
|
||||
test("keeps the header editable while a step is still incomplete", () => {
|
||||
assert.deepEqual(
|
||||
readWorkflowHeaderState(INCOMPLETE_STEP_YAML, {
|
||||
enabled: true,
|
||||
name: "Saved name",
|
||||
}),
|
||||
{ canEdit: true, enabled: true, name: "mock-horse-battery" },
|
||||
);
|
||||
assert.equal(
|
||||
readWorkflowHeaderState("name: [unclosed\n", {
|
||||
enabled: false,
|
||||
name: "Saved name",
|
||||
}).canEdit,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("derives enabled from the document, defaulting to enabled", () => {
|
||||
assert.equal(headerEnabled(INCOMPLETE_STEP_YAML, false), true);
|
||||
assert.equal(
|
||||
headerEnabled(`enabled: false\n${INCOMPLETE_STEP_YAML}`, true),
|
||||
false,
|
||||
);
|
||||
// Only an unreadable document may fall back to the saved value.
|
||||
assert.equal(headerEnabled("- just\n- a list\n", false), false);
|
||||
assert.equal(headerEnabled("- just\n- a list\n", true), true);
|
||||
});
|
||||
|
||||
test("writes the name back without disturbing the rest of the document", () => {
|
||||
const next = yamlWithWorkflowName(INCOMPLETE_STEP_YAML, "renamed");
|
||||
assert.equal(headerName(next, undefined), "renamed");
|
||||
assert.match(next, /action: send_message/);
|
||||
assert.match(next, /on: message_posted/);
|
||||
});
|
||||
|
||||
test("seeds a definition when naming an empty document", () => {
|
||||
const next = yamlWithWorkflowName("", "fresh");
|
||||
assert.equal(headerName(next, undefined), "fresh");
|
||||
assert.match(next, /trigger:/);
|
||||
});
|
||||
|
||||
test("adds and removes the enabled key without touching the name", () => {
|
||||
const disabled = yamlWithWorkflowEnabled(INCOMPLETE_STEP_YAML, false);
|
||||
assert.match(disabled, /enabled: false/);
|
||||
assert.equal(headerName(disabled, undefined), "mock-horse-battery");
|
||||
|
||||
const reEnabled = yamlWithWorkflowEnabled(disabled, true);
|
||||
assert.doesNotMatch(reEnabled, /enabled:/);
|
||||
assert.equal(headerName(reEnabled, undefined), "mock-horse-battery");
|
||||
});
|
||||
114
desktop/src/features/workflows/ui/workflowYamlDocument.ts
Normal file
114
desktop/src/features/workflows/ui/workflowYamlDocument.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { isMap, parseDocument } from "yaml";
|
||||
|
||||
import { DEFAULT_FORM_STATE, formStateToYaml } from "./workflowFormTypes";
|
||||
|
||||
/**
|
||||
* Header-level view of a workflow definition.
|
||||
*
|
||||
* The dialog header (name, name editing, enabled toggle) must stay usable while
|
||||
* the body of the definition is still incomplete — a freshly added step has no
|
||||
* message text yet, so full form validation fails even though `name` is present
|
||||
* and editable. These fields therefore come from the YAML document itself
|
||||
* rather than from {@link yamlToFormState}.
|
||||
*/
|
||||
export type WorkflowDocumentFields = {
|
||||
/** Whether top-level keys can be written back into the document. */
|
||||
editable: boolean;
|
||||
/** The explicit `enabled` value, or `null` when the key is absent. */
|
||||
enabled: boolean | null;
|
||||
/** The top-level `name` scalar, or `null` when absent or not a string. */
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
const EMPTY_DOCUMENT_FIELDS: WorkflowDocumentFields = {
|
||||
editable: true,
|
||||
enabled: null,
|
||||
name: null,
|
||||
};
|
||||
|
||||
const UNEDITABLE_DOCUMENT_FIELDS: WorkflowDocumentFields = {
|
||||
editable: false,
|
||||
enabled: null,
|
||||
name: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the header-level fields of a workflow definition without requiring the
|
||||
* whole definition to be valid.
|
||||
*/
|
||||
export function readWorkflowDocumentFields(
|
||||
yaml: string,
|
||||
): WorkflowDocumentFields {
|
||||
if (!yaml.trim()) return EMPTY_DOCUMENT_FIELDS;
|
||||
|
||||
const document = parseDocument(yaml);
|
||||
if (document.errors.length > 0 || !isMap(document.contents)) {
|
||||
return UNEDITABLE_DOCUMENT_FIELDS;
|
||||
}
|
||||
|
||||
const name = document.get("name");
|
||||
const enabled = document.get("enabled");
|
||||
return {
|
||||
editable: true,
|
||||
enabled: typeof enabled === "boolean" ? enabled : null,
|
||||
name: typeof name === "string" ? name : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the dialog header renders for a definition, in a single parse. */
|
||||
export type WorkflowHeaderState = {
|
||||
/** Whether the name can be edited and the enabled toggle can be written. */
|
||||
canEdit: boolean;
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Derives the header presentation from the working definition, falling back to
|
||||
* the saved workflow only for what the document itself does not supply.
|
||||
*/
|
||||
export function readWorkflowHeaderState(
|
||||
yaml: string,
|
||||
fallback: { enabled: boolean; name: string | undefined },
|
||||
): WorkflowHeaderState {
|
||||
const fields = readWorkflowDocumentFields(yaml);
|
||||
return {
|
||||
canEdit: fields.editable,
|
||||
enabled: fields.editable ? fields.enabled !== false : fallback.enabled,
|
||||
name: fields.name?.trim() || (fallback.name?.trim() ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes `name` into the definition, preserving everything else verbatim. */
|
||||
export function yamlWithWorkflowName(
|
||||
yaml: string,
|
||||
name: string,
|
||||
): string | null {
|
||||
if (!yaml.trim()) {
|
||||
return formStateToYaml({ ...DEFAULT_FORM_STATE, name });
|
||||
}
|
||||
|
||||
const document = parseDocument(yaml);
|
||||
if (document.errors.length > 0 || !isMap(document.contents)) return null;
|
||||
document.set("name", name);
|
||||
return document.toString();
|
||||
}
|
||||
|
||||
/** Writes `enabled` into the definition, preserving everything else verbatim. */
|
||||
export function yamlWithWorkflowEnabled(
|
||||
yaml: string,
|
||||
enabled: boolean,
|
||||
): string | null {
|
||||
if (!yaml.trim()) {
|
||||
return formStateToYaml({ ...DEFAULT_FORM_STATE, enabled });
|
||||
}
|
||||
|
||||
const document = parseDocument(yaml);
|
||||
if (document.errors.length > 0 || !isMap(document.contents)) return null;
|
||||
if (enabled) {
|
||||
document.delete("enabled");
|
||||
} else {
|
||||
document.set("enabled", false);
|
||||
}
|
||||
return document.toString();
|
||||
}
|
||||
50
desktop/src/shared/context/WorkflowEditorOverlayContext.tsx
Normal file
50
desktop/src/shared/context/WorkflowEditorOverlayContext.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { Workflow } from "@/shared/api/types";
|
||||
|
||||
type WorkflowEditorOverlayContextValue = {
|
||||
/** Opens the create editor over the current surface, optionally preselecting
|
||||
* a channel. */
|
||||
openNewWorkflow: ((channelId?: string) => void) | null;
|
||||
/** Opens an existing workflow over the current surface. Pass the workflow
|
||||
* when the caller already has it so the editor skips its loading state. */
|
||||
openWorkflow: ((workflowId: string, workflow?: Workflow) => void) | null;
|
||||
};
|
||||
|
||||
const WorkflowEditorOverlayContext =
|
||||
React.createContext<WorkflowEditorOverlayContextValue>({
|
||||
openNewWorkflow: null,
|
||||
openWorkflow: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Lets any surface open the shared workflow editor as a modal above itself,
|
||||
* instead of navigating to the Workflows route first.
|
||||
*/
|
||||
export function WorkflowEditorOverlayProvider({
|
||||
children,
|
||||
onOpenNewWorkflow,
|
||||
onOpenWorkflow,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onOpenNewWorkflow: (channelId?: string) => void;
|
||||
onOpenWorkflow: (workflowId: string, workflow?: Workflow) => void;
|
||||
}) {
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
openNewWorkflow: onOpenNewWorkflow,
|
||||
openWorkflow: onOpenWorkflow,
|
||||
}),
|
||||
[onOpenNewWorkflow, onOpenWorkflow],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkflowEditorOverlayContext.Provider value={value}>
|
||||
{children}
|
||||
</WorkflowEditorOverlayContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkflowEditorOverlay() {
|
||||
return React.useContext(WorkflowEditorOverlayContext);
|
||||
}
|
||||
39
desktop/src/shared/ui/PortalledScrollArea.tsx
Normal file
39
desktop/src/shared/ui/PortalledScrollArea.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type * as React from "react";
|
||||
|
||||
type PortalledScrollAreaProps = React.ComponentPropsWithoutRef<"div">;
|
||||
|
||||
/**
|
||||
* Keeps a portalled overflow container wheel-scrollable when it is rendered
|
||||
* outside a modal's scroll-lock boundary.
|
||||
*/
|
||||
export function PortalledScrollArea({
|
||||
onWheel,
|
||||
...props
|
||||
}: PortalledScrollAreaProps) {
|
||||
function handleWheel(event: React.WheelEvent<HTMLDivElement>) {
|
||||
onWheel?.(event);
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
const area = event.currentTarget;
|
||||
const maxScrollTop = area.scrollHeight - area.clientHeight;
|
||||
if (maxScrollTop <= 0) return;
|
||||
|
||||
const multiplier =
|
||||
event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
||||
? 16
|
||||
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
|
||||
? area.clientHeight
|
||||
: 1;
|
||||
const nextScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(maxScrollTop, area.scrollTop + event.deltaY * multiplier),
|
||||
);
|
||||
if (nextScrollTop === area.scrollTop) return;
|
||||
|
||||
area.scrollTop = nextScrollTop;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
return <div {...props} onWheel={handleWheel} />;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
type PopoverContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof PopoverPrimitive.Content
|
||||
> & {
|
||||
portalled?: boolean;
|
||||
surface?: "default" | "textured";
|
||||
textureSize?: CardTextureSize;
|
||||
textureTone?: CardTextureTone;
|
||||
@@ -40,6 +41,7 @@ const PopoverContent = React.forwardRef<
|
||||
{
|
||||
className,
|
||||
align = "center",
|
||||
portalled = true,
|
||||
sideOffset,
|
||||
style,
|
||||
surface = "default",
|
||||
@@ -48,8 +50,8 @@ const PopoverContent = React.forwardRef<
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
) => {
|
||||
const content = (
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
@@ -72,8 +74,14 @@ const PopoverContent = React.forwardRef<
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
|
||||
return portalled ? (
|
||||
<PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
},
|
||||
);
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
|
||||
@@ -250,6 +250,10 @@ type E2eConfig = {
|
||||
acpAuthMethodsError?: string;
|
||||
/** When set, workflow updates fail with this message. */
|
||||
workflowUpdateError?: string;
|
||||
/** When set, workflow deletion fails with this message. */
|
||||
workflowDeleteError?: string;
|
||||
/** Reject workflow run creation with this message. */
|
||||
workflowTriggerError?: string;
|
||||
/** When set, the `delete_custom_harness` mock command throws with this message. */
|
||||
deleteCustomHarnessError?: string;
|
||||
connectAcpRuntimeResult?: RawConnectAcpRuntimeResult;
|
||||
@@ -3754,6 +3758,8 @@ function handleUpdateWorkflow(args: {
|
||||
}
|
||||
|
||||
function handleDeleteWorkflow(args: { workflowId: string }) {
|
||||
const configuredError = window.__BUZZ_E2E__?.mock?.workflowDeleteError;
|
||||
if (configuredError) throw new Error(configuredError);
|
||||
const index = mockWorkflows.findIndex((w) => w.id === args.workflowId);
|
||||
if (index === -1) throw new Error(`Workflow ${args.workflowId} not found`);
|
||||
mockWorkflows.splice(index, 1);
|
||||
@@ -3826,6 +3832,8 @@ function buildMockWorkflowRun(workflow: MockWorkflow): RawWorkflowRun {
|
||||
function handleTriggerWorkflow(args: { workflowId: string }) {
|
||||
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
|
||||
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
|
||||
const configuredError = window.__BUZZ_E2E__?.mock?.workflowTriggerError;
|
||||
if (configuredError) throw new Error(configuredError);
|
||||
const run = buildMockWorkflowRun(workflow);
|
||||
mockWorkflowRuns = [run, ...mockWorkflowRuns];
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
KIND_HUDDLE_STARTED,
|
||||
KIND_TYPING_INDICATOR,
|
||||
} from "../../src/shared/constants/kinds";
|
||||
import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features";
|
||||
import {
|
||||
TEST_IDENTITIES,
|
||||
installMockBridge,
|
||||
@@ -2952,6 +2953,140 @@ test("manage channel places canvas between channel info and actions", async ({
|
||||
expect(canvasBox?.y).toBeLessThan(leaveBox?.y);
|
||||
});
|
||||
|
||||
test("channel settings hides workflows and skips its query when the experiment is disabled", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((key) => {
|
||||
const overrides = JSON.parse(
|
||||
window.localStorage.getItem(key) ?? "{}",
|
||||
) as Record<string, boolean>;
|
||||
overrides.workflows = false;
|
||||
window.localStorage.setItem(key, JSON.stringify(overrides));
|
||||
}, FEATURE_OVERRIDES_STORAGE_KEY);
|
||||
|
||||
await page.goto("/");
|
||||
await openChannelManagement(page, "general");
|
||||
|
||||
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
|
||||
await expect(page.getByTestId("channel-workflows-ingress")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
(
|
||||
window as Window & {
|
||||
__BUZZ_E2E_COMMAND_LOG__?: Array<{ command: string }>;
|
||||
}
|
||||
).__BUZZ_E2E_COMMAND_LOG__?.some(
|
||||
({ command }) => command === "get_channel_workflows",
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test("channel settings opens and creates channel workflows over the channel", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await invokeMockCommand(page, "create_workflow", {
|
||||
channelId: GENERAL_CHANNEL_ID,
|
||||
yamlDefinition:
|
||||
"name: Welcome responder\ntrigger:\n on: message_posted\nsteps:\n - id: reply\n run: send_message\n with:\n text: Welcome\n",
|
||||
});
|
||||
await openChannelManagement(page, "general");
|
||||
|
||||
const sheet = page.getByTestId("channel-management-sheet");
|
||||
const canvasBox = await sheet
|
||||
.getByTestId("channel-canvas-ingress")
|
||||
.boundingBox();
|
||||
const workflowsBox = await sheet
|
||||
.getByTestId("channel-workflows-ingress")
|
||||
.boundingBox();
|
||||
expect(canvasBox).not.toBeNull();
|
||||
expect(workflowsBox).not.toBeNull();
|
||||
expect(canvasBox?.y).toBeLessThan(workflowsBox?.y);
|
||||
|
||||
await sheet.getByTestId("channel-workflows-ingress").click();
|
||||
await expect(sheet.getByText("Workflows", { exact: true })).toBeVisible();
|
||||
await expect(sheet.getByTestId("channel-workflows-list")).toContainText(
|
||||
"Welcome responder",
|
||||
);
|
||||
|
||||
// Opening a workflow keeps the settings Workflows view mounted beneath the
|
||||
// shared editor; the channel route stays put behind both layers.
|
||||
const channelUrl = new RegExp(`/channels/${GENERAL_CHANNEL_ID}(?:\\?|$)`);
|
||||
await sheet.getByTestId("channel-workflow-mock-wf-1").click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Edit workflow" }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(channelUrl);
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByText("Workflows", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("message-timeline")).toBeVisible();
|
||||
|
||||
const overlayEditor = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await overlayEditor.getByRole("tab", { name: "YAML" }).click();
|
||||
const overlayYaml = overlayEditor.getByRole("textbox", {
|
||||
name: "Workflow YAML",
|
||||
});
|
||||
await overlayYaml.fill(
|
||||
(await overlayYaml.inputValue()).replace(
|
||||
"Welcome responder",
|
||||
"Unsaved welcome responder",
|
||||
),
|
||||
);
|
||||
await overlayEditor.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Duplicate" }).click();
|
||||
const discardConfirmation = page.getByRole("alertdialog", {
|
||||
name: "Discard changes?",
|
||||
});
|
||||
await expect(discardConfirmation).toBeVisible();
|
||||
await discardConfirmation
|
||||
.getByRole("button", { name: "Keep editing" })
|
||||
.click();
|
||||
await expect(overlayEditor).toBeVisible();
|
||||
await expect(overlayYaml).toContainText("Unsaved welcome responder");
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Duplicate workflow" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await page
|
||||
.getByRole("alertdialog", { name: "Discard changes?" })
|
||||
.getByRole("button", { name: "Discard changes" })
|
||||
.click();
|
||||
await expect(page.getByRole("dialog", { name: "Edit workflow" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page).toHaveURL(channelUrl);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByText("Workflows", { exact: true })).toBeVisible();
|
||||
await expect(sheet.getByTestId("channel-workflows-list")).toContainText(
|
||||
"Welcome responder",
|
||||
);
|
||||
|
||||
await page.getByTestId("channel-workflows-new").click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Create workflow" }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(channelUrl);
|
||||
await expect(
|
||||
page.getByRole("combobox", { exact: true, name: "Channel" }),
|
||||
).toContainText("general");
|
||||
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Create workflow" }),
|
||||
).toHaveCount(0);
|
||||
await expect(page).toHaveURL(channelUrl);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByText("Workflows", { exact: true })).toBeVisible();
|
||||
await expect(sheet.getByTestId("channel-workflows-new")).toBeVisible();
|
||||
});
|
||||
|
||||
async function seedHomeInboxMention(
|
||||
page: import("@playwright/test").Page,
|
||||
itemId: string,
|
||||
|
||||
@@ -24,10 +24,22 @@ async function createWorkflow(
|
||||
name: string,
|
||||
) {
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByLabel("Workflow name").fill(name);
|
||||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||||
|
||||
const channelList = page.getByTestId("channel-combobox-list");
|
||||
await expect(channelList).toBeVisible();
|
||||
await channelList
|
||||
.getByRole("option", { name: "agents", exact: true })
|
||||
.click();
|
||||
|
||||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||||
await dialog.getByRole("textbox", { name: "Workflow name" }).fill(name);
|
||||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
await dialog.getByLabel("Message text").fill("Workflow notification");
|
||||
await dialog.getByRole("button", { name: "Create" }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
}
|
||||
@@ -124,8 +136,15 @@ test("direct workflow detail links close back to workflows", async ({
|
||||
|
||||
await page.goto(`/#/workflows/${workflowId}`);
|
||||
|
||||
await expect(page.getByTestId("workflow-detail-panel")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Close detail panel" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await expect(dialog.getByText(workflowName, { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Trigger: Message Posted" }),
|
||||
).toBeVisible();
|
||||
await expect(dialog.getByRole("button", { name: "Run history" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).click();
|
||||
|
||||
await expect(page).toHaveURL(/#\/workflows$/);
|
||||
await expect(page.getByTestId("workflows-view")).toBeVisible();
|
||||
|
||||
242
desktop/tests/e2e/workflow-local-controls.spec.ts
Normal file
242
desktop/tests/e2e/workflow-local-controls.spec.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
|
||||
async function openCreateWorkflow(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
) {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-workflows-view").click();
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
const channelList = page.getByTestId("channel-combobox-list");
|
||||
if (!(await channelList.isVisible())) {
|
||||
await dialog.getByRole("combobox", { name: "Channel" }).click();
|
||||
}
|
||||
await channelList
|
||||
.getByRole("option", { name: "agents", exact: true })
|
||||
.click();
|
||||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||||
await dialog.getByRole("textbox", { name: "Workflow name" }).fill(name);
|
||||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function selectTrigger(
|
||||
page: import("@playwright/test").Page,
|
||||
dialog: import("@playwright/test").Locator,
|
||||
trigger: string,
|
||||
) {
|
||||
await dialog.getByRole("button", { name: "Trigger event" }).click();
|
||||
await page.getByRole("menuitem", { name: trigger, exact: true }).click();
|
||||
}
|
||||
|
||||
async function openTriggerInspector(
|
||||
dialog: import("@playwright/test").Locator,
|
||||
trigger: string,
|
||||
) {
|
||||
const menu = dialog.getByRole("button", { name: "Trigger event" });
|
||||
if (!(await menu.isVisible())) {
|
||||
await dialog.getByRole("button", { name: `Trigger: ${trigger}` }).click();
|
||||
}
|
||||
await expect(menu).toBeVisible();
|
||||
}
|
||||
|
||||
async function addMessageStep(
|
||||
page: import("@playwright/test").Page,
|
||||
dialog: import("@playwright/test").Locator,
|
||||
) {
|
||||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
await dialog.getByLabel("Message text").fill("Workflow notification");
|
||||
}
|
||||
|
||||
async function reopenWorkflow(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
) {
|
||||
const card = page
|
||||
.locator('[data-testid^="workflow-card-"]')
|
||||
.filter({ hasText: name });
|
||||
await card.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
return page.getByRole("dialog", { name: "Edit workflow" });
|
||||
}
|
||||
|
||||
test("round-trips schedule presets and saves a custom UTC cron", async ({
|
||||
page,
|
||||
}) => {
|
||||
const name = `schedule_controls_${Date.now()}`;
|
||||
const dialog = await openCreateWorkflow(page, name);
|
||||
await selectTrigger(page, dialog, "Schedule");
|
||||
|
||||
await expect(dialog.getByRole("radio", { name: "Daily" })).toBeChecked();
|
||||
await expect(dialog.getByLabel("Run time (UTC)")).toHaveValue("09:00");
|
||||
|
||||
await dialog.getByText("Every 15 minutes", { exact: true }).click();
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
||||
let definition = parseYaml(await yamlEditor.inputValue());
|
||||
expect(definition.trigger).toEqual({ on: "schedule", interval: "15m" });
|
||||
|
||||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||||
await openTriggerInspector(dialog, "Schedule");
|
||||
await expect(
|
||||
dialog.getByRole("radio", { name: "Every 15 minutes" }),
|
||||
).toBeChecked();
|
||||
|
||||
await dialog.getByText("Monthly", { exact: true }).click();
|
||||
await dialog.getByLabel("Day of month").selectOption("31");
|
||||
await expect(
|
||||
dialog.getByText("This schedule won’t run in some months."),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByText("Custom cron", { exact: true }).click();
|
||||
await dialog.getByRole("textbox", { name: "Minute", exact: true }).fill("5");
|
||||
await dialog.getByRole("textbox", { name: "Hour", exact: true }).fill("*/2");
|
||||
await dialog.getByRole("textbox", { name: "Day", exact: true }).fill("*");
|
||||
await dialog.getByRole("textbox", { name: "Month", exact: true }).fill("*");
|
||||
await dialog
|
||||
.getByRole("textbox", { name: "Weekday", exact: true })
|
||||
.fill("2-4");
|
||||
await expect(dialog.getByText(/UTC · Paste all 5 fields/)).toBeVisible();
|
||||
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
definition = parseYaml(await yamlEditor.inputValue());
|
||||
expect(definition.trigger).toEqual({
|
||||
on: "schedule",
|
||||
cron: "5 */2 * * 2-4",
|
||||
});
|
||||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||||
await openTriggerInspector(dialog, "Schedule");
|
||||
await expect(
|
||||
dialog.getByRole("radio", { name: "Custom cron" }),
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
dialog.getByRole("textbox", { name: "Weekday", exact: true }),
|
||||
).toHaveValue("2-4");
|
||||
|
||||
await addMessageStep(page, dialog);
|
||||
await dialog.getByRole("button", { name: "Create" }).click();
|
||||
const reopened = await reopenWorkflow(page, name);
|
||||
await openTriggerInspector(reopened, "Schedule");
|
||||
await expect(
|
||||
reopened.getByRole("radio", { name: "Custom cron" }),
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
reopened.getByRole("textbox", { name: "Minute", exact: true }),
|
||||
).toHaveValue("5");
|
||||
await expect(
|
||||
reopened.getByRole("textbox", { name: "Weekday", exact: true }),
|
||||
).toHaveValue("2-4");
|
||||
});
|
||||
|
||||
test("round-trips and reopens structured message-text conditions", async ({
|
||||
page,
|
||||
}) => {
|
||||
const name = `message_condition_${Date.now()}`;
|
||||
const text = 'deploy "buzz"\\path';
|
||||
const expression = 'str_ends_with(trigger_text, "deploy \\"buzz\\"\\\\path")';
|
||||
const dialog = await openCreateWorkflow(page, name);
|
||||
|
||||
await dialog.getByText("ends with", { exact: true }).click();
|
||||
await dialog.getByLabel("Text to match").fill(text);
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
||||
const definition = parseYaml(await yamlEditor.inputValue());
|
||||
expect(definition.trigger.filter).toBe(expression);
|
||||
|
||||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||||
await openTriggerInspector(dialog, "Message Posted");
|
||||
await expect(dialog.getByText("ends with", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByLabel("Text to match")).toHaveValue(text);
|
||||
await dialog.getByRole("tab", { name: "Advanced" }).click();
|
||||
await expect(dialog.getByLabel("Advanced expression")).toHaveValue(
|
||||
expression,
|
||||
);
|
||||
await dialog.getByRole("tab", { name: "Basic" }).click();
|
||||
|
||||
await addMessageStep(page, dialog);
|
||||
await dialog.getByRole("button", { name: "Create" }).click();
|
||||
const reopened = await reopenWorkflow(page, name);
|
||||
await openTriggerInspector(reopened, "Message Posted");
|
||||
await expect(reopened.getByText("ends with", { exact: true })).toBeVisible();
|
||||
await expect(reopened.getByLabel("Text to match")).toHaveValue(text);
|
||||
});
|
||||
|
||||
test("hides and clears message-text step conditions for schedule triggers", async ({
|
||||
page,
|
||||
}) => {
|
||||
const dialog = await openCreateWorkflow(
|
||||
page,
|
||||
`schedule_step_condition_${Date.now()}`,
|
||||
);
|
||||
await addMessageStep(page, dialog);
|
||||
await dialog.getByRole("button", { name: "Run controls" }).click();
|
||||
await dialog.getByLabel("Text to match").fill("deploy");
|
||||
|
||||
await dialog.getByRole("button", { name: "Trigger: Message Posted" }).click();
|
||||
await selectTrigger(page, dialog, "Schedule");
|
||||
await dialog.getByRole("button", { name: "Step 1: Send Message" }).click();
|
||||
await dialog.getByRole("button", { name: "Run controls" }).click();
|
||||
|
||||
await expect(dialog.getByLabel("Text to match")).toHaveCount(0);
|
||||
await expect(
|
||||
dialog.getByRole("textbox", { name: "Timeout", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
const definition = parseYaml(
|
||||
await dialog.getByRole("textbox", { name: "Workflow YAML" }).inputValue(),
|
||||
);
|
||||
expect(definition.steps[0].if).toBeUndefined();
|
||||
});
|
||||
|
||||
test("keeps advanced and malformed definitions lossless", async ({ page }) => {
|
||||
const advanced =
|
||||
'str_contains(trigger_text, "deploy") && trigger_author == "abc"';
|
||||
const dialog = await openCreateWorkflow(page, `lossless_${Date.now()}`);
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
||||
const initialYaml = await yamlEditor.inputValue();
|
||||
await yamlEditor.fill(
|
||||
initialYaml.replace(
|
||||
"trigger:\n on: message_posted",
|
||||
`trigger:\n on: message_posted\n filter: '${advanced}'`,
|
||||
),
|
||||
);
|
||||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||||
await openTriggerInspector(dialog, "Message Posted");
|
||||
await expect(dialog.getByRole("tab", { name: "Advanced" })).toHaveAttribute(
|
||||
"data-state",
|
||||
"active",
|
||||
);
|
||||
await expect(dialog.getByLabel("Advanced expression")).toHaveValue(advanced);
|
||||
await dialog.getByRole("tab", { name: "Basic" }).click();
|
||||
await expect(
|
||||
dialog.getByText(/advanced expression is active/i),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByRole("switch", { name: "Enable workflow" }).click();
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
expect(parseYaml(await yamlEditor.inputValue()).trigger.filter).toBe(
|
||||
advanced,
|
||||
);
|
||||
|
||||
const malformedYaml = (await yamlEditor.inputValue()).replace(
|
||||
/trigger:\n {2}on: message_posted\n {2}filter:.*\n/,
|
||||
'trigger:\n on: schedule\n cron: "0 9 * * *"\n interval: 1h\n',
|
||||
);
|
||||
await yamlEditor.fill(malformedYaml);
|
||||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||||
await expect(
|
||||
dialog.getByText(/cannot specify both cron and interval/i),
|
||||
).toBeVisible();
|
||||
await expect(yamlEditor).toHaveValue(malformedYaml);
|
||||
});
|
||||
205
desktop/tests/e2e/workflow-reaction-picker.spec.ts
Normal file
205
desktop/tests/e2e/workflow-reaction-picker.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
// The Reaction Added trigger stores the reaction event's content verbatim — a
|
||||
// native glyph or a custom `:shortcode:`. These cover the picker interaction and
|
||||
// that the chosen value survives into canonical YAML and back out of a reload,
|
||||
// which a free-text field silently got wrong (a typed `thumbsup` never matches).
|
||||
|
||||
const EMOJI = "🚀";
|
||||
const EMOJI_SHORTCODE = ":rocket:";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
|
||||
async function openReactionTrigger(page: import("@playwright/test").Page) {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-workflows-view").click();
|
||||
await expect(page.getByTestId("workflows-view")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const channelList = page.getByTestId("channel-combobox-list");
|
||||
if (!(await channelList.isVisible())) {
|
||||
await dialog.getByRole("combobox", { name: "Channel" }).click();
|
||||
}
|
||||
await expect(channelList).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await channelList
|
||||
.getByRole("option", { name: "agents", exact: true })
|
||||
.click();
|
||||
|
||||
await dialog.getByRole("button", { name: "Trigger: Message Posted" }).click();
|
||||
await dialog.getByRole("button", { name: "Trigger event" }).click();
|
||||
await page.getByRole("menuitem", { name: "Reaction Added" }).click();
|
||||
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function pickEmoji(
|
||||
page: import("@playwright/test").Page,
|
||||
dialog: import("@playwright/test").Locator,
|
||||
query: string,
|
||||
glyph: string,
|
||||
) {
|
||||
await dialog.getByRole("button", { name: "Choose trigger emoji" }).click();
|
||||
const picker = page.locator("em-emoji-picker");
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.locator("input[type='search']").fill(query);
|
||||
await picker.locator(`button[aria-label='${glyph}']`).first().click();
|
||||
}
|
||||
|
||||
test("reaction trigger uses the emoji picker instead of a free-text field", async ({
|
||||
page,
|
||||
}) => {
|
||||
const dialog = await openReactionTrigger(page);
|
||||
|
||||
// The old plain text input is gone; the picker trigger stands in its place.
|
||||
const pickerButton = dialog.getByRole("button", {
|
||||
name: "Choose trigger emoji",
|
||||
});
|
||||
await expect(pickerButton).toBeVisible();
|
||||
await expect(pickerButton).toHaveText(/Choose a reaction/);
|
||||
await expect(
|
||||
dialog.getByRole("textbox", { name: "Emoji filter (optional)" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Nothing to clear while the optional filter is empty.
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Clear trigger emoji" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await pickEmoji(page, dialog, "rocket", EMOJI);
|
||||
|
||||
// Selecting closes the popover and shows the chosen reaction by name.
|
||||
await expect(page.locator("em-emoji-picker")).toHaveCount(0);
|
||||
await expect(pickerButton).toContainText(EMOJI_SHORTCODE);
|
||||
await expect(pickerButton).not.toContainText("Choose a reaction");
|
||||
});
|
||||
|
||||
test("picked reaction persists into canonical YAML and clears back out", async ({
|
||||
page,
|
||||
}) => {
|
||||
const dialog = await openReactionTrigger(page);
|
||||
await pickEmoji(page, dialog, "rocket", EMOJI);
|
||||
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
||||
const yaml = await yamlEditor.inputValue();
|
||||
expect(yaml).toContain("on: reaction_added");
|
||||
// Stored verbatim as the reaction content the executor compares against.
|
||||
expect(yaml).toContain(`emoji: ${EMOJI}`);
|
||||
|
||||
// Clearing the optional filter removes the key entirely rather than
|
||||
// writing an empty string, so the trigger matches any reaction again.
|
||||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||||
// Switching editor modes drops the inspector selection, so re-open the
|
||||
// trigger node before reaching for its fields.
|
||||
await dialog.getByRole("button", { name: "Trigger: Reaction Added" }).click();
|
||||
await dialog.getByRole("button", { name: "Clear trigger emoji" }).click();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Choose trigger emoji" }),
|
||||
).toHaveText(/Choose a reaction/);
|
||||
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
const clearedYaml = await yamlEditor.inputValue();
|
||||
expect(clearedYaml).toContain("on: reaction_added");
|
||||
expect(clearedYaml).not.toContain("emoji:");
|
||||
});
|
||||
|
||||
test("legacy free-text emoji values survive a trip through the picker", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The field used to be free text, so existing definitions can hold a bare
|
||||
// shortcode. Swapping in a picker must not silently drop a value it cannot
|
||||
// render as a glyph — the user has to be able to see and re-pick it.
|
||||
const dialog = await openReactionTrigger(page);
|
||||
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
||||
const seeded = (await yamlEditor.inputValue()).replace(
|
||||
"trigger:\n on: reaction_added",
|
||||
"trigger:\n on: reaction_added\n emoji: thumbsup",
|
||||
);
|
||||
await yamlEditor.fill(seeded);
|
||||
|
||||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||||
await dialog.getByRole("button", { name: "Trigger: Reaction Added" }).click();
|
||||
const pickerButton = dialog.getByRole("button", {
|
||||
name: "Choose trigger emoji",
|
||||
});
|
||||
await expect(pickerButton).toContainText("thumbsup");
|
||||
await expect(pickerButton).not.toContainText("Choose a reaction");
|
||||
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
expect(await yamlEditor.inputValue()).toContain("emoji: thumbsup");
|
||||
});
|
||||
|
||||
test("saved reaction filter round-trips back into the picker", async ({
|
||||
page,
|
||||
}) => {
|
||||
const name = `reaction_picker_${Date.now()}`;
|
||||
const dialog = await openReactionTrigger(page);
|
||||
|
||||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||||
await dialog.getByRole("textbox", { name: "Workflow name" }).fill(name);
|
||||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||||
|
||||
await pickEmoji(page, dialog, "rocket", EMOJI);
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
await dialog.getByLabel("Message text").fill("Workflow notification");
|
||||
await dialog.getByRole("button", { name: "Create" }).click();
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
|
||||
const editDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await expect(editDialog).toBeVisible();
|
||||
await editDialog
|
||||
.getByRole("button", { name: "Trigger: Reaction Added" })
|
||||
.click();
|
||||
|
||||
// Reopening rehydrates the stored glyph into the picker, not an empty field.
|
||||
await expect(
|
||||
editDialog.getByRole("button", { name: "Choose trigger emoji" }),
|
||||
).toContainText(EMOJI_SHORTCODE);
|
||||
});
|
||||
|
||||
test("emoji picker opens within the viewport at narrow width", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Reach the trigger inspector at desktop width — below 58rem the inspector
|
||||
// becomes an absolute overlay covering the canvas nodes — then narrow the
|
||||
// window with the inspector already open and drive the picker there.
|
||||
const dialog = await openReactionTrigger(page);
|
||||
await page.setViewportSize({ width: 800, height: 720 });
|
||||
await waitForAnimations(page);
|
||||
|
||||
await dialog.getByRole("button", { name: "Choose trigger emoji" }).click();
|
||||
const picker = page.locator("em-emoji-picker");
|
||||
await expect(picker).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
|
||||
// Radix collision handling must keep the popover on-screen, otherwise the
|
||||
// picker is unreachable at this width.
|
||||
const box = await picker.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (box) {
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(800);
|
||||
}
|
||||
|
||||
await picker.locator("input[type='search']").fill("rocket");
|
||||
await picker.locator(`button[aria-label='${EMOJI}']`).first().click();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Choose trigger emoji" }),
|
||||
).toContainText(EMOJI_SHORTCODE);
|
||||
});
|
||||
275
desktop/tests/e2e/workflow-title-stability.spec.ts
Normal file
275
desktop/tests/e2e/workflow-title-stability.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
|
||||
const GENERATED_NAME = /^[a-z]+-[a-z]+-[a-z]+$/;
|
||||
|
||||
async function navigateToWorkflows(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-workflows-view").click();
|
||||
await expect(page.getByTestId("workflows-view")).toBeVisible();
|
||||
}
|
||||
|
||||
function readWorkflowName(dialog: Locator) {
|
||||
return dialog
|
||||
.getByRole("button", { name: "Edit workflow name" })
|
||||
.evaluate((button) =>
|
||||
(button.previousElementSibling?.textContent ?? "").trim(),
|
||||
);
|
||||
}
|
||||
|
||||
async function selectWorkflowChannel(page: Page, dialog: Locator) {
|
||||
const channelList = page.getByTestId("channel-combobox-list");
|
||||
if (!(await channelList.isVisible())) {
|
||||
await dialog.getByRole("combobox", { name: "Channel" }).click();
|
||||
}
|
||||
await expect(channelList).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await channelList
|
||||
.getByRole("option", { name: "agents", exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
async function openTriggerPane(dialog: Locator) {
|
||||
await dialog.getByRole("button", { name: /^Trigger: / }).click();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Trigger event" }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function openStepPane(dialog: Locator, index: number) {
|
||||
await dialog
|
||||
.getByRole("button", { name: new RegExp(`^Step ${index}: `) })
|
||||
.click();
|
||||
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
|
||||
`Step ${index}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a step right after the trigger. The insertion control only becomes
|
||||
* interactive on hover once the trigger is no longer the terminal node.
|
||||
*/
|
||||
async function addStepAfterTrigger(page: Page, dialog: Locator) {
|
||||
const ingress = dialog.getByTestId("workflow-node-ingress").first();
|
||||
await ingress.hover();
|
||||
await ingress.getByRole("button", { name: "Add step", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
}
|
||||
|
||||
async function createNamedWorkflow(page: Page, name: string) {
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||||
await dialog.getByRole("textbox", { name: "Workflow name" }).fill(name);
|
||||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||||
|
||||
await dialog.getByRole("button", { name: "Add first step" }).click();
|
||||
await dialog.getByLabel("Message text").fill("Workflow notification");
|
||||
await dialog.getByRole("button", { name: "Create workflow" }).click();
|
||||
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(name);
|
||||
}
|
||||
|
||||
function workflowCard(page: Page, name: string) {
|
||||
return page
|
||||
.locator('[data-testid^="workflow-card-"]')
|
||||
.filter({ hasText: name })
|
||||
.first();
|
||||
}
|
||||
|
||||
test("keeps the generated name while moving between trigger and step panes", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await expect.poll(() => readWorkflowName(dialog)).toMatch(GENERATED_NAME);
|
||||
const generatedName = await readWorkflowName(dialog);
|
||||
|
||||
// Adding the first step lands on the step pane while the step is still
|
||||
// incomplete — the header must keep showing the workflow name.
|
||||
await dialog.getByRole("button", { name: "Add first step" }).click();
|
||||
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
|
||||
"Step 1",
|
||||
);
|
||||
expect(await readWorkflowName(dialog)).toBe(generatedName);
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Edit workflow name" }),
|
||||
).toBeEnabled();
|
||||
|
||||
await openTriggerPane(dialog);
|
||||
expect(await readWorkflowName(dialog)).toBe(generatedName);
|
||||
|
||||
await openStepPane(dialog, 1);
|
||||
expect(await readWorkflowName(dialog)).toBe(generatedName);
|
||||
|
||||
// A second, still-empty step must not clear the name either.
|
||||
await addStepAfterTrigger(page, dialog);
|
||||
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
|
||||
"Step 1",
|
||||
);
|
||||
expect(await readWorkflowName(dialog)).toBe(generatedName);
|
||||
|
||||
await openStepPane(dialog, 2);
|
||||
expect(await readWorkflowName(dialog)).toBe(generatedName);
|
||||
});
|
||||
|
||||
test("renames stay visible across pane navigation before the step is filled in", async ({
|
||||
page,
|
||||
}) => {
|
||||
const name = `renamed_workflow_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await dialog.getByRole("button", { name: "Add first step" }).click();
|
||||
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
|
||||
"Step 1",
|
||||
);
|
||||
|
||||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||||
await dialog.getByRole("textbox", { name: "Workflow name" }).fill(name);
|
||||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||||
expect(await readWorkflowName(dialog)).toBe(name);
|
||||
|
||||
await openTriggerPane(dialog);
|
||||
expect(await readWorkflowName(dialog)).toBe(name);
|
||||
|
||||
await openStepPane(dialog, 1);
|
||||
expect(await readWorkflowName(dialog)).toBe(name);
|
||||
|
||||
// Completing the step re-serializes the definition from the builder's form
|
||||
// state, which must have picked the rename up rather than writing back the
|
||||
// generated name it was holding when the rename happened.
|
||||
await dialog.getByLabel("Message text").fill("Workflow notification");
|
||||
expect(await readWorkflowName(dialog)).toBe(name);
|
||||
|
||||
await dialog.getByRole("button", { name: "Create workflow" }).click();
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(name);
|
||||
});
|
||||
|
||||
test("keeps a header disable across a later form edit", async ({ page }) => {
|
||||
const name = `disabled_workflow_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createNamedWorkflow(page, name);
|
||||
|
||||
await workflowCard(page, name)
|
||||
.getByRole("button", { name: "Workflow actions" })
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const enableItem = page.getByRole("menuitemcheckbox", { name: "Enable" });
|
||||
// The open menu aria-hides the dialog behind it, so it has to be dismissed
|
||||
// before the dialog can be addressed again.
|
||||
const closeActionsMenu = async () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(enableItem).not.toBeVisible();
|
||||
};
|
||||
|
||||
// Add an empty step so the definition stops validating, then disable from the
|
||||
// header. Completing the step re-serializes the definition from the builder's
|
||||
// form state, which must carry the disable rather than the enabled state it
|
||||
// held before the header wrote it.
|
||||
await addStepAfterTrigger(page, dialog);
|
||||
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
|
||||
"Step 1",
|
||||
);
|
||||
|
||||
await dialog.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await expect(enableItem).toHaveAttribute("aria-checked", "true");
|
||||
await enableItem.click();
|
||||
await expect(enableItem).toHaveAttribute("aria-checked", "false");
|
||||
await closeActionsMenu();
|
||||
|
||||
await dialog.getByLabel("Message text").fill("Updated notification");
|
||||
|
||||
await dialog.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await expect(enableItem).toHaveAttribute("aria-checked", "false");
|
||||
await closeActionsMenu();
|
||||
|
||||
await dialog.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect(workflowCard(page, name)).toContainText("disabled");
|
||||
});
|
||||
|
||||
test("keeps the saved name while editing an existing workflow", async ({
|
||||
page,
|
||||
}) => {
|
||||
const name = `edit_title_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createNamedWorkflow(page, name);
|
||||
|
||||
await workflowCard(page, name)
|
||||
.getByRole("button", { name: "Workflow actions" })
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(await readWorkflowName(dialog)).toBe(name);
|
||||
|
||||
await openTriggerPane(dialog);
|
||||
expect(await readWorkflowName(dialog)).toBe(name);
|
||||
|
||||
await openStepPane(dialog, 1);
|
||||
expect(await readWorkflowName(dialog)).toBe(name);
|
||||
|
||||
await addStepAfterTrigger(page, dialog);
|
||||
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
|
||||
"Step 1",
|
||||
);
|
||||
expect(await readWorkflowName(dialog)).toBe(name);
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Edit workflow name" }),
|
||||
).toBeEnabled();
|
||||
|
||||
await openStepPane(dialog, 2);
|
||||
expect(await readWorkflowName(dialog)).toBe(name);
|
||||
});
|
||||
|
||||
test("keeps the copy name while duplicating an existing workflow", async ({
|
||||
page,
|
||||
}) => {
|
||||
const name = `dup_title_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createNamedWorkflow(page, name);
|
||||
|
||||
await workflowCard(page, name)
|
||||
.getByRole("button", { name: "Workflow actions" })
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Duplicate" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Duplicate workflow" });
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(await readWorkflowName(dialog)).toBe(`${name} (copy)`);
|
||||
|
||||
await openStepPane(dialog, 1);
|
||||
expect(await readWorkflowName(dialog)).toBe(`${name} (copy)`);
|
||||
|
||||
await addStepAfterTrigger(page, dialog);
|
||||
await expect(dialog.getByTestId("workflow-node-inspector")).toContainText(
|
||||
"Step 1",
|
||||
);
|
||||
expect(await readWorkflowName(dialog)).toBe(`${name} (copy)`);
|
||||
|
||||
await openTriggerPane(dialog);
|
||||
expect(await readWorkflowName(dialog)).toBe(`${name} (copy)`);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
@@ -13,6 +14,36 @@ async function navigateToWorkflows(page: import("@playwright/test").Page) {
|
||||
await expect(page.getByTestId("workflows-view")).toBeVisible();
|
||||
}
|
||||
|
||||
async function selectWorkflowChannel(
|
||||
page: import("@playwright/test").Page,
|
||||
dialog: import("@playwright/test").Locator,
|
||||
) {
|
||||
const channelList = page.getByTestId("channel-combobox-list");
|
||||
if (!(await channelList.isVisible())) {
|
||||
await dialog.getByRole("combobox", { name: "Channel" }).click();
|
||||
}
|
||||
await expect(channelList).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await channelList
|
||||
.getByRole("option", { name: "agents", exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
async function editWorkflowName(
|
||||
dialog: import("@playwright/test").Locator,
|
||||
name: string,
|
||||
) {
|
||||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||||
const input = dialog.getByRole("textbox", { name: "Workflow name" });
|
||||
await input.fill(name);
|
||||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||||
await expect(input).not.toBeVisible();
|
||||
await expect(dialog).toContainText(name);
|
||||
await expect(
|
||||
dialog.page().getByTestId("channel-combobox-list"),
|
||||
).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function createWorkflow(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
@@ -20,38 +51,67 @@ async function createWorkflow(
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
trigger?: string;
|
||||
stepCondition?: string;
|
||||
stepConditionText?: string;
|
||||
stepName?: string;
|
||||
stepTimeoutSecs?: string;
|
||||
},
|
||||
) {
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.getByLabel("Workflow name").fill(name);
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await editWorkflowName(dialog, name);
|
||||
if (options?.description) {
|
||||
await dialog.getByLabel("Description (optional)").fill(options.description);
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
||||
const yaml = await yamlEditor.inputValue();
|
||||
await yamlEditor.fill(
|
||||
yaml.replace(
|
||||
`name: ${name}`,
|
||||
`name: ${name}\ndescription: ${JSON.stringify(options.description)}`,
|
||||
),
|
||||
);
|
||||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||||
}
|
||||
if (options?.enabled === false) {
|
||||
await dialog.getByLabel("Workflow is enabled").click();
|
||||
await dialog.getByRole("switch", { name: "Enable" }).click();
|
||||
}
|
||||
if (options?.trigger) {
|
||||
await dialog.getByLabel("Trigger").selectOption(options.trigger);
|
||||
await dialog
|
||||
.getByRole("button", { name: "Trigger: Message Posted" })
|
||||
.click();
|
||||
await dialog.getByRole("button", { name: "Trigger event" }).click();
|
||||
await page
|
||||
.getByRole("menuitem", {
|
||||
name:
|
||||
options.trigger === "diff_posted"
|
||||
? "Diff Posted"
|
||||
: options.trigger === "reaction_added"
|
||||
? "Reaction Added"
|
||||
: options.trigger === "webhook"
|
||||
? "Webhook"
|
||||
: "Message Posted",
|
||||
})
|
||||
.click();
|
||||
}
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
await dialog.getByLabel("Message text").fill("Workflow notification");
|
||||
if (options?.stepName) {
|
||||
await dialog.getByLabel("Step name (optional)").fill(options.stepName);
|
||||
await dialog.getByRole("button", { name: "Step details" }).click();
|
||||
await dialog.getByLabel("Name (optional)").fill(options.stepName);
|
||||
}
|
||||
if (options?.stepCondition) {
|
||||
await dialog
|
||||
.getByLabel("Run condition (optional)")
|
||||
.fill(options.stepCondition);
|
||||
if (options?.stepConditionText || options?.stepTimeoutSecs) {
|
||||
await dialog.getByRole("button", { name: "Run controls" }).click();
|
||||
}
|
||||
if (options?.stepConditionText) {
|
||||
await dialog.getByLabel("Text to match").fill(options.stepConditionText);
|
||||
}
|
||||
if (options?.stepTimeoutSecs) {
|
||||
await dialog
|
||||
.getByLabel("Timeout seconds (optional)")
|
||||
.getByRole("textbox", { name: "Timeout", exact: true })
|
||||
.fill(options.stepTimeoutSecs);
|
||||
}
|
||||
|
||||
@@ -71,6 +131,59 @@ test("navigates to workflows view and shows the empty create tile", async ({
|
||||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("creation reveals the trigger pane only after a one-shot channel pick", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
const channelList = page.getByTestId("channel-combobox-list");
|
||||
await expect(channelList).toBeVisible();
|
||||
await expect(dialog.getByTestId("workflow-node-inspector")).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(channelList).not.toBeVisible();
|
||||
await expect
|
||||
.poll(() => dialog.getByText("Untitled workflow", { exact: true }).count())
|
||||
.toBe(0);
|
||||
await expect(channelList).not.toBeVisible();
|
||||
|
||||
await dialog.getByRole("combobox", { name: "Channel" }).click();
|
||||
await channelList
|
||||
.getByRole("option", { name: "agents", exact: true })
|
||||
.click();
|
||||
await expect(dialog.getByTestId("workflow-node-inspector")).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Trigger event" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Escape closes the selected inspector before the workflow modal", async ({
|
||||
page,
|
||||
}) => {
|
||||
for (const width of [760, 1280]) {
|
||||
await page.setViewportSize({ width, height: 820 });
|
||||
await page.goto(
|
||||
"/#/workflows?view=create&channel=94a444a4-c0a3-5966-ab05-530c6ddc2301&pane=trigger",
|
||||
);
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(inspector).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(inspector).not.toBeVisible();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page).toHaveURL(/view=create/);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page).toHaveURL(/#\/workflows$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("creates a workflow via the form builder", async ({ page }) => {
|
||||
const workflowName = `test_workflow_${Date.now()}`;
|
||||
|
||||
@@ -81,21 +194,64 @@ test("creates a workflow via the form builder", async ({ page }) => {
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
|
||||
});
|
||||
|
||||
test("accepts the generated name through the first form mutation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
const generatedName = (
|
||||
await dialog
|
||||
.getByRole("button", { name: "Edit workflow name" })
|
||||
.evaluate((button) => button.previousElementSibling?.textContent ?? "")
|
||||
).trim();
|
||||
expect(generatedName).toMatch(/^[a-z]+-[a-z]+-[a-z]+$/);
|
||||
|
||||
await dialog.getByRole("button", { name: "Add first step" }).click();
|
||||
await dialog.getByRole("button", { name: "Create workflow" }).click();
|
||||
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(generatedName);
|
||||
});
|
||||
|
||||
test("disables autocapitalization in the workflow form", async ({ page }) => {
|
||||
await navigateToWorkflows(page);
|
||||
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
|
||||
await expect(dialog.getByLabel("Workflow name")).toHaveAttribute(
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||||
await expect(
|
||||
dialog.getByRole("textbox", { name: "Workflow name" }),
|
||||
).toHaveAttribute("autocapitalize", "off");
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
await dialog.getByRole("button", { name: "Step details" }).click();
|
||||
await expect(dialog.getByLabel("Name (optional)")).toHaveAttribute(
|
||||
"autocapitalize",
|
||||
"off",
|
||||
);
|
||||
});
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||||
await expect(dialog.getByLabel("Step name (optional)")).toHaveAttribute(
|
||||
"autocapitalize",
|
||||
"off",
|
||||
test("shows executable guidance for diff trigger conditions", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await dialog.getByRole("button", { name: "Trigger event" }).click();
|
||||
await page
|
||||
.getByRole("menuitem", { name: "Diff Posted", exact: true })
|
||||
.click();
|
||||
|
||||
await expect(dialog.getByLabel("Condition (optional)")).toHaveAttribute(
|
||||
"placeholder",
|
||||
'e.g. str_contains(trigger_text, "deploy")',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -110,7 +266,7 @@ test("captures workflow library across responsive viewports", async ({
|
||||
});
|
||||
await createWorkflow(page, "Post the daily standup reminder to the team", {
|
||||
description: "Keeps the team aligned every morning",
|
||||
trigger: "schedule",
|
||||
trigger: "message_posted",
|
||||
});
|
||||
await createWorkflow(
|
||||
page,
|
||||
@@ -152,8 +308,8 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => {
|
||||
enabled: false,
|
||||
trigger: "diff_posted",
|
||||
stepName: "Notify reviewers",
|
||||
stepCondition: 'str_contains(trigger_text, "src/")',
|
||||
stepTimeoutSecs: "45",
|
||||
stepConditionText: "src/",
|
||||
stepTimeoutSecs: "45s",
|
||||
});
|
||||
|
||||
const card = page
|
||||
@@ -182,11 +338,6 @@ test("enables and disables a workflow from its card menu", async ({ page }) => {
|
||||
|
||||
const enableItem = page.getByRole("menuitemcheckbox", { name: "Enable" });
|
||||
|
||||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||||
const detailPanel = page.getByTestId("workflow-detail-panel");
|
||||
await expect(detailPanel).toBeVisible();
|
||||
await expect(detailPanel.getByText("active", { exact: true })).toBeVisible();
|
||||
|
||||
await workflowActions().click();
|
||||
await expect(enableItem).toHaveAttribute("aria-checked", "true");
|
||||
await expect(enableItem.locator("button")).toHaveCount(0);
|
||||
@@ -197,15 +348,11 @@ test("enables and disables a workflow from its card menu", async ({ page }) => {
|
||||
await expect(
|
||||
workflowCard().getByText("disabled", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
detailPanel.getByText("disabled", { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await enableItem.click();
|
||||
await expect(
|
||||
workflowCard().getByText("active", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(detailPanel.getByText("active", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("rejects a stale card toggle without overwriting a newer edit", async ({
|
||||
@@ -309,12 +456,22 @@ test("shows the webhook secret dialog after saving a webhook workflow", async ({
|
||||
trigger: "webhook",
|
||||
});
|
||||
|
||||
await expect(page.getByText("Webhook Ready")).toBeVisible();
|
||||
await expect(page.getByText("Webhook ready")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy URL" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy Secret" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await expect(page.getByText("Webhook Ready")).not.toBeVisible();
|
||||
await expect(page.getByText(/•{24}/)).toBeVisible();
|
||||
await page.goBack();
|
||||
const confirmation = page.getByRole("alertdialog", {
|
||||
name: "Continue without this secret?",
|
||||
});
|
||||
await expect(confirmation).toBeVisible();
|
||||
await confirmation.getByRole("button", { name: "Go back" }).click();
|
||||
await expect(page.getByText("Webhook ready")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
await confirmation.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(page.getByText("Webhook ready")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("edits an existing workflow", async ({ page }) => {
|
||||
@@ -332,13 +489,18 @@ test("edits an existing workflow", async ({ page }) => {
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
|
||||
// Dialog should open in edit mode
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByText("Edit Workflow")).toBeVisible();
|
||||
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText("Edit workflow")).toBeVisible();
|
||||
|
||||
// Change the name
|
||||
const nameInput = page.getByLabel("Workflow name");
|
||||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||||
const nameInput = dialog
|
||||
.getByRole("textbox", { name: "Workflow name" })
|
||||
.first();
|
||||
await nameInput.clear();
|
||||
await nameInput.fill(updatedName);
|
||||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||||
|
||||
// Save
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
@@ -362,8 +524,10 @@ test("duplicates a workflow", async ({ page }) => {
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByText("Duplicate Workflow")).toBeVisible();
|
||||
|
||||
// Submit the duplicate
|
||||
await page.getByRole("button", { name: "Create Copy" }).click();
|
||||
// Submit the duplicate after deliberately choosing its destination channel.
|
||||
const dialog = page.getByRole("dialog", { name: "Duplicate workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await dialog.getByRole("button", { name: "Create copy" }).click();
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
|
||||
// Both the original and copy should exist
|
||||
@@ -396,35 +560,451 @@ test("deletes a workflow with confirmation", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("triggers a workflow from the detail panel", async ({ page }) => {
|
||||
const workflowName = `trigger_test_${Date.now()}`;
|
||||
test("deleting the open workflow closes its editor", async ({ page }) => {
|
||||
const workflowName = `delete_open_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||||
|
||||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await expect(detailDialog).toBeVisible();
|
||||
await detailDialog.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
await expect(page.getByRole("alertdialog")).toContainText(workflowName);
|
||||
await page.getByRole("button", { name: "Delete" }).click();
|
||||
|
||||
await expect(detailDialog).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/#\/workflows(?:\?|$)/);
|
||||
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
|
||||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("rejected deletion keeps the confirmation, editor, and draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflowName = `delete_rejected_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||||
const editor = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await editor.getByRole("tab", { name: "YAML" }).click();
|
||||
const yamlEditor = editor.getByRole("textbox", { name: "Workflow YAML" });
|
||||
await yamlEditor.fill(
|
||||
(await yamlEditor.inputValue()).replace(
|
||||
workflowName,
|
||||
`${workflowName}_draft`,
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E__ ??= {};
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.workflowDeleteError = "relay refused deletion";
|
||||
});
|
||||
|
||||
await editor.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
const confirmation = page.getByRole("alertdialog", {
|
||||
name: "Delete workflow?",
|
||||
});
|
||||
await confirmation.getByRole("button", { name: "Delete" }).click();
|
||||
|
||||
await expect(confirmation).toBeVisible();
|
||||
await expect(confirmation.getByRole("alert")).toContainText(
|
||||
"relay refused deletion",
|
||||
);
|
||||
await confirmation.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(yamlEditor).toContainText(`${workflowName}_draft`);
|
||||
});
|
||||
|
||||
test("captures the built editor at desktop and narrow widths", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await editWorkflowName(dialog, "editor_screenshot");
|
||||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
await dialog.getByLabel("Message text").fill("Notify the workflow channel");
|
||||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||||
|
||||
for (const viewport of [
|
||||
{ height: 900, name: "wide", width: 1440 },
|
||||
{ height: 820, name: "narrow", width: 760 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await inspector.getByRole("button", { name: "Run controls" }).click();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
path: `test-results/workflow-editor-${viewport.name}-run-controls.png`,
|
||||
});
|
||||
await inspector.getByRole("button", { name: "Step details" }).click();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
path: `test-results/workflow-editor-${viewport.name}-step-details.png`,
|
||||
});
|
||||
await inspector.getByRole("button", { name: "Step details" }).click();
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves final sequence affordances and responsive inspector behavior", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await editWorkflowName(dialog, "sequence_parity_test");
|
||||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
|
||||
const inspector = dialog.getByTestId("workflow-node-inspector");
|
||||
const runControls = inspector.getByRole("button", { name: "Run controls" });
|
||||
const stepDetails = inspector.getByRole("button", { name: "Step details" });
|
||||
await expect(runControls).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(stepDetails).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(inspector.getByLabel("Text to match")).toHaveCount(0);
|
||||
await expect(inspector.getByLabel("Name (optional)")).toHaveCount(0);
|
||||
|
||||
await runControls.click();
|
||||
await expect(runControls).toHaveAttribute("aria-expanded", "true");
|
||||
await inspector.getByLabel("Text to match").fill("deploy");
|
||||
const timeoutField = inspector.getByRole("textbox", {
|
||||
name: "Timeout",
|
||||
exact: true,
|
||||
});
|
||||
const timeoutSlider = inspector.getByRole("slider", {
|
||||
name: "Timeout slider",
|
||||
});
|
||||
await expect(timeoutSlider).toHaveAttribute("aria-valuetext", "5m");
|
||||
await timeoutField.fill("1h 2s");
|
||||
await expect(timeoutSlider).toHaveAttribute("aria-valuetext", "1h");
|
||||
await timeoutField.fill("45s");
|
||||
await expect(runControls).toContainText("Conditional · 45s");
|
||||
|
||||
await stepDetails.click();
|
||||
await expect(runControls).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(stepDetails).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(inspector.getByLabel("Text to match")).toHaveCount(0);
|
||||
await inspector.getByLabel("Name (optional)").fill("Notify deployers");
|
||||
await expect(stepDetails).toContainText("Notify deployers");
|
||||
await expect(inspector.getByLabel("Step ID")).toHaveValue("step_1");
|
||||
|
||||
await expect(dialog.getByText("End", { exact: true })).toHaveCount(0);
|
||||
const ingresses = dialog.getByTestId("workflow-node-ingress");
|
||||
await expect(ingresses).toHaveCount(2);
|
||||
await expect(
|
||||
ingresses.first().locator("svg.lucide-arrow-down"),
|
||||
).toBeVisible();
|
||||
await expect(ingresses.last().locator("svg.lucide-arrow-down")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
ingresses.last().getByRole("button", { name: "Add after Step 1" }),
|
||||
).toBeVisible();
|
||||
|
||||
const stepNode = dialog.getByRole("button", { name: /^Step 1:/ });
|
||||
const removeStep = dialog.getByRole("button", { name: "Remove Step 1" });
|
||||
await expect(removeStep).toHaveClass(/-right-8/);
|
||||
await stepNode.hover();
|
||||
await expect(removeStep).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 760, height: 820 });
|
||||
await expect(
|
||||
dialog.getByTestId("workflow-node-inspector-backdrop"),
|
||||
).toBeVisible();
|
||||
await dialog.getByTestId("workflow-node-inspector-backdrop").click({
|
||||
position: { x: 20, y: 20 },
|
||||
});
|
||||
await expect(dialog.getByTestId("workflow-node-inspector")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("pane routes use stable IDs and Form/YAML changes stay synchronized", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await editWorkflowName(dialog, "pane_sync_test");
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Send Message" }).click();
|
||||
await dialog.getByLabel("Message text").fill("first message");
|
||||
await expect(page).toHaveURL(/pane=step%3Astep_1/);
|
||||
|
||||
await dialog.getByRole("button", { name: "Add after Step 1" }).click();
|
||||
await page.getByRole("menuitem", { name: "Delay" }).click();
|
||||
await dialog.getByLabel("Duration").fill("5m");
|
||||
await expect(page).toHaveURL(/pane=step%3Astep_2/);
|
||||
|
||||
await dialog.getByRole("button", { name: "Step 1: Send Message" }).click();
|
||||
await expect(page).toHaveURL(/pane=step%3Astep_1/);
|
||||
await dialog
|
||||
.getByTestId("workflow-node-inspector")
|
||||
.getByRole("button", { name: "Remove step", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/pane=step%3Astep_2/);
|
||||
|
||||
await dialog.getByRole("tab", { name: "YAML" }).click();
|
||||
const yamlEditor = dialog.getByRole("textbox", { name: "Workflow YAML" });
|
||||
await expect(yamlEditor).toContainText("id: step_2");
|
||||
await expect(yamlEditor).not.toContainText("id: step_1");
|
||||
const yaml = await yamlEditor.inputValue();
|
||||
await yamlEditor.fill(yaml.replace("duration: 5m", "duration: 10m"));
|
||||
await dialog.getByRole("tab", { name: "Form" }).click();
|
||||
await dialog.getByRole("button", { name: "Step 1: Delay" }).click();
|
||||
await expect(dialog.getByLabel("Duration")).toHaveValue("10m");
|
||||
});
|
||||
|
||||
test("clean editor close respects in-app versus direct-entry provenance", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
await expect(page).toHaveURL(/view=create/);
|
||||
await page
|
||||
.getByRole("dialog", { name: "Create workflow" })
|
||||
.getByRole("button", {
|
||||
name: "Close",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(page).toHaveURL(/#\/workflows$/);
|
||||
|
||||
await page.goto("/#/workflows?view=create");
|
||||
await page
|
||||
.getByRole("dialog", { name: "Create workflow" })
|
||||
.getByRole("button", {
|
||||
name: "Close",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(page).toHaveURL(/#\/workflows$/);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/#\/workflows$/);
|
||||
});
|
||||
|
||||
test("direct workflow routes survive refresh and invalid view opens detail", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflowName = `route_test_${Date.now()}`;
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
|
||||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
await expect(page).toHaveURL(/#\/workflows\/[^?]+\?.*view=edit/);
|
||||
const workflowId = new URL(page.url()).hash.match(/workflows\/([^?]+)/)?.[1];
|
||||
expect(workflowId).toBeTruthy();
|
||||
await page.goto(`/#/workflows/${workflowId}?view=invalid`);
|
||||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await expect(detailDialog).toBeVisible();
|
||||
await expect(detailDialog).toContainText(workflowName);
|
||||
await expect(
|
||||
detailDialog.getByRole("button", { name: "Run history" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.goto("/#/workflows?view=create");
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Create workflow" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("dirty create close is blocked and keep editing preserves the draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Create workflow" });
|
||||
await selectWorkflowChannel(page, dialog);
|
||||
await editWorkflowName(dialog, "preserved_dirty_draft");
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).click();
|
||||
|
||||
const confirmation = page.getByRole("alertdialog", {
|
||||
name: "Discard changes?",
|
||||
});
|
||||
await expect(confirmation).toBeVisible();
|
||||
await confirmation.getByRole("button", { name: "Keep editing" }).click();
|
||||
await expect(dialog).toContainText("preserved_dirty_draft");
|
||||
});
|
||||
|
||||
test("stale editor save preserves the local draft and reports the conflict", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflowName = `stale_editor_${Date.now()}`;
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await dialog.getByRole("button", { name: "Edit workflow name" }).click();
|
||||
const nameInput = dialog
|
||||
.getByRole("textbox", { name: "Workflow name" })
|
||||
.first();
|
||||
await nameInput.fill(`${workflowName}_local`);
|
||||
await dialog.getByRole("button", { name: "Save workflow name" }).click();
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||||
const workflowId = new URL(window.location.href).hash.match(
|
||||
/workflows\/([^?]+)/,
|
||||
)?.[1];
|
||||
if (!workflowId) throw new Error("workflow id unavailable");
|
||||
const workflow = (await invoke("get_workflow", { workflowId })) as {
|
||||
revision: string;
|
||||
};
|
||||
await invoke("update_workflow", {
|
||||
expectedRevision: workflow.revision,
|
||||
workflowId,
|
||||
yamlDefinition:
|
||||
"name: authoritative_remote\ntrigger:\n on: message_posted\nsteps:\n - id: step_1\n action: send_message\n text: remote\n",
|
||||
});
|
||||
});
|
||||
|
||||
await dialog.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect(dialog).toContainText("workflow changed since it was loaded");
|
||||
await expect(dialog).toContainText(`${workflowName}_local`);
|
||||
await expect(dialog).toBeVisible();
|
||||
});
|
||||
|
||||
test("unsupported workflow opens canonical YAML without a fabricated sequence", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflowName = `unsupported_detail_${Date.now()}`;
|
||||
await navigateToWorkflows(page);
|
||||
const workflowId = await page.evaluate(async (name) => {
|
||||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||||
const workflow = (await invoke("create_workflow", {
|
||||
channelId: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||||
yamlDefinition: `name: ${name}\ntrigger:\n on: message_posted\n legacy_filter:\n author: alice\nsteps:\n - id: legacy_step\n action: send_message\n text: preserve me\n`,
|
||||
})) as { id: string };
|
||||
return workflow.id;
|
||||
}, workflowName);
|
||||
|
||||
await page.goto(`/#/workflows/${workflowId}`);
|
||||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await expect(detailDialog).toBeVisible();
|
||||
await expect(detailDialog).toContainText(workflowName);
|
||||
await expect(detailDialog.getByRole("tab", { name: "YAML" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
const yamlEditor = detailDialog.getByRole("textbox", {
|
||||
name: "Workflow YAML",
|
||||
});
|
||||
await expect(yamlEditor).toContainText("legacy_filter:");
|
||||
await expect(yamlEditor).toContainText("author: alice");
|
||||
await expect(yamlEditor).toContainText("id: legacy_step");
|
||||
await expect(yamlEditor).toContainText("text: preserve me");
|
||||
await expect(
|
||||
detailDialog.getByRole("button", { name: "Trigger: Message Posted" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
detailDialog.getByRole("button", { name: "Step 1: Send Message" }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("card click opens the animated trigger inspector, triggers a run, and hides unsupported run history", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflowName = `card_editor_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
|
||||
// Click on the workflow card to open the detail panel
|
||||
const inspectorEntryAnimation = page.evaluate(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(false);
|
||||
}, 1_000);
|
||||
const observer = new MutationObserver(() => {
|
||||
const inspector = document.querySelector<HTMLElement>(
|
||||
'[data-testid="workflow-node-inspector"]',
|
||||
);
|
||||
if (!inspector) return;
|
||||
|
||||
observer.disconnect();
|
||||
window.clearTimeout(timeoutId);
|
||||
const initialRect = inspector.getBoundingClientRect();
|
||||
const initialStyles = getComputedStyle(inspector);
|
||||
const initial = {
|
||||
opacity: initialStyles.opacity,
|
||||
transform: initialStyles.transform,
|
||||
width: initialRect.width,
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const nextRect = inspector.getBoundingClientRect();
|
||||
const nextStyles = getComputedStyle(inspector);
|
||||
resolve(
|
||||
nextRect.width !== initial.width ||
|
||||
nextStyles.opacity !== initial.opacity ||
|
||||
nextStyles.transform !== initial.transform,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}),
|
||||
);
|
||||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||||
await expect(page.getByTestId("workflow-detail-panel")).toBeVisible();
|
||||
|
||||
// Click the Trigger button
|
||||
await page
|
||||
.getByTestId("workflow-detail-panel")
|
||||
.getByRole("button", { name: "Trigger" })
|
||||
.click();
|
||||
|
||||
// Wait for the trigger to complete (button text changes back from "Triggering...")
|
||||
await expect(page).toHaveURL(/#\/workflows\/[^?]+\?pane=trigger$/);
|
||||
const detailDialog = page.getByRole("dialog", { name: "Edit workflow" });
|
||||
await expect(detailDialog).toBeVisible();
|
||||
await expect(detailDialog).toContainText(workflowName);
|
||||
await expect(inspectorEntryAnimation).resolves.toBe(true);
|
||||
await expect(page.getByTestId("workflow-node-inspector")).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workflow-detail-panel")
|
||||
.getByRole("button", { name: "Trigger" }),
|
||||
).toBeVisible();
|
||||
|
||||
detailDialog.getByRole("button", { name: "Trigger: Message Posted" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
const triggerCallsBefore = await page.evaluate(
|
||||
() =>
|
||||
window.__BUZZ_E2E_COMMAND_PAYLOADS__?.filter(
|
||||
(call) => call.command === "trigger_workflow",
|
||||
).length ?? 0,
|
||||
);
|
||||
await detailDialog.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Trigger" }).click();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
window.__BUZZ_E2E_COMMAND_PAYLOADS__?.filter(
|
||||
(call) => call.command === "trigger_workflow",
|
||||
).length ?? 0,
|
||||
),
|
||||
)
|
||||
.toBe(triggerCallsBefore + 1);
|
||||
await expect(page.getByTestId("workflow-detail-panel")).toHaveCount(0);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workflow-detail-panel")
|
||||
.getByTestId("workflow-selected-run"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("workflow-detail-panel").getByTestId("workflow-run-trace"),
|
||||
).toContainText("step_1");
|
||||
detailDialog.getByRole("button", { name: "Run history" }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("missing workflow routes show an unavailable modal with close and retry", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await page.goto("/#/workflows/missing-workflow?view=edit");
|
||||
|
||||
const unavailable = page.getByRole("dialog", {
|
||||
name: "Workflow unavailable",
|
||||
});
|
||||
await expect(unavailable).toBeVisible();
|
||||
await expect(
|
||||
unavailable.getByRole("button", { name: "Retry" }),
|
||||
).toBeVisible();
|
||||
await unavailable.getByRole("button", { name: "Close" }).click();
|
||||
await expect(page).toHaveURL(/#\/workflows$/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user