mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +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>
276 lines
9.8 KiB
TypeScript
276 lines
9.8 KiB
TypeScript
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)`);
|
|
});
|