mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish workflow editor presentation
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -37,7 +37,6 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import {
|
||||
getWorkflowCardLabel,
|
||||
getWorkflowDescription,
|
||||
getWorkflowDisplayStatus,
|
||||
getWorkflowEnabled,
|
||||
getWorkflowPrimaryAction,
|
||||
@@ -217,7 +216,6 @@ export function WorkflowCard({
|
||||
const customEmoji = useCustomEmoji();
|
||||
const isEnabled = getWorkflowEnabled(workflow.definition);
|
||||
const displayStatus = getWorkflowDisplayStatus(workflow);
|
||||
const description = getWorkflowDescription(workflow.definition);
|
||||
const configuredTrigger = getWorkflowTriggerConfig(workflow.definition);
|
||||
const trigger = configuredTrigger ?? { on: "message_posted" as const };
|
||||
const triggerPresentation = useWorkflowTriggerPresentation({
|
||||
@@ -383,12 +381,6 @@ export function WorkflowCard({
|
||||
text={cardLabel}
|
||||
/>
|
||||
</h3>
|
||||
{description ? (
|
||||
<p className="mt-2 line-clamp-2 text-sm leading-relaxed text-white/75">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto flex min-w-0 items-end justify-between gap-3 pt-5 text-white/75">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-semibold text-white">
|
||||
|
||||
@@ -752,7 +752,7 @@ export function WorkflowConditionBuilder({
|
||||
{isExpanded && activeEditor ? (
|
||||
<div
|
||||
className={cn(
|
||||
"animate-in space-y-4 pb-4 pt-1 fade-in slide-in-from-top-1 duration-150 motion-reduce:animate-none",
|
||||
"animate-in space-y-4 pt-1 fade-in slide-in-from-top-1 duration-150 motion-reduce:animate-none",
|
||||
conditionFieldUsesFullHeightPicker(
|
||||
activeEditor.draft.field,
|
||||
) && "flex min-h-0 flex-1 flex-col space-y-0",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import * as React from "react";
|
||||
import { Code } from "lucide-react";
|
||||
import { Check, Code, Pencil } from "lucide-react";
|
||||
import { useBlocker } from "@tanstack/react-router";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
import { isMap, parseDocument, 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 {
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { ChannelCombobox } from "./ChannelCombobox";
|
||||
import {
|
||||
WorkflowFormBuilder,
|
||||
@@ -35,7 +37,11 @@ import {
|
||||
} from "./WorkflowFormBuilder";
|
||||
import { WorkflowWebhookSecretDialog } from "./WorkflowWebhookSecretDialog";
|
||||
import type { WorkflowEditorPane } from "./workflowEditorPane";
|
||||
import { yamlToFormState } from "./workflowFormTypes";
|
||||
import {
|
||||
DEFAULT_FORM_STATE,
|
||||
formStateToYaml,
|
||||
yamlToFormState,
|
||||
} from "./workflowFormTypes";
|
||||
|
||||
type DialogMode = "create" | "edit" | "duplicate";
|
||||
|
||||
@@ -84,6 +90,113 @@ const PENDING_LABELS: Record<DialogMode, string> = {
|
||||
duplicate: "Creating…",
|
||||
};
|
||||
|
||||
function visibleWorkflowName(
|
||||
yaml: string,
|
||||
fallbackName: string | undefined,
|
||||
): string {
|
||||
const parsed = yamlToFormState(yaml);
|
||||
if (parsed.ok && parsed.state.name.trim()) return parsed.state.name.trim();
|
||||
return fallbackName?.trim() ?? "";
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function WorkflowNameEditor({
|
||||
disabled,
|
||||
generating,
|
||||
name,
|
||||
onCommit,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
generating: boolean;
|
||||
name: string;
|
||||
onCommit: (name: string) => boolean;
|
||||
}) {
|
||||
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 commit = React.useCallback(() => {
|
||||
const nextName = draft.trim();
|
||||
if (!nextName || !onCommit(nextName)) return;
|
||||
setEditing(false);
|
||||
}, [draft, onCommit]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="flex h-7 items-center gap-1.5">
|
||||
<Input
|
||||
aria-label="Workflow name"
|
||||
className="h-7 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);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Save workflow name"
|
||||
className="text-muted-foreground"
|
||||
disabled={disabled || !draft.trim()}
|
||||
onClick={commit}
|
||||
size="icon-xs"
|
||||
title="Save workflow name"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Check />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline-flex h-7 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={() => setEditing(true)}
|
||||
size="icon-xs"
|
||||
title="Edit workflow name"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowDialog({
|
||||
channels,
|
||||
mode,
|
||||
@@ -115,10 +228,12 @@ export function WorkflowDialog({
|
||||
} | null>(null);
|
||||
const [discardConfirmationOpen, setDiscardConfirmationOpen] =
|
||||
React.useState(false);
|
||||
const [generatingName, setGeneratingName] = React.useState(false);
|
||||
const initialValuesRef = React.useRef({
|
||||
channelId,
|
||||
yaml: getInitialYaml(mode, workflow),
|
||||
});
|
||||
const yamlDefinitionRef = React.useRef(yamlDefinition);
|
||||
const allowNavigationRef = React.useRef(false);
|
||||
const proceedingNavigationRef = React.useRef(false);
|
||||
|
||||
@@ -135,6 +250,7 @@ export function WorkflowDialog({
|
||||
|
||||
// Re-initialize when dialog opens or workflow/mode changes
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
if (open) {
|
||||
const newChannelId =
|
||||
mode === "edit" && workflowChannelId ? workflowChannelId : "";
|
||||
@@ -144,6 +260,7 @@ export function WorkflowDialog({
|
||||
channelId: newChannelId,
|
||||
yaml: initialYaml,
|
||||
};
|
||||
yamlDefinitionRef.current = initialYaml;
|
||||
setYamlDefinition(initialYaml);
|
||||
setEditorMode(getInitialEditorMode(initialYaml));
|
||||
setEditorParseError(null);
|
||||
@@ -151,7 +268,37 @@ export function WorkflowDialog({
|
||||
setDiscardConfirmationOpen(false);
|
||||
resetCreate();
|
||||
resetUpdate();
|
||||
|
||||
if (mode === "create" && !workflow) {
|
||||
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);
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave the editable "Untitled workflow" fallback in place.
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setGeneratingName(false);
|
||||
});
|
||||
} else {
|
||||
setGeneratingName(false);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, mode, workflow, workflowChannelId, resetCreate, resetUpdate]);
|
||||
|
||||
const closeDialog = React.useCallback(() => {
|
||||
@@ -244,6 +391,21 @@ export function WorkflowDialog({
|
||||
[editorMode, yamlDefinition],
|
||||
);
|
||||
|
||||
const workflowName = visibleWorkflowName(yamlDefinition, workflow?.name);
|
||||
const canEditWorkflowName =
|
||||
!yamlDefinition.trim() || yamlToFormState(yamlDefinition).ok;
|
||||
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 showChannelSelector = mode !== "edit";
|
||||
|
||||
return (
|
||||
@@ -254,15 +416,21 @@ export function WorkflowDialog({
|
||||
>
|
||||
<DialogContent className="flex h-[88vh] max-h-[88vh] w-[calc(100vw-2rem)] max-w-6xl flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="flex flex-shrink-0 flex-row items-center justify-between gap-6 border-b border-border px-6 py-5 pr-14 text-left">
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{TITLES[mode]}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<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>
|
||||
<WorkflowNameEditor
|
||||
disabled={mutation.isPending || !canEditWorkflowName}
|
||||
generating={generatingName}
|
||||
name={workflowName}
|
||||
onCommit={handleWorkflowNameCommit}
|
||||
/>
|
||||
</div>
|
||||
<div ref={setHeaderTrailingElement} />
|
||||
</DialogHeader>
|
||||
@@ -275,6 +443,7 @@ export function WorkflowDialog({
|
||||
mode={editorMode}
|
||||
onChange={(yaml) => {
|
||||
mutation.reset();
|
||||
yamlDefinitionRef.current = yaml;
|
||||
setYamlDefinition(yaml);
|
||||
}}
|
||||
onSelectedNodeChange={onEditorPaneChange}
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { WorkflowConditionBuilder } from "./WorkflowConditionBuilder";
|
||||
@@ -39,7 +38,6 @@ import {
|
||||
import { WorkflowScheduleFields } from "./WorkflowScheduleFields";
|
||||
import { WorkflowStepCard } from "./WorkflowStepCard";
|
||||
import { buildConditionExpression } from "./workflowConditionExpression";
|
||||
import { FieldLabel } from "./workflowFormPrimitives";
|
||||
import {
|
||||
DEFAULT_FORM_STATE,
|
||||
ACTION_LABELS,
|
||||
@@ -424,6 +422,17 @@ export function WorkflowFormBuilder({
|
||||
if (result.ok) setFormState(result.state);
|
||||
}, [mode, yaml]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!yaml.trim()) return;
|
||||
const result = yamlToFormState(yaml);
|
||||
if (!result.ok) return;
|
||||
setFormState((current) =>
|
||||
current.name === result.state.name
|
||||
? current
|
||||
: { ...current, name: result.state.name },
|
||||
);
|
||||
}, [yaml]);
|
||||
|
||||
const selectNode = React.useCallback(
|
||||
(nextNode: Exclude<WorkflowEditorPane, null>) => {
|
||||
if (selectedNode) {
|
||||
@@ -530,41 +539,6 @@ export function WorkflowFormBuilder({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="grid flex-shrink-0 grid-cols-2 items-end gap-3 border-b border-border bg-muted/10 px-6 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-name">Workflow name</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
disabled={disabled}
|
||||
id="wf-name"
|
||||
onChange={(event) =>
|
||||
updateFormState({ ...formState, name: event.target.value })
|
||||
}
|
||||
placeholder="e.g. deploy_notifier"
|
||||
value={formState.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-description">
|
||||
Description (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id="wf-description"
|
||||
onChange={(event) =>
|
||||
updateFormState({
|
||||
...formState,
|
||||
description: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="What does this workflow do?"
|
||||
value={formState.description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
|
||||
@@ -385,15 +385,6 @@ export function getWorkflowDisplayStatus(
|
||||
return getWorkflowEnabled(workflow.definition) ? workflow.status : "disabled";
|
||||
}
|
||||
|
||||
export function getWorkflowDescription(
|
||||
definition: Record<string, unknown>,
|
||||
): string | null {
|
||||
const description = definition.description;
|
||||
return typeof description === "string" && description.trim().length > 0
|
||||
? description.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getWorkflowTriggerSummary(
|
||||
definition: Record<string, unknown>,
|
||||
): string | null {
|
||||
|
||||
Reference in New Issue
Block a user