mirror of
https://github.com/block/buzz.git
synced 2026-11-05 08:51:10 +00:00
**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>
295 lines
7.3 KiB
TypeScript
295 lines
7.3 KiB
TypeScript
import { invokeTauri } from "@/shared/api/tauri";
|
|
import type {
|
|
ApprovalActionResponse,
|
|
TriggerWorkflowResponse,
|
|
Workflow,
|
|
WorkflowApproval,
|
|
WorkflowRun,
|
|
WorkflowSaveResult,
|
|
TraceEntry,
|
|
} from "@/shared/api/types";
|
|
|
|
// ── Raw types (snake_case from backend) ───────────────────────────────────
|
|
|
|
type RawWorkflow = {
|
|
id: string;
|
|
revision: string;
|
|
name: string;
|
|
owner_pubkey: string;
|
|
channel_id: string | null;
|
|
definition: Record<string, unknown>;
|
|
status: Workflow["status"];
|
|
created_at: number;
|
|
updated_at: number;
|
|
};
|
|
|
|
type RawWorkflowSaveResponse = RawWorkflow & {
|
|
webhook_secret?: string | null;
|
|
};
|
|
|
|
type RawTraceEntry = {
|
|
step_id: string;
|
|
status: string;
|
|
output?: Record<string, unknown>;
|
|
started_at?: number | null;
|
|
completed_at?: number | null;
|
|
error?: string | null;
|
|
};
|
|
|
|
type RawWorkflowRun = {
|
|
id: string;
|
|
workflow_id: string;
|
|
status: WorkflowRun["status"];
|
|
current_step: number | null;
|
|
execution_trace: RawTraceEntry[];
|
|
started_at: number | null;
|
|
completed_at: number | null;
|
|
error_code?: string | null;
|
|
error_message: string | null;
|
|
created_at: number;
|
|
};
|
|
|
|
type RawWorkflowRunCursor = {
|
|
before: string;
|
|
before_id: string;
|
|
};
|
|
|
|
type RawWorkflowRunsResponse = {
|
|
runs: RawWorkflowRun[];
|
|
next: RawWorkflowRunCursor | null;
|
|
};
|
|
|
|
type RawWorkflowApproval = {
|
|
approval_ref: string;
|
|
workflow_id: string;
|
|
run_id: string;
|
|
step_id: string;
|
|
step_index: number;
|
|
approver_spec: string;
|
|
status: WorkflowApproval["status"];
|
|
approver_pubkey: string | null;
|
|
note: string | null;
|
|
expires_at: string;
|
|
created_at: number;
|
|
};
|
|
|
|
type RawWorkflowApprovalsResponse = {
|
|
approvals: RawWorkflowApproval[];
|
|
};
|
|
|
|
type RawTriggerWorkflowResponse = {
|
|
run_id: string;
|
|
workflow_id: string;
|
|
status: string;
|
|
};
|
|
|
|
type RawApprovalActionResponse = {
|
|
token: string;
|
|
status: string;
|
|
run_id: string;
|
|
workflow_id: string;
|
|
};
|
|
|
|
// ── Conversion functions ──────────────────────────────────────────────────
|
|
|
|
function fromRawWorkflow(raw: RawWorkflow): Workflow {
|
|
return {
|
|
id: raw.id,
|
|
revision: raw.revision,
|
|
name: raw.name,
|
|
ownerPubkey: raw.owner_pubkey,
|
|
channelId: raw.channel_id,
|
|
definition: raw.definition,
|
|
status: raw.status,
|
|
createdAt: raw.created_at,
|
|
updatedAt: raw.updated_at,
|
|
};
|
|
}
|
|
|
|
function fromRawWorkflowSave(raw: RawWorkflowSaveResponse): WorkflowSaveResult {
|
|
return {
|
|
workflow: fromRawWorkflow(raw),
|
|
webhookSecret: raw.webhook_secret ?? null,
|
|
};
|
|
}
|
|
|
|
function fromRawTraceEntry(raw: RawTraceEntry): TraceEntry {
|
|
return {
|
|
stepId: raw.step_id,
|
|
status: raw.status,
|
|
output: raw.output ?? {},
|
|
startedAt: raw.started_at ?? null,
|
|
completedAt: raw.completed_at ?? null,
|
|
error: raw.error ?? null,
|
|
};
|
|
}
|
|
|
|
function fromRawWorkflowRun(raw: RawWorkflowRun): WorkflowRun {
|
|
return {
|
|
id: raw.id,
|
|
workflowId: raw.workflow_id,
|
|
status: raw.status,
|
|
currentStep: raw.current_step,
|
|
executionTrace: raw.execution_trace.map(fromRawTraceEntry),
|
|
startedAt: raw.started_at,
|
|
completedAt: raw.completed_at,
|
|
errorCode: raw.error_code ?? null,
|
|
errorMessage: raw.error_message,
|
|
createdAt: raw.created_at,
|
|
};
|
|
}
|
|
|
|
export function fromRawApproval(raw: RawWorkflowApproval): WorkflowApproval {
|
|
return {
|
|
approvalRef: raw.approval_ref,
|
|
workflowId: raw.workflow_id,
|
|
runId: raw.run_id,
|
|
stepId: raw.step_id,
|
|
stepIndex: raw.step_index,
|
|
approverSpec: raw.approver_spec,
|
|
status: raw.status,
|
|
approverPubkey: raw.approver_pubkey,
|
|
note: raw.note,
|
|
expiresAt: raw.expires_at,
|
|
createdAt: raw.created_at,
|
|
};
|
|
}
|
|
|
|
function fromRawTriggerResponse(
|
|
raw: RawTriggerWorkflowResponse,
|
|
): TriggerWorkflowResponse {
|
|
return {
|
|
runId: raw.run_id,
|
|
workflowId: raw.workflow_id,
|
|
status: raw.status,
|
|
};
|
|
}
|
|
|
|
function fromRawApprovalResponse(
|
|
raw: RawApprovalActionResponse,
|
|
): ApprovalActionResponse {
|
|
return {
|
|
token: raw.token,
|
|
status: raw.status,
|
|
runId: raw.run_id,
|
|
workflowId: raw.workflow_id,
|
|
};
|
|
}
|
|
|
|
// ── Tauri invoke wrappers ─────────────────────────────────────────────────
|
|
|
|
export async function getChannelWorkflows(
|
|
channelId: string,
|
|
): Promise<Workflow[]> {
|
|
const raw = await invokeTauri<RawWorkflow[]>("get_channel_workflows", {
|
|
channelId,
|
|
});
|
|
return raw.map(fromRawWorkflow);
|
|
}
|
|
|
|
/**
|
|
* Fetch workflows across many channels in a single relay round-trip.
|
|
*
|
|
* Replaces the per-channel `Promise.all(getChannelWorkflows)` fanout on the
|
|
* Workflows overview: the backend `#h` filter matches any listed channel, and
|
|
* each returned workflow carries its own `channelId` so callers can group.
|
|
*/
|
|
export async function getChannelsWorkflows(
|
|
channelIds: string[],
|
|
): Promise<Workflow[]> {
|
|
const raw = await invokeTauri<RawWorkflow[]>("get_channels_workflows", {
|
|
channelIds,
|
|
});
|
|
return raw.map(fromRawWorkflow);
|
|
}
|
|
|
|
export async function getWorkflow(workflowId: string): Promise<Workflow> {
|
|
const raw = await invokeTauri<RawWorkflow>("get_workflow", { workflowId });
|
|
return fromRawWorkflow(raw);
|
|
}
|
|
|
|
export async function createWorkflow(
|
|
channelId: string,
|
|
yamlDefinition: string,
|
|
): Promise<WorkflowSaveResult> {
|
|
const raw = await invokeTauri<RawWorkflowSaveResponse>("create_workflow", {
|
|
channelId,
|
|
yamlDefinition,
|
|
});
|
|
return fromRawWorkflowSave(raw);
|
|
}
|
|
|
|
export async function updateWorkflow(
|
|
workflowId: string,
|
|
yamlDefinition: string,
|
|
expectedRevision: string,
|
|
): Promise<WorkflowSaveResult> {
|
|
const raw = await invokeTauri<RawWorkflowSaveResponse>("update_workflow", {
|
|
workflowId,
|
|
yamlDefinition,
|
|
expectedRevision,
|
|
});
|
|
return fromRawWorkflowSave(raw);
|
|
}
|
|
|
|
export async function deleteWorkflow(workflowId: string): Promise<void> {
|
|
await invokeTauri("delete_workflow", { workflowId });
|
|
}
|
|
|
|
export async function getWorkflowRuns(
|
|
workflowId: string,
|
|
limit?: number,
|
|
): Promise<WorkflowRun[]> {
|
|
const raw = await invokeTauri<RawWorkflowRunsResponse>("get_workflow_runs", {
|
|
workflowId,
|
|
limit: limit ?? null,
|
|
});
|
|
return raw.runs.map(fromRawWorkflowRun);
|
|
}
|
|
|
|
export async function getRunApprovals(
|
|
workflowId: string,
|
|
runId: string,
|
|
): Promise<WorkflowApproval[]> {
|
|
const raw = await invokeTauri<RawWorkflowApprovalsResponse>(
|
|
"get_run_approvals",
|
|
{
|
|
workflowId,
|
|
runId,
|
|
},
|
|
);
|
|
return raw.approvals.map(fromRawApproval);
|
|
}
|
|
|
|
export async function triggerWorkflow(
|
|
workflowId: string,
|
|
): Promise<TriggerWorkflowResponse> {
|
|
const raw = await invokeTauri<RawTriggerWorkflowResponse>(
|
|
"trigger_workflow",
|
|
{ workflowId },
|
|
);
|
|
return fromRawTriggerResponse(raw);
|
|
}
|
|
|
|
export async function grantApproval(
|
|
token: string,
|
|
note?: string,
|
|
): Promise<ApprovalActionResponse> {
|
|
const raw = await invokeTauri<RawApprovalActionResponse>("grant_approval", {
|
|
token,
|
|
note: note ?? null,
|
|
});
|
|
return fromRawApprovalResponse(raw);
|
|
}
|
|
|
|
export async function denyApproval(
|
|
token: string,
|
|
note?: string,
|
|
): Promise<ApprovalActionResponse> {
|
|
const raw = await invokeTauri<RawApprovalActionResponse>("deny_approval", {
|
|
token,
|
|
note: note ?? null,
|
|
});
|
|
return fromRawApprovalResponse(raw);
|
|
}
|