mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
**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>
291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|