mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(workflows): add responsive library card actions (#6008)
**Category:** improvement **User Impact:** Users can scan what each workflow does and trigger, edit, duplicate, enable, disable, or delete it directly from the library. **Problem:** The workflow list buried common actions and did not expose each automation's trigger-to-action shape at a glance. **Solution:** Add a responsive workflow library with a persistent create tile, compact trigger/action diagrams, prominent workflow titles with supporting descriptions, and shared card actions while preserving existing detail, editor, and run-history entry points. Card toggles refresh both list and open-detail caches so status and definition stay consistent. <details> <summary>File changes</summary> **desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx** Adds a shared card menu for trigger, edit, duplicate, enable/disable, and delete actions. **desktop/src/features/workflows/ui/WorkflowCard.tsx** Reworks cards around the prototype's visual hierarchy: color-coded trigger, action flow, sentence-case eyebrow, prominent title, supporting description, status, channel, and update date without a footer clock icon. **desktop/src/features/workflows/ui/WorkflowsView.tsx** Adds the responsive grid, create tile, mutation wiring, and list/detail cache invalidation. Container breakpoints keep cards two-across at medium widths and three-across in the 1280px desktop layout. **desktop/src/features/workflows/ui/workflowDefinition.ts** Adds immutable enabled-state updates plus narrow trigger and first-action readers used only to select card icons. **desktop/src/features/workflows/ui/workflowDefinition.test.mjs** Covers neutral icon selection, enabled-state immutability, and status presentation. **desktop/tests/e2e/workflows.spec.ts** Covers the create tile, title/description hierarchy, selected-card enable/disable consistency, and deterministic narrow/medium/wide captures while retaining existing action coverage. </details> ## Reproduction steps 1. Open **Workflows** and confirm the create tile stays first as cards flow from one to three columns with available width. 2. Confirm each card shows a sentence-case trigger eyebrow, prominent workflow title, supporting description when present, status, channel, and update date without a clock icon. 3. Open a card's overflow menu and trigger, edit, duplicate, enable/disable, or delete the workflow. 4. Leave the detail panel open while toggling and confirm its badge and JSON definition update with the card. ## Screenshots Real built E2E UI with representative workflow data at three viewport sizes. ### Narrow — 800 × 720  ### Medium — 1024 × 720  ### Wide — 1280 × 720  ### Card actions  --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -62,13 +62,13 @@ async function createWorkflow(
|
||||
).not.toBeVisible();
|
||||
}
|
||||
|
||||
test("navigates to workflows view and shows empty state", async ({ page }) => {
|
||||
test("navigates to workflows view and shows the empty create tile", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
|
||||
await expect(page.getByText("No workflows yet")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Create your first workflow" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
|
||||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("creates a workflow via the form builder", async ({ page }) => {
|
||||
@@ -99,6 +99,49 @@ test("disables autocapitalization in the workflow form", async ({ page }) => {
|
||||
);
|
||||
});
|
||||
|
||||
test("captures workflow library across responsive viewports", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, "Notify reviewers when source files change", {
|
||||
description: "Watches diff events for src/ changes",
|
||||
enabled: false,
|
||||
trigger: "diff_posted",
|
||||
});
|
||||
await createWorkflow(page, "Post the daily standup reminder to the team", {
|
||||
description: "Keeps the team aligned every morning",
|
||||
trigger: "schedule",
|
||||
});
|
||||
await createWorkflow(
|
||||
page,
|
||||
"Request approval before deploying to production",
|
||||
{
|
||||
description: "Requires a final review before release",
|
||||
trigger: "reaction_added",
|
||||
},
|
||||
);
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 800, height: 720, name: "narrow" },
|
||||
{ width: 1024, height: 720, name: "medium" },
|
||||
{ width: 1280, height: 720, name: "wide" },
|
||||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: `test-results/workflow-library-${viewport.name}.png`,
|
||||
});
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
const firstCard = page.locator('[data-testid^="workflow-card-"]').first();
|
||||
await firstCard.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: "test-results/workflow-library-wide-actions.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("captures disabled diff workflows in the list UI", async ({ page }) => {
|
||||
const workflowName = `diff_workflow_${Date.now()}`;
|
||||
const description = "Watches diff events for src/ changes";
|
||||
@@ -117,12 +160,145 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => {
|
||||
.locator('[data-testid^="workflow-card-"]')
|
||||
.filter({ hasText: workflowName })
|
||||
.first();
|
||||
await expect(card).toContainText(workflowName);
|
||||
await expect(card).toContainText(description);
|
||||
await expect(card).toContainText("Diff Posted");
|
||||
await expect(card.getByText("Diff Posted", { exact: true })).toBeVisible();
|
||||
await expect(card.locator("h3")).toHaveText(workflowName);
|
||||
await expect(card.getByText(description, { exact: true })).toBeVisible();
|
||||
await expect(card).toContainText("disabled");
|
||||
});
|
||||
|
||||
test("enables and disables a workflow from its card menu", async ({ page }) => {
|
||||
const workflowName = `toggle_workflow_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
|
||||
const workflowCard = () =>
|
||||
page
|
||||
.locator('[data-testid^="workflow-card-"]')
|
||||
.filter({ hasText: workflowName })
|
||||
.first();
|
||||
const workflowActions = () =>
|
||||
workflowCard().getByRole("button", { name: "Workflow actions" });
|
||||
|
||||
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);
|
||||
await expect(
|
||||
enableItem.getByTestId("workflow-enabled-switch-visual"),
|
||||
).toHaveAttribute("aria-hidden", "true");
|
||||
await enableItem.click();
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
const workflowName = `stale_toggle_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
const workflowCard = page
|
||||
.locator('[data-testid^="workflow-card-"]')
|
||||
.filter({ hasText: workflowName })
|
||||
.first();
|
||||
|
||||
await page.evaluate(async (name) => {
|
||||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||||
const createCall = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
||||
.reverse()
|
||||
.find((call) => call.command === "create_workflow");
|
||||
const channelId = (
|
||||
createCall?.payload as { channelId?: string } | undefined
|
||||
)?.channelId;
|
||||
if (!channelId) throw new Error("create workflow channel unavailable");
|
||||
const workflows = (await invoke("get_channels_workflows", {
|
||||
channelIds: [channelId],
|
||||
})) as Array<{
|
||||
id: string;
|
||||
revision: string;
|
||||
definition: Record<string, unknown>;
|
||||
}>;
|
||||
const workflow = workflows.find(
|
||||
(candidate) => candidate.definition.name === name,
|
||||
);
|
||||
if (!workflow) throw new Error("created workflow unavailable");
|
||||
await invoke("update_workflow", {
|
||||
workflowId: workflow.id,
|
||||
expectedRevision: workflow.revision,
|
||||
yamlDefinition: `name: ${name} edited elsewhere\nenabled: true\ntrigger:\n on: message_posted\nsteps:\n - id: step_1\n action: post_message\n`,
|
||||
});
|
||||
}, workflowName);
|
||||
|
||||
await workflowCard.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await page.getByRole("menuitemcheckbox", { name: "Enable" }).click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator("[data-sonner-toast][data-removed='false']")
|
||||
.filter({ hasText: "workflow changed since it was loaded" }),
|
||||
).toBeVisible();
|
||||
const authoritativeName = await page.evaluate(async () => {
|
||||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||
if (!invoke) throw new Error("mock command bridge unavailable");
|
||||
const createCall = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
||||
.reverse()
|
||||
.find((call) => call.command === "create_workflow");
|
||||
const channelId = (
|
||||
createCall?.payload as { channelId?: string } | undefined
|
||||
)?.channelId;
|
||||
if (!channelId) throw new Error("create workflow channel unavailable");
|
||||
const workflows = (await invoke("get_channels_workflows", {
|
||||
channelIds: [channelId],
|
||||
})) as Array<{ name: string }>;
|
||||
return workflows[0]?.name;
|
||||
});
|
||||
expect(authoritativeName).toBe(`${workflowName} edited elsewhere`);
|
||||
});
|
||||
|
||||
test("reports a rejected workflow status change", async ({ page }) => {
|
||||
const workflowName = `rejected_toggle_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E__ ??= {};
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.workflowUpdateError = "relay refused the update";
|
||||
});
|
||||
|
||||
const workflowCard = page
|
||||
.locator('[data-testid^="workflow-card-"]')
|
||||
.filter({ hasText: workflowName })
|
||||
.first();
|
||||
await workflowCard.getByRole("button", { name: "Workflow actions" }).click();
|
||||
await page.getByRole("menuitemcheckbox", { name: "Enable" }).click();
|
||||
|
||||
const errorToast = page
|
||||
.locator("[data-sonner-toast][data-removed='false']")
|
||||
.filter({ hasText: "Couldn’t change workflow status" });
|
||||
await expect(errorToast).toContainText("relay refused the update");
|
||||
await expect(workflowCard.getByText("active", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows the webhook secret dialog after saving a webhook workflow", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -215,8 +391,9 @@ test("deletes a workflow with confirmation", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Delete" }).click();
|
||||
await expect(page.getByRole("alertdialog")).not.toBeVisible();
|
||||
|
||||
// Verify workflow is gone — back to empty state
|
||||
await expect(page.getByText("No workflows yet")).toBeVisible();
|
||||
// Verify workflow is gone — back to the empty create tile.
|
||||
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
|
||||
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("triggers a workflow from the detail panel", async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user