mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): workflow builder UI with form editor, CRUD, and channel picker (#231)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,7 +50,8 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.6",
|
||||
|
||||
@@ -24,6 +24,7 @@ export default defineConfig({
|
||||
"**/channel-browser.spec.ts",
|
||||
"**/messaging.spec.ts",
|
||||
"**/mentions.spec.ts",
|
||||
"**/workflows.spec.ts",
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
|
||||
47
desktop/pnpm-lock.yaml
generated
47
desktop/pnpm-lock.yaml
generated
@@ -89,6 +89,9 @@ importers:
|
||||
tailwind-merge:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
yaml:
|
||||
specifier: ^2.8.3
|
||||
version: 2.8.3
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: ^2.4.6
|
||||
@@ -110,7 +113,7 @@ importers:
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.6.0
|
||||
version: 4.7.0(vite@7.3.1(jiti@1.21.7)(yaml@2.8.2))
|
||||
version: 4.7.0(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))
|
||||
autoprefixer:
|
||||
specifier: ^10.4.27
|
||||
version: 10.4.27(postcss@8.5.8)
|
||||
@@ -122,16 +125,16 @@ importers:
|
||||
version: 8.5.8
|
||||
tailwindcss:
|
||||
specifier: ^3.4.17
|
||||
version: 3.4.19(yaml@2.8.2)
|
||||
version: 3.4.19(yaml@2.8.3)
|
||||
tailwindcss-animate:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(tailwindcss@3.4.19(yaml@2.8.2))
|
||||
version: 1.0.7(tailwindcss@3.4.19(yaml@2.8.3))
|
||||
typescript:
|
||||
specifier: ~5.8.3
|
||||
version: 5.8.3
|
||||
vite:
|
||||
specifier: ^7.0.4
|
||||
version: 7.3.1(jiti@1.21.7)(yaml@2.8.2)
|
||||
version: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -925,6 +928,7 @@ packages:
|
||||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||
@@ -935,21 +939,25 @@ packages:
|
||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||
@@ -960,11 +968,13 @@ packages:
|
||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||
@@ -975,11 +985,13 @@ packages:
|
||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||
@@ -2013,8 +2025,8 @@ packages:
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yaml@2.8.2:
|
||||
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
|
||||
yaml@2.8.3:
|
||||
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
@@ -2909,7 +2921,7 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@vitejs/plugin-react@4.7.0(vite@7.3.1(jiti@1.21.7)(yaml@2.8.2))':
|
||||
'@vitejs/plugin-react@4.7.0(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
|
||||
@@ -2917,7 +2929,7 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.27
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.17.0
|
||||
vite: 7.3.1(jiti@1.21.7)(yaml@2.8.2)
|
||||
vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -3633,13 +3645,13 @@ snapshots:
|
||||
camelcase-css: 2.0.1
|
||||
postcss: 8.5.8
|
||||
|
||||
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(yaml@2.8.2):
|
||||
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(yaml@2.8.3):
|
||||
dependencies:
|
||||
lilconfig: 3.1.3
|
||||
optionalDependencies:
|
||||
jiti: 1.21.7
|
||||
postcss: 8.5.8
|
||||
yaml: 2.8.2
|
||||
yaml: 2.8.3
|
||||
|
||||
postcss-nested@6.2.0(postcss@8.5.8):
|
||||
dependencies:
|
||||
@@ -3855,11 +3867,11 @@ snapshots:
|
||||
|
||||
tailwind-merge@3.5.0: {}
|
||||
|
||||
tailwindcss-animate@1.0.7(tailwindcss@3.4.19(yaml@2.8.2)):
|
||||
tailwindcss-animate@1.0.7(tailwindcss@3.4.19(yaml@2.8.3)):
|
||||
dependencies:
|
||||
tailwindcss: 3.4.19(yaml@2.8.2)
|
||||
tailwindcss: 3.4.19(yaml@2.8.3)
|
||||
|
||||
tailwindcss@3.4.19(yaml@2.8.2):
|
||||
tailwindcss@3.4.19(yaml@2.8.3):
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
arg: 5.0.2
|
||||
@@ -3878,7 +3890,7 @@ snapshots:
|
||||
postcss: 8.5.8
|
||||
postcss-import: 15.1.0(postcss@8.5.8)
|
||||
postcss-js: 4.1.0(postcss@8.5.8)
|
||||
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8)(yaml@2.8.2)
|
||||
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8)(yaml@2.8.3)
|
||||
postcss-nested: 6.2.0(postcss@8.5.8)
|
||||
postcss-selector-parser: 6.1.2
|
||||
resolve: 1.22.11
|
||||
@@ -3980,7 +3992,7 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vite@7.3.1(jiti@1.21.7)(yaml@2.8.2):
|
||||
vite@7.3.1(jiti@1.21.7)(yaml@2.8.3):
|
||||
dependencies:
|
||||
esbuild: 0.27.3
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -3991,7 +4003,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
yaml: 2.8.2
|
||||
yaml: 2.8.3
|
||||
|
||||
warning@4.0.3:
|
||||
dependencies:
|
||||
@@ -3999,7 +4011,6 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yaml@2.8.2:
|
||||
optional: true
|
||||
yaml@2.8.3: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bot, Plus, Settings2, UserRound } from "lucide-react";
|
||||
import { Bot, Plus, Settings2, UserRound, Zap } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useRelayAgentsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { useChannelMembersQuery } from "@/features/channels/hooks";
|
||||
import { CreateWorkflowDialog } from "@/features/workflows/ui/CreateWorkflowDialog";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -47,6 +48,7 @@ export function ChannelMembersBar({
|
||||
onManageChannel,
|
||||
}: ChannelMembersBarProps) {
|
||||
const [isAddBotOpen, setIsAddBotOpen] = React.useState(false);
|
||||
const [isCreateWorkflowOpen, setIsCreateWorkflowOpen] = React.useState(false);
|
||||
const membersQuery = useChannelMembersQuery(channel.id);
|
||||
const providersQuery = useAcpProvidersQuery();
|
||||
const backendProvidersQuery = useBackendProvidersQuery();
|
||||
@@ -111,6 +113,7 @@ export function ChannelMembersBar({
|
||||
|
||||
previousChannelIdRef.current = channel.id;
|
||||
setIsAddBotOpen(false);
|
||||
setIsCreateWorkflowOpen(false);
|
||||
}, [channel.id]);
|
||||
|
||||
const dialogErrorMessage =
|
||||
@@ -154,6 +157,21 @@ export function ChannelMembersBar({
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
aria-label="Create workflow"
|
||||
className="h-9 w-9 rounded-full"
|
||||
data-testid="channel-create-workflow-trigger"
|
||||
disabled={!canAddAgents}
|
||||
onClick={() => {
|
||||
setIsCreateWorkflowOpen(true);
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
aria-label="Manage channel"
|
||||
className="h-9 w-9 rounded-full"
|
||||
@@ -167,6 +185,12 @@ export function ChannelMembersBar({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CreateWorkflowDialog
|
||||
channels={[channel]}
|
||||
onOpenChange={setIsCreateWorkflowOpen}
|
||||
open={isCreateWorkflowOpen}
|
||||
/>
|
||||
|
||||
<AddChannelBotDialog
|
||||
backendProviders={backendProvidersQuery.data ?? []}
|
||||
backendProvidersLoading={backendProvidersQuery.isLoading}
|
||||
|
||||
162
desktop/src/features/workflows/ui/ChannelCombobox.tsx
Normal file
162
desktop/src/features/workflows/ui/ChannelCombobox.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Check, ChevronsUpDown, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
|
||||
function formatChannelLabel(ch: Channel): string {
|
||||
return `${ch.name} · ${ch.channelType} · ${ch.visibility}`;
|
||||
}
|
||||
|
||||
type ChannelComboboxProps = {
|
||||
channels: Channel[];
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function ChannelCombobox({
|
||||
channels,
|
||||
disabled,
|
||||
id,
|
||||
onChange,
|
||||
value,
|
||||
}: ChannelComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
|
||||
|
||||
const selected = channels.find((c) => c.id === value);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
if (!query) return channels;
|
||||
const q = query.toLowerCase();
|
||||
return channels.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.channelType?.toLowerCase().includes(q) ||
|
||||
c.id.toLowerCase().includes(q),
|
||||
);
|
||||
}, [channels, query]);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setQuery("");
|
||||
setHighlightedIndex(0);
|
||||
}
|
||||
}
|
||||
|
||||
function selectChannel(channelId: string) {
|
||||
onChange(channelId);
|
||||
handleOpenChange(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (filtered.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown": {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((i) => (i + 1) % filtered.length);
|
||||
break;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((i) => (i - 1 + filtered.length) % filtered.length);
|
||||
break;
|
||||
}
|
||||
case "Enter": {
|
||||
e.preventDefault();
|
||||
const target = filtered[highlightedIndex];
|
||||
if (target) selectChannel(target.id);
|
||||
break;
|
||||
}
|
||||
case "Escape": {
|
||||
e.preventDefault();
|
||||
handleOpenChange(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={handleOpenChange} open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
!selected && "text-muted-foreground",
|
||||
)}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
role="combobox"
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selected ? formatChannelLabel(selected) : "Select a channel..."}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
ref={(el) => el?.focus()}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setHighlightedIndex(0);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search channels..."
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto p-1">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
No channels found.
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((channel, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
channel.id === value && "bg-accent/50",
|
||||
index === highlightedIndex &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
key={channel.id}
|
||||
onClick={() => selectChannel(channel.id)}
|
||||
type="button"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
channel.id === value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{channel.name}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
· {channel.channelType} · {channel.visibility}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,5 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useCreateWorkflowMutation } from "@/features/workflows/hooks";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { WorkflowDialog } from "./WorkflowDialog";
|
||||
|
||||
type CreateWorkflowDialogProps = {
|
||||
channels: Channel[];
|
||||
@@ -19,162 +7,17 @@ type CreateWorkflowDialogProps = {
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
const WORKFLOW_PLACEHOLDER = `name: ui_smoke_delay
|
||||
trigger:
|
||||
on: message_posted
|
||||
steps:
|
||||
- id: wait_two_seconds
|
||||
action: delay
|
||||
duration: 2s`;
|
||||
|
||||
export function CreateWorkflowDialog({
|
||||
channels,
|
||||
onOpenChange,
|
||||
open,
|
||||
}: CreateWorkflowDialogProps) {
|
||||
const [selectedChannelId, setSelectedChannelId] = React.useState(
|
||||
channels[0]?.id ?? "",
|
||||
);
|
||||
const [yamlDefinition, setYamlDefinition] = React.useState("");
|
||||
const createMutation = useCreateWorkflowMutation(selectedChannelId);
|
||||
const selectedChannel =
|
||||
channels.find((channel) => channel.id === selectedChannelId) ?? null;
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setSelectedChannelId(channels[0]?.id ?? "");
|
||||
setYamlDefinition("");
|
||||
createMutation.reset();
|
||||
}, [channels, createMutation]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
reset();
|
||||
}
|
||||
|
||||
onOpenChange(nextOpen);
|
||||
},
|
||||
[onOpenChange, reset],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && channels.length > 0 && !selectedChannelId) {
|
||||
setSelectedChannelId(channels[0].id);
|
||||
}
|
||||
}, [open, channels, selectedChannelId]);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!selectedChannelId || !yamlDefinition.trim()) return;
|
||||
|
||||
try {
|
||||
await createMutation.mutateAsync(yamlDefinition);
|
||||
handleOpenChange(false);
|
||||
} catch {
|
||||
// React Query stores the error; keep the dialog open and render it inline.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Workflow</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick a channel, paste YAML, and create a workflow scoped to that
|
||||
channel.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="block text-sm font-medium"
|
||||
htmlFor="wf-channel-select"
|
||||
>
|
||||
Channel
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
className="flex h-11 w-full appearance-none rounded-xl border border-border/70 bg-muted/20 px-3 pr-10 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={channels.length === 0 || createMutation.isPending}
|
||||
id="wf-channel-select"
|
||||
onChange={(event) => {
|
||||
createMutation.reset();
|
||||
setSelectedChannelId(event.target.value);
|
||||
}}
|
||||
value={selectedChannelId}
|
||||
>
|
||||
{channels.length === 0 ? (
|
||||
<option value="">No channels available</option>
|
||||
) : null}
|
||||
{channels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{channel.name} · {channel.channelType} ·{" "}
|
||||
{channel.visibility}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedChannel
|
||||
? `New workflows will belong to ${selectedChannel.name}.`
|
||||
: "Join or create a channel before adding a workflow."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="block text-sm font-medium"
|
||||
htmlFor="wf-yaml-editor"
|
||||
>
|
||||
YAML Definition
|
||||
</label>
|
||||
<Textarea
|
||||
className="min-h-[200px] resize-y font-mono text-xs"
|
||||
id="wf-yaml-editor"
|
||||
onChange={(event) => {
|
||||
createMutation.reset();
|
||||
setYamlDefinition(event.target.value);
|
||||
}}
|
||||
placeholder={WORKFLOW_PLACEHOLDER}
|
||||
value={yamlDefinition}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use <code>trigger.on</code> with values like{" "}
|
||||
<code>message_posted</code>, <code>reaction_added</code>,{" "}
|
||||
<code>webhook</code>, or <code>schedule</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createMutation.error instanceof Error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{createMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!selectedChannelId ||
|
||||
!yamlDefinition.trim() ||
|
||||
createMutation.isPending
|
||||
}
|
||||
onClick={handleCreate}
|
||||
type="button"
|
||||
>
|
||||
{createMutation.isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<WorkflowDialog
|
||||
channels={channels}
|
||||
mode="create"
|
||||
onOpenChange={onOpenChange}
|
||||
open={open}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Clock, MoreHorizontal, Play, Trash2, Zap } from "lucide-react";
|
||||
import {
|
||||
Clock,
|
||||
Copy,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Play,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Workflow } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -14,7 +22,9 @@ type WorkflowCardProps = {
|
||||
channelName?: string;
|
||||
onSelect: (workflowId: string) => void;
|
||||
onTrigger: (workflowId: string) => void;
|
||||
onDelete: (workflowId: string) => void;
|
||||
onEdit: (workflow: Workflow) => void;
|
||||
onDuplicate: (workflow: Workflow) => void;
|
||||
onDelete: (workflow: Workflow) => void;
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: Workflow["status"] }) {
|
||||
@@ -38,6 +48,8 @@ export function WorkflowCard({
|
||||
channelName,
|
||||
onSelect,
|
||||
onTrigger,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: WorkflowCardProps) {
|
||||
return (
|
||||
@@ -87,9 +99,17 @@ export function WorkflowCard({
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Trigger
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit(workflow)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onDuplicate(workflow)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDelete(workflow.id)}
|
||||
onClick={() => onDelete(workflow)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
|
||||
61
desktop/src/features/workflows/ui/WorkflowDeleteDialog.tsx
Normal file
61
desktop/src/features/workflows/ui/WorkflowDeleteDialog.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { Workflow } from "@/shared/api/types";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
type WorkflowDeleteDialogProps = {
|
||||
open: boolean;
|
||||
workflow: Workflow | null;
|
||||
onConfirm: (workflow: Workflow) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function WorkflowDeleteDialog({
|
||||
open,
|
||||
workflow,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
}: WorkflowDeleteDialogProps) {
|
||||
return (
|
||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete workflow?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{workflow
|
||||
? `Delete "${workflow.name}". This will stop all future triggers and remove the workflow permanently.`
|
||||
: "Delete this workflow."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (workflow) {
|
||||
onConfirm(workflow);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Play, X } from "lucide-react";
|
||||
import { Pencil, Play, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
@@ -8,16 +8,19 @@ import {
|
||||
useWorkflowRunsQuery,
|
||||
} from "@/features/workflows/hooks";
|
||||
import { WorkflowRunTrace } from "@/features/workflows/ui/WorkflowRunTrace";
|
||||
import type { Workflow } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
type WorkflowDetailPanelProps = {
|
||||
workflowId: string;
|
||||
onClose: () => void;
|
||||
onEdit: (workflow: Workflow) => void;
|
||||
};
|
||||
|
||||
export function WorkflowDetailPanel({
|
||||
workflowId,
|
||||
onClose,
|
||||
onEdit,
|
||||
}: WorkflowDetailPanelProps) {
|
||||
const workflowQuery = useWorkflowQuery(workflowId);
|
||||
const runsQuery = useWorkflowRunsQuery(workflowId);
|
||||
@@ -50,6 +53,16 @@ export function WorkflowDetailPanel({
|
||||
{workflow?.name ?? "Loading..."}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
{workflow ? (
|
||||
<Button
|
||||
onClick={() => onEdit(workflow)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={triggerMutation.isPending}
|
||||
onClick={() => void handleTrigger()}
|
||||
|
||||
220
desktop/src/features/workflows/ui/WorkflowDialog.tsx
Normal file
220
desktop/src/features/workflows/ui/WorkflowDialog.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import * as React from "react";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
|
||||
import {
|
||||
useCreateWorkflowMutation,
|
||||
useUpdateWorkflowMutation,
|
||||
} from "@/features/workflows/hooks";
|
||||
import type { Channel, Workflow } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { ChannelCombobox } from "./ChannelCombobox";
|
||||
import { WorkflowFormBuilder } from "./WorkflowFormBuilder";
|
||||
import { FieldLabel } from "./workflowFormPrimitives";
|
||||
|
||||
type DialogMode = "create" | "edit" | "duplicate";
|
||||
|
||||
type WorkflowDialogProps = {
|
||||
channels: Channel[];
|
||||
mode: DialogMode;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
workflow?: Workflow | null;
|
||||
};
|
||||
|
||||
function getInitialYaml(
|
||||
mode: DialogMode,
|
||||
workflow: Workflow | null | undefined,
|
||||
): string {
|
||||
if (!workflow) return "";
|
||||
const def = { ...workflow.definition };
|
||||
if (mode === "duplicate") {
|
||||
def.name = `${def.name ?? workflow.name} (copy)`;
|
||||
}
|
||||
return yamlStringify(def);
|
||||
}
|
||||
|
||||
const TITLES: Record<DialogMode, string> = {
|
||||
create: "Create Workflow",
|
||||
edit: "Edit Workflow",
|
||||
duplicate: "Duplicate Workflow",
|
||||
};
|
||||
|
||||
const SUBMIT_LABELS: Record<DialogMode, string> = {
|
||||
create: "Create",
|
||||
edit: "Save",
|
||||
duplicate: "Create Copy",
|
||||
};
|
||||
|
||||
const PENDING_LABELS: Record<DialogMode, string> = {
|
||||
create: "Creating...",
|
||||
edit: "Saving...",
|
||||
duplicate: "Creating...",
|
||||
};
|
||||
|
||||
export function WorkflowDialog({
|
||||
channels,
|
||||
mode,
|
||||
onOpenChange,
|
||||
open,
|
||||
workflow,
|
||||
}: WorkflowDialogProps) {
|
||||
const channelId =
|
||||
mode === "edit" && workflow?.channelId
|
||||
? workflow.channelId
|
||||
: (channels[0]?.id ?? "");
|
||||
|
||||
const [selectedChannelId, setSelectedChannelId] = React.useState(channelId);
|
||||
const [yamlDefinition, setYamlDefinition] = React.useState(() =>
|
||||
getInitialYaml(mode, workflow),
|
||||
);
|
||||
|
||||
const createMutation = useCreateWorkflowMutation(selectedChannelId);
|
||||
const updateMutation = useUpdateWorkflowMutation(workflow?.id ?? "");
|
||||
const mutation = mode === "edit" ? updateMutation : createMutation;
|
||||
|
||||
const selectedChannel =
|
||||
channels.find((c) => c.id === selectedChannelId) ?? null;
|
||||
|
||||
const defaultChannelId = channels[0]?.id ?? "";
|
||||
const workflowChannelId = workflow?.channelId ?? null;
|
||||
const resetCreate = createMutation.reset;
|
||||
const resetUpdate = updateMutation.reset;
|
||||
|
||||
// Re-initialize when dialog opens or workflow/mode changes
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
const newChannelId =
|
||||
mode === "edit" && workflowChannelId
|
||||
? workflowChannelId
|
||||
: defaultChannelId;
|
||||
setSelectedChannelId(newChannelId);
|
||||
setYamlDefinition(getInitialYaml(mode, workflow));
|
||||
resetCreate();
|
||||
resetUpdate();
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
mode,
|
||||
workflow,
|
||||
workflowChannelId,
|
||||
defaultChannelId,
|
||||
resetCreate,
|
||||
resetUpdate,
|
||||
]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
resetCreate();
|
||||
resetUpdate();
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
},
|
||||
[onOpenChange, resetCreate, resetUpdate],
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selectedChannelId || !yamlDefinition.trim()) return;
|
||||
|
||||
try {
|
||||
await mutation.mutateAsync(yamlDefinition);
|
||||
handleOpenChange(false);
|
||||
} catch {
|
||||
// React Query stores the error; keep the dialog open.
|
||||
}
|
||||
}
|
||||
|
||||
const showChannelSelector = mode !== "edit" && channels.length > 1;
|
||||
const showChannelInfo = mode !== "edit" && channels.length === 1;
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-lg">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{TITLES[mode]}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === "edit"
|
||||
? "Modify the workflow definition."
|
||||
: channels.length === 1
|
||||
? "Create a workflow scoped to this channel."
|
||||
: "Define a workflow and assign it to a channel."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
|
||||
{showChannelSelector ? (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-channel-select">Channel</FieldLabel>
|
||||
<ChannelCombobox
|
||||
channels={channels}
|
||||
disabled={mutation.isPending}
|
||||
id="wf-channel-select"
|
||||
onChange={(value) => {
|
||||
mutation.reset();
|
||||
setSelectedChannelId(value);
|
||||
}}
|
||||
value={selectedChannelId}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedChannel
|
||||
? `New workflows will belong to ${selectedChannel.name}.`
|
||||
: "Join or create a channel before adding a workflow."}
|
||||
</p>
|
||||
</div>
|
||||
) : (showChannelInfo || mode === "edit") && selectedChannel ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{mode === "edit"
|
||||
? "Editing workflow in"
|
||||
: "This workflow will be created in"}{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{selectedChannel.name}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<WorkflowFormBuilder
|
||||
disabled={mutation.isPending}
|
||||
onChange={(yaml) => {
|
||||
mutation.reset();
|
||||
setYamlDefinition(yaml);
|
||||
}}
|
||||
yaml={yamlDefinition}
|
||||
/>
|
||||
|
||||
{mutation.error instanceof Error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{mutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 justify-end gap-2 border-t border-border pt-4">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!selectedChannelId || !yamlDefinition.trim() || mutation.isPending
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
type="button"
|
||||
>
|
||||
{mutation.isPending ? PENDING_LABELS[mode] : SUBMIT_LABELS[mode]}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
325
desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx
Normal file
325
desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { Code, Plus } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { WorkflowStepCard } from "./WorkflowStepCard";
|
||||
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
|
||||
import {
|
||||
DEFAULT_FORM_STATE,
|
||||
TRIGGER_LABELS,
|
||||
TRIGGER_TYPES,
|
||||
formStateToYaml,
|
||||
nextStepId,
|
||||
yamlToFormState,
|
||||
} from "./workflowFormTypes";
|
||||
import type {
|
||||
StepFormState,
|
||||
TriggerConfig,
|
||||
TriggerType,
|
||||
WorkflowFormState,
|
||||
} from "./workflowFormTypes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trigger config fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TriggerConfigFields({
|
||||
trigger,
|
||||
onUpdate,
|
||||
}: {
|
||||
trigger: TriggerConfig;
|
||||
onUpdate: (trigger: TriggerConfig) => void;
|
||||
}) {
|
||||
switch (trigger.on) {
|
||||
case "message_posted":
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-trigger-filter">
|
||||
Filter expression (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
id="wf-trigger-filter"
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...trigger, filter: event.target.value })
|
||||
}
|
||||
placeholder='e.g. contains(text, "deploy")'
|
||||
value={trigger.filter ?? ""}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Evalexpr filter — leave empty to trigger on all messages.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case "reaction_added":
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-trigger-emoji">
|
||||
Emoji filter (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
id="wf-trigger-emoji"
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...trigger, emoji: event.target.value })
|
||||
}
|
||||
placeholder="e.g. thumbsup"
|
||||
value={trigger.emoji ?? ""}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to trigger on any reaction.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case "webhook":
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A unique webhook URL will be generated when the workflow is created.
|
||||
</p>
|
||||
);
|
||||
case "schedule":
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-trigger-cron">
|
||||
Cron expression (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
id="wf-trigger-cron"
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...trigger, cron: event.target.value })
|
||||
}
|
||||
placeholder="e.g. 0 9 * * 1-5 (weekdays at 9am UTC)"
|
||||
value={trigger.cron ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-trigger-interval">
|
||||
Interval (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
id="wf-trigger-interval"
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...trigger, interval: event.target.value })
|
||||
}
|
||||
placeholder="e.g. 1h, 30m"
|
||||
value={trigger.interval ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide either a cron expression or a simple interval.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type WorkflowFormBuilderProps = {
|
||||
disabled?: boolean;
|
||||
onChange: (yaml: string) => void;
|
||||
yaml: string;
|
||||
};
|
||||
|
||||
export function WorkflowFormBuilder({
|
||||
disabled,
|
||||
onChange,
|
||||
yaml,
|
||||
}: WorkflowFormBuilderProps) {
|
||||
// Parse once on mount instead of calling yamlToFormState three times
|
||||
const initialParseRef = React.useRef(yaml ? yamlToFormState(yaml) : null);
|
||||
const [mode, setMode] = React.useState<"form" | "yaml">(
|
||||
initialParseRef.current === null || initialParseRef.current.ok
|
||||
? "form"
|
||||
: "yaml",
|
||||
);
|
||||
const [formState, setFormState] = React.useState<WorkflowFormState>(
|
||||
initialParseRef.current?.ok
|
||||
? initialParseRef.current.state
|
||||
: DEFAULT_FORM_STATE,
|
||||
);
|
||||
const [parseError, setParseError] = React.useState<string | null>(
|
||||
initialParseRef.current !== null && !initialParseRef.current.ok
|
||||
? initialParseRef.current.error
|
||||
: null,
|
||||
);
|
||||
|
||||
const updateFormState = React.useCallback(
|
||||
(next: WorkflowFormState) => {
|
||||
setFormState(next);
|
||||
onChange(formStateToYaml(next));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleToggleMode = React.useCallback(() => {
|
||||
if (mode === "form") {
|
||||
setMode("yaml");
|
||||
setParseError(null);
|
||||
} else {
|
||||
const result = yamlToFormState(yaml);
|
||||
if (result.ok) {
|
||||
setFormState(result.state);
|
||||
setParseError(null);
|
||||
setMode("form");
|
||||
} else {
|
||||
setParseError(result.error);
|
||||
}
|
||||
}
|
||||
}, [mode, yaml]);
|
||||
|
||||
const addStep = React.useCallback(() => {
|
||||
updateFormState({
|
||||
...formState,
|
||||
steps: [
|
||||
...formState.steps,
|
||||
{ id: nextStepId(formState.steps), action: "delay" },
|
||||
],
|
||||
});
|
||||
}, [formState, updateFormState]);
|
||||
|
||||
const removeStep = React.useCallback(
|
||||
(index: number) => {
|
||||
updateFormState({
|
||||
...formState,
|
||||
steps: formState.steps.filter((_, i) => i !== index),
|
||||
});
|
||||
},
|
||||
[formState, updateFormState],
|
||||
);
|
||||
|
||||
const updateStep = React.useCallback(
|
||||
(index: number, step: StepFormState) => {
|
||||
const next = [...formState.steps];
|
||||
next[index] = step;
|
||||
updateFormState({ ...formState, steps: next });
|
||||
},
|
||||
[formState, updateFormState],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
disabled={disabled}
|
||||
onClick={handleToggleMode}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Code className="h-3.5 w-3.5" />
|
||||
{mode === "form" ? "Edit as YAML" : "Back to form"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{parseError ? (
|
||||
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
Cannot switch to form view: {parseError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{mode === "yaml" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Textarea
|
||||
className="min-h-[240px] resize-y font-mono text-xs"
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
value={yaml}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Edit the raw YAML definition directly.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-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-3">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-trigger-type">Trigger</FieldLabel>
|
||||
<FormSelect
|
||||
disabled={disabled}
|
||||
id="wf-trigger-type"
|
||||
onChange={(value) =>
|
||||
updateFormState({
|
||||
...formState,
|
||||
trigger: { on: value as TriggerType },
|
||||
})
|
||||
}
|
||||
value={formState.trigger.on}
|
||||
>
|
||||
{TRIGGER_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{TRIGGER_LABELS[type]}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
</div>
|
||||
<TriggerConfigFields
|
||||
onUpdate={(trigger) => updateFormState({ ...formState, trigger })}
|
||||
trigger={formState.trigger}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel>Steps</FieldLabel>
|
||||
<Button
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
disabled={disabled}
|
||||
onClick={addStep}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add step
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{formState.steps.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">
|
||||
No steps yet — add one to get started.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{formState.steps.map((step, index) => (
|
||||
<WorkflowStepCard
|
||||
index={index}
|
||||
key={step.id}
|
||||
onRemove={() => removeStep(index)}
|
||||
onUpdate={(updated) => updateStep(index, updated)}
|
||||
step={step}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
desktop/src/features/workflows/ui/WorkflowStepCard.tsx
Normal file
295
desktop/src/features/workflows/ui/WorkflowStepCard.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
|
||||
import { ACTION_LABELS, ACTION_TYPES } from "./workflowFormTypes";
|
||||
import type { ActionType, StepFormState } from "./workflowFormTypes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step action config fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StepConfigFields({
|
||||
step,
|
||||
prefix,
|
||||
onUpdate,
|
||||
}: {
|
||||
step: StepFormState;
|
||||
prefix: string;
|
||||
onUpdate: (step: StepFormState) => void;
|
||||
}) {
|
||||
switch (step.action) {
|
||||
case "delay":
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-duration`}>Duration</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
id={`${prefix}-duration`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, duration: event.target.value })
|
||||
}
|
||||
placeholder="e.g. 5s, 1m, 1h"
|
||||
value={step.duration ?? ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "send_message":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
|
||||
<Textarea
|
||||
className="min-h-[60px] resize-y text-xs"
|
||||
id={`${prefix}-text`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, text: event.target.value })
|
||||
}
|
||||
placeholder="e.g. Deployment started by {{trigger.author}}"
|
||||
value={step.text ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-channel`}>
|
||||
Channel override (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
id={`${prefix}-channel`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, channel: event.target.value })
|
||||
}
|
||||
placeholder="Channel UUID — leave empty for trigger channel"
|
||||
value={step.channel ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "send_dm":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-to`}>To (pubkey)</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
id={`${prefix}-to`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, to: event.target.value })
|
||||
}
|
||||
placeholder="e.g. {{trigger.author}} or hex pubkey"
|
||||
value={step.to ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-text`}>Message text</FieldLabel>
|
||||
<Textarea
|
||||
className="min-h-[60px] resize-y text-xs"
|
||||
id={`${prefix}-text`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, text: event.target.value })
|
||||
}
|
||||
placeholder="DM content"
|
||||
value={step.text ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "call_webhook":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-url`}>URL</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
id={`${prefix}-url`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, url: event.target.value })
|
||||
}
|
||||
placeholder="https://..."
|
||||
value={step.url ?? ""}
|
||||
/>
|
||||
{step.url && !step.url.startsWith("https://") ? (
|
||||
<p className="text-xs text-destructive">
|
||||
URL must start with https://
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-method`}>
|
||||
Method (optional)
|
||||
</FieldLabel>
|
||||
<FormSelect
|
||||
id={`${prefix}-method`}
|
||||
onChange={(value) => onUpdate({ ...step, method: value })}
|
||||
value={step.method ?? "POST"}
|
||||
>
|
||||
<option value="POST">POST</option>
|
||||
<option value="GET">GET</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
</FormSelect>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-body`}>Body (optional)</FieldLabel>
|
||||
<Textarea
|
||||
className="min-h-[60px] resize-y font-mono text-xs"
|
||||
id={`${prefix}-body`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, body: event.target.value })
|
||||
}
|
||||
placeholder='{"key": "{{trigger.text}}"}'
|
||||
value={step.body ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "request_approval":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-from`}>From (approver)</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
id={`${prefix}-from`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, from: event.target.value })
|
||||
}
|
||||
placeholder="Pubkey or role"
|
||||
value={step.from ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-message`}>Message</FieldLabel>
|
||||
<Input
|
||||
id={`${prefix}-message`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, message: event.target.value })
|
||||
}
|
||||
placeholder="Approval request message"
|
||||
value={step.message ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-timeout`}>
|
||||
Timeout (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`${prefix}-timeout`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, timeout: event.target.value })
|
||||
}
|
||||
placeholder="e.g. 24h"
|
||||
value={step.timeout ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "add_reaction":
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-emoji`}>Emoji</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
id={`${prefix}-emoji`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, emoji: event.target.value })
|
||||
}
|
||||
placeholder="e.g. thumbsup"
|
||||
value={step.emoji ?? ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "set_channel_topic":
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-topic`}>Topic</FieldLabel>
|
||||
<Input
|
||||
id={`${prefix}-topic`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, topic: event.target.value })
|
||||
}
|
||||
placeholder="New channel topic"
|
||||
value={step.topic ?? ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function WorkflowStepCard({
|
||||
index,
|
||||
onRemove,
|
||||
onUpdate,
|
||||
step,
|
||||
}: {
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
onUpdate: (step: StepFormState) => void;
|
||||
step: StepFormState;
|
||||
}) {
|
||||
const prefix = `wf-step-${index}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-border/70 bg-muted/10 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Step {index + 1}
|
||||
</span>
|
||||
<Button
|
||||
aria-label="Remove step"
|
||||
className="h-7 w-7"
|
||||
onClick={onRemove}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-id`}>Step ID</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
id={`${prefix}-id`}
|
||||
onChange={(event) => onUpdate({ ...step, id: event.target.value })}
|
||||
placeholder="unique_step_id"
|
||||
value={step.id}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-action`}>Action</FieldLabel>
|
||||
<FormSelect
|
||||
id={`${prefix}-action`}
|
||||
onChange={(value) => {
|
||||
const next = { ...step, action: value as ActionType };
|
||||
if (value === "call_webhook" && !next.method) {
|
||||
next.method = "POST";
|
||||
}
|
||||
onUpdate(next);
|
||||
}}
|
||||
value={step.action}
|
||||
>
|
||||
{ACTION_TYPES.map((action) => (
|
||||
<option key={action} value={action}>
|
||||
{ACTION_LABELS[action]}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepConfigFields onUpdate={onUpdate} prefix={prefix} step={step} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,10 @@ import * as React from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { allWorkflowsQueryKey } from "@/features/workflows/hooks";
|
||||
import { CreateWorkflowDialog } from "@/features/workflows/ui/CreateWorkflowDialog";
|
||||
import { WorkflowCard } from "@/features/workflows/ui/WorkflowCard";
|
||||
import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog";
|
||||
import { WorkflowDetailPanel } from "@/features/workflows/ui/WorkflowDetailPanel";
|
||||
import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog";
|
||||
import type { Channel, Workflow } from "@/shared/api/types";
|
||||
import {
|
||||
deleteWorkflow,
|
||||
@@ -23,8 +24,17 @@ type WorkflowWithChannel = {
|
||||
channelName: string;
|
||||
};
|
||||
|
||||
type DialogState =
|
||||
| { mode: "closed" }
|
||||
| { mode: "create" }
|
||||
| { mode: "edit"; workflow: Workflow }
|
||||
| { mode: "duplicate"; workflow: Workflow };
|
||||
|
||||
export function WorkflowsView({ channels }: WorkflowsViewProps) {
|
||||
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
|
||||
const [dialogState, setDialogState] = React.useState<DialogState>({
|
||||
mode: "closed",
|
||||
});
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<Workflow | null>(null);
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -78,16 +88,42 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const triggerOne = triggerMutation.mutate;
|
||||
const handleTrigger = React.useCallback(
|
||||
(workflowId: string) => triggerMutation.mutate(workflowId),
|
||||
[triggerMutation],
|
||||
(workflowId: string) => triggerOne(workflowId),
|
||||
[triggerOne],
|
||||
);
|
||||
|
||||
const handleDelete = React.useCallback(
|
||||
(workflowId: string) => deleteMutation.mutate(workflowId),
|
||||
[deleteMutation],
|
||||
(workflow: Workflow) => setDeleteTarget(workflow),
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteOne = deleteMutation.mutate;
|
||||
const handleConfirmDelete = React.useCallback(
|
||||
(workflow: Workflow) => {
|
||||
deleteOne(workflow.id);
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
[deleteOne],
|
||||
);
|
||||
|
||||
const handleEdit = React.useCallback(
|
||||
(workflow: Workflow) => setDialogState({ mode: "edit", workflow }),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDuplicate = React.useCallback(
|
||||
(workflow: Workflow) => setDialogState({ mode: "duplicate", workflow }),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDialogOpenChange = React.useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setDialogState({ mode: "closed" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 overflow-hidden"
|
||||
@@ -109,7 +145,7 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateOpen(true)} size="sm">
|
||||
<Button onClick={() => setDialogState({ mode: "create" })} size="sm">
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Create Workflow
|
||||
</Button>
|
||||
@@ -137,7 +173,7 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
|
||||
<Zap className="h-10 w-10 opacity-30" />
|
||||
<p className="text-sm">No workflows yet</p>
|
||||
<Button
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
onClick={() => setDialogState({ mode: "create" })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
@@ -152,6 +188,8 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
|
||||
channelName={channelName}
|
||||
key={workflow.id}
|
||||
onDelete={handleDelete}
|
||||
onDuplicate={handleDuplicate}
|
||||
onEdit={handleEdit}
|
||||
onSelect={setSelectedWorkflowId}
|
||||
onTrigger={handleTrigger}
|
||||
workflow={workflow}
|
||||
@@ -166,15 +204,31 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
|
||||
<WorkflowDetailPanel
|
||||
key={selectedWorkflowId}
|
||||
onClose={() => setSelectedWorkflowId(null)}
|
||||
onEdit={handleEdit}
|
||||
workflowId={selectedWorkflowId}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CreateWorkflowDialog
|
||||
<WorkflowDialog
|
||||
channels={memberChannels}
|
||||
onOpenChange={setIsCreateOpen}
|
||||
open={isCreateOpen}
|
||||
mode={dialogState.mode === "closed" ? "create" : dialogState.mode}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
open={dialogState.mode !== "closed"}
|
||||
workflow={
|
||||
dialogState.mode === "edit" || dialogState.mode === "duplicate"
|
||||
? dialogState.workflow
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
<WorkflowDeleteDialog
|
||||
onConfirm={handleConfirmDelete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
open={deleteTarget !== null}
|
||||
workflow={deleteTarget}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
48
desktop/src/features/workflows/ui/workflowFormPrimitives.tsx
Normal file
48
desktop/src/features/workflows/ui/workflowFormPrimitives.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
export function FormSelect({
|
||||
children,
|
||||
disabled,
|
||||
id,
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
className="flex h-9 w-full appearance-none rounded-md border border-input bg-transparent px-3 pr-8 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
value={value}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldLabel({
|
||||
children,
|
||||
htmlFor,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
htmlFor?: string;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className="block text-xs font-medium text-muted-foreground"
|
||||
htmlFor={htmlFor}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
221
desktop/src/features/workflows/ui/workflowFormTypes.ts
Normal file
221
desktop/src/features/workflows/ui/workflowFormTypes.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { stringify as yamlStringify, parse as yamlParse } from "yaml";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TRIGGER_TYPES = [
|
||||
"message_posted",
|
||||
"reaction_added",
|
||||
"webhook",
|
||||
"schedule",
|
||||
] as const;
|
||||
export type TriggerType = (typeof TRIGGER_TYPES)[number];
|
||||
|
||||
export const ACTION_TYPES = [
|
||||
"delay",
|
||||
"send_message",
|
||||
"send_dm",
|
||||
"call_webhook",
|
||||
"request_approval",
|
||||
"add_reaction",
|
||||
"set_channel_topic",
|
||||
] as const;
|
||||
export type ActionType = (typeof ACTION_TYPES)[number];
|
||||
|
||||
export type TriggerConfig = {
|
||||
on: TriggerType;
|
||||
filter?: string;
|
||||
emoji?: string;
|
||||
cron?: string;
|
||||
interval?: string;
|
||||
};
|
||||
|
||||
export type StepFormState = {
|
||||
id: string;
|
||||
action: ActionType;
|
||||
duration?: string;
|
||||
text?: string;
|
||||
channel?: string;
|
||||
to?: string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
body?: string;
|
||||
emoji?: string;
|
||||
topic?: string;
|
||||
from?: string;
|
||||
message?: string;
|
||||
timeout?: string;
|
||||
};
|
||||
|
||||
export type WorkflowFormState = {
|
||||
name: string;
|
||||
trigger: TriggerConfig;
|
||||
steps: StepFormState[];
|
||||
};
|
||||
|
||||
export const DEFAULT_FORM_STATE: WorkflowFormState = {
|
||||
name: "",
|
||||
trigger: { on: "message_posted" },
|
||||
steps: [],
|
||||
};
|
||||
|
||||
export const TRIGGER_LABELS: Record<TriggerType, string> = {
|
||||
message_posted: "Message Posted",
|
||||
reaction_added: "Reaction Added",
|
||||
webhook: "Webhook",
|
||||
schedule: "Schedule",
|
||||
};
|
||||
|
||||
export const ACTION_LABELS: Record<ActionType, string> = {
|
||||
delay: "Delay",
|
||||
send_message: "Send Message",
|
||||
send_dm: "Send DM",
|
||||
call_webhook: "Call Webhook",
|
||||
request_approval: "Request Approval",
|
||||
add_reaction: "Add Reaction",
|
||||
set_channel_topic: "Set Channel Topic",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serialization helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
|
||||
const fields: Record<string, unknown> = {};
|
||||
switch (step.action) {
|
||||
case "delay":
|
||||
if (step.duration) fields.duration = step.duration;
|
||||
break;
|
||||
case "send_message":
|
||||
if (step.text) fields.text = step.text;
|
||||
if (step.channel) fields.channel = step.channel;
|
||||
break;
|
||||
case "send_dm":
|
||||
if (step.to) fields.to = step.to;
|
||||
if (step.text) fields.text = step.text;
|
||||
break;
|
||||
case "call_webhook":
|
||||
if (step.url) fields.url = step.url;
|
||||
fields.method = step.method || "POST";
|
||||
if (step.body) fields.body = step.body;
|
||||
break;
|
||||
case "request_approval":
|
||||
if (step.from) fields.from = step.from;
|
||||
if (step.message) fields.message = step.message;
|
||||
if (step.timeout) fields.timeout = step.timeout;
|
||||
break;
|
||||
case "add_reaction":
|
||||
if (step.emoji) fields.emoji = step.emoji;
|
||||
break;
|
||||
case "set_channel_topic":
|
||||
if (step.topic) fields.topic = step.topic;
|
||||
break;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function formStateToYaml(state: WorkflowFormState): string {
|
||||
const trigger: Record<string, unknown> = { on: state.trigger.on };
|
||||
if (state.trigger.on === "message_posted" && state.trigger.filter) {
|
||||
trigger.filter = state.trigger.filter;
|
||||
}
|
||||
if (state.trigger.on === "reaction_added" && state.trigger.emoji) {
|
||||
trigger.emoji = state.trigger.emoji;
|
||||
}
|
||||
if (state.trigger.on === "schedule") {
|
||||
if (state.trigger.cron) trigger.cron = state.trigger.cron;
|
||||
if (state.trigger.interval) trigger.interval = state.trigger.interval;
|
||||
}
|
||||
|
||||
const steps = state.steps.map((step) => ({
|
||||
id: step.id,
|
||||
action: step.action,
|
||||
...actionFieldsForStep(step),
|
||||
}));
|
||||
|
||||
return yamlStringify({ name: state.name, trigger, steps });
|
||||
}
|
||||
|
||||
const STEP_ID_PATTERN = /^step_(\d+)$/;
|
||||
|
||||
export function nextStepId(existingSteps: StepFormState[]): string {
|
||||
const existingIds = new Set(existingSteps.map((s) => s.id));
|
||||
let maxN = 0;
|
||||
for (const id of existingIds) {
|
||||
const match = STEP_ID_PATTERN.exec(id);
|
||||
if (match) maxN = Math.max(maxN, Number(match[1]));
|
||||
}
|
||||
let n = maxN + 1;
|
||||
while (existingIds.has(`step_${n}`)) n++;
|
||||
return `step_${n}`;
|
||||
}
|
||||
|
||||
export function yamlToFormState(
|
||||
yaml: string,
|
||||
): { ok: true; state: WorkflowFormState } | { ok: false; error: string } {
|
||||
try {
|
||||
const parsed = yamlParse(yaml);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { ok: false, error: "YAML must be an object" };
|
||||
}
|
||||
|
||||
const triggerOn = parsed.trigger?.on;
|
||||
if (triggerOn && !TRIGGER_TYPES.includes(triggerOn as TriggerType)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unsupported trigger type "${triggerOn}" — use the YAML editor`,
|
||||
};
|
||||
}
|
||||
const trigger: TriggerConfig = {
|
||||
on: (triggerOn as TriggerType) ?? TRIGGER_TYPES[0],
|
||||
filter: parsed.trigger?.filter ?? undefined,
|
||||
emoji: parsed.trigger?.emoji ?? undefined,
|
||||
cron: parsed.trigger?.cron ?? undefined,
|
||||
interval: parsed.trigger?.interval ?? undefined,
|
||||
};
|
||||
|
||||
const rawSteps = parsed.steps ?? [];
|
||||
if (!Array.isArray(rawSteps)) {
|
||||
return { ok: false, error: "steps must be a list" };
|
||||
}
|
||||
|
||||
for (const step of rawSteps) {
|
||||
if (step.action && !ACTION_TYPES.includes(step.action as ActionType)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unsupported action type "${step.action}" — use the YAML editor`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const steps: StepFormState[] = rawSteps.map(
|
||||
(step: Record<string, unknown>, index: number) => ({
|
||||
id: (step.id as string) ?? `step_${index + 1}`,
|
||||
action: (step.action as ActionType) ?? ACTION_TYPES[0],
|
||||
duration: step.duration as string | undefined,
|
||||
text: step.text as string | undefined,
|
||||
channel: step.channel as string | undefined,
|
||||
to: step.to as string | undefined,
|
||||
url: step.url as string | undefined,
|
||||
method: step.method as string | undefined,
|
||||
body: step.body as string | undefined,
|
||||
emoji: step.emoji as string | undefined,
|
||||
topic: step.topic as string | undefined,
|
||||
from: step.from as string | undefined,
|
||||
message: step.message as string | undefined,
|
||||
timeout: step.timeout as string | undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
state: { name: (parsed.name as string) ?? "", trigger, steps },
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : "Invalid YAML",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -999,6 +999,95 @@ let mockRelayAgents: RawRelayAgent[] = [
|
||||
status: "away",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Workflow mocks ─────────────────────────────────────────────────────────
|
||||
|
||||
type MockWorkflow = {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_pubkey: string;
|
||||
channel_id: string | null;
|
||||
definition: Record<string, unknown>;
|
||||
status: "active" | "disabled" | "archived";
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
const mockWorkflows: MockWorkflow[] = [];
|
||||
let mockWorkflowIdCounter = 0;
|
||||
|
||||
function handleGetChannelWorkflows(args: { channelId: string }) {
|
||||
return mockWorkflows.filter((w) => w.channel_id === args.channelId);
|
||||
}
|
||||
|
||||
function handleGetWorkflow(args: { workflowId: string }) {
|
||||
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
|
||||
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
|
||||
return workflow;
|
||||
}
|
||||
|
||||
function handleCreateWorkflow(args: {
|
||||
channelId: string;
|
||||
yamlDefinition: string;
|
||||
}) {
|
||||
mockWorkflowIdCounter += 1;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Parse the name from the YAML definition (simple extraction)
|
||||
const nameMatch = args.yamlDefinition.match(/^name:\s*(.+)$/m);
|
||||
const name = nameMatch
|
||||
? nameMatch[1].trim()
|
||||
: `workflow_${mockWorkflowIdCounter}`;
|
||||
const workflow: MockWorkflow = {
|
||||
id: `mock-wf-${mockWorkflowIdCounter}`,
|
||||
name,
|
||||
owner_pubkey: MOCK_IDENTITY_PUBKEY,
|
||||
channel_id: args.channelId,
|
||||
definition: { raw: args.yamlDefinition },
|
||||
status: "active",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
mockWorkflows.push(workflow);
|
||||
return workflow;
|
||||
}
|
||||
|
||||
function handleUpdateWorkflow(args: {
|
||||
workflowId: string;
|
||||
yamlDefinition: string;
|
||||
}) {
|
||||
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
|
||||
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
|
||||
const nameMatch = args.yamlDefinition.match(/^name:\s*(.+)$/m);
|
||||
if (nameMatch) workflow.name = nameMatch[1].trim();
|
||||
workflow.definition = { raw: args.yamlDefinition };
|
||||
workflow.updated_at = Math.floor(Date.now() / 1000);
|
||||
return workflow;
|
||||
}
|
||||
|
||||
function handleDeleteWorkflow(args: { workflowId: string }) {
|
||||
const index = mockWorkflows.findIndex((w) => w.id === args.workflowId);
|
||||
if (index === -1) throw new Error(`Workflow ${args.workflowId} not found`);
|
||||
mockWorkflows.splice(index, 1);
|
||||
}
|
||||
|
||||
function handleTriggerWorkflow(args: { workflowId: string }) {
|
||||
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
|
||||
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
|
||||
return {
|
||||
run_id: `mock-run-${Date.now()}`,
|
||||
workflow_id: workflow.id,
|
||||
status: "pending",
|
||||
};
|
||||
}
|
||||
|
||||
function handleGetWorkflowRuns(_args: { workflowId: string }) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function handleGetRunApprovals(_args: { workflowId: string; runId: string }) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const mockProfiles = new Map<string, RawProfile>([
|
||||
[
|
||||
MOCK_IDENTITY_PUBKEY,
|
||||
@@ -3552,6 +3641,38 @@ export function maybeInstallE2eTauriMocks() {
|
||||
}
|
||||
|
||||
return disconnectMockSocket((payload as { id: number }).id);
|
||||
case "get_channel_workflows":
|
||||
return handleGetChannelWorkflows(
|
||||
payload as Parameters<typeof handleGetChannelWorkflows>[0],
|
||||
);
|
||||
case "get_workflow":
|
||||
return handleGetWorkflow(
|
||||
payload as Parameters<typeof handleGetWorkflow>[0],
|
||||
);
|
||||
case "create_workflow":
|
||||
return handleCreateWorkflow(
|
||||
payload as Parameters<typeof handleCreateWorkflow>[0],
|
||||
);
|
||||
case "update_workflow":
|
||||
return handleUpdateWorkflow(
|
||||
payload as Parameters<typeof handleUpdateWorkflow>[0],
|
||||
);
|
||||
case "delete_workflow":
|
||||
return handleDeleteWorkflow(
|
||||
payload as Parameters<typeof handleDeleteWorkflow>[0],
|
||||
);
|
||||
case "trigger_workflow":
|
||||
return handleTriggerWorkflow(
|
||||
payload as Parameters<typeof handleTriggerWorkflow>[0],
|
||||
);
|
||||
case "get_workflow_runs":
|
||||
return handleGetWorkflowRuns(
|
||||
payload as Parameters<typeof handleGetWorkflowRuns>[0],
|
||||
);
|
||||
case "get_run_approvals":
|
||||
return handleGetRunApprovals(
|
||||
payload as Parameters<typeof handleGetRunApprovals>[0],
|
||||
);
|
||||
case "plugin:webview|set_webview_zoom":
|
||||
window.__SPROUT_E2E_WEBVIEW_ZOOM__ = (
|
||||
payload as { value: number }
|
||||
|
||||
@@ -102,7 +102,10 @@ test("create stream with name and description", async ({ page }) => {
|
||||
await page
|
||||
.getByTestId("create-stream-description")
|
||||
.fill("A stream for testing channel creation");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await page
|
||||
.getByTestId("create-stream-form")
|
||||
.getByRole("button", { name: "Create" })
|
||||
.click();
|
||||
|
||||
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
@@ -117,7 +120,10 @@ test("create stream with special characters", async ({ page }) => {
|
||||
await page
|
||||
.getByTestId("create-stream-description")
|
||||
.fill("Stream with spaces and hyphens");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await page
|
||||
.getByTestId("create-stream-form")
|
||||
.getByRole("button", { name: "Create" })
|
||||
.click();
|
||||
|
||||
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
@@ -456,7 +462,10 @@ test("manage channel can delete an owned stream", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelName);
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await page
|
||||
.getByTestId("create-stream-form")
|
||||
.getByRole("button", { name: "Create" })
|
||||
.click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
@@ -477,7 +486,10 @@ test("canceling channel deletion keeps the owned stream", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelName);
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await page
|
||||
.getByTestId("create-stream-form")
|
||||
.getByRole("button", { name: "Create" })
|
||||
.click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
|
||||
@@ -13,7 +13,10 @@ async function createStream(
|
||||
if (description !== undefined) {
|
||||
await page.getByTestId("create-stream-description").fill(description);
|
||||
}
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await page
|
||||
.getByTestId("create-stream-form")
|
||||
.getByRole("button", { name: "Create" })
|
||||
.click();
|
||||
|
||||
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
@@ -40,7 +43,10 @@ test("create channel and verify in sidebar", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelName);
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await page
|
||||
.getByTestId("create-stream-form")
|
||||
.getByRole("button", { name: "Create" })
|
||||
.click();
|
||||
|
||||
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
@@ -64,7 +70,10 @@ test("two users see the same channel", async ({
|
||||
await pageOne.goto("/");
|
||||
await pageOne.getByRole("button", { name: "Create a stream" }).click();
|
||||
await pageOne.getByTestId("create-stream-name").fill(channelName);
|
||||
await pageOne.getByRole("button", { name: "Create" }).click();
|
||||
await pageOne
|
||||
.getByTestId("create-stream-form")
|
||||
.getByRole("button", { name: "Create" })
|
||||
.click();
|
||||
await expect(pageOne.getByTestId("stream-list")).toContainText(channelName);
|
||||
|
||||
await pageTwo.goto("/");
|
||||
@@ -166,13 +175,19 @@ test("multiple channels independent", async ({ page }) => {
|
||||
// Create channel A
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelA);
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await page
|
||||
.getByTestId("create-stream-form")
|
||||
.getByRole("button", { name: "Create" })
|
||||
.click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelA);
|
||||
|
||||
// Create channel B
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelB);
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await page
|
||||
.getByTestId("create-stream-form")
|
||||
.getByRole("button", { name: "Create" })
|
||||
.click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelB);
|
||||
|
||||
// Navigate to channel A and send a message
|
||||
|
||||
@@ -62,7 +62,10 @@ test("creates a new mocked stream", async ({ page }) => {
|
||||
await page
|
||||
.getByTestId("create-stream-description")
|
||||
.fill("Release coordination");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await page
|
||||
.getByTestId("create-stream-form")
|
||||
.getByRole("button", { name: "Create" })
|
||||
.click();
|
||||
|
||||
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
|
||||
@@ -52,7 +52,10 @@ async function createAndJoinSharedStream(
|
||||
) {
|
||||
await ownerPage.getByRole("button", { name: "Create a stream" }).click();
|
||||
await ownerPage.getByTestId("create-stream-name").fill(channelName);
|
||||
await ownerPage.getByRole("button", { name: "Create" }).click();
|
||||
await ownerPage
|
||||
.getByTestId("create-stream-form")
|
||||
.getByRole("button", { name: "Create" })
|
||||
.click();
|
||||
await expect(ownerPage.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(ownerPage.getByTestId("chat-title")).toHaveText(channelName);
|
||||
|
||||
@@ -121,7 +124,10 @@ test("creates a relay-backed stream", async ({ page }) => {
|
||||
await page
|
||||
.getByTestId("create-stream-description")
|
||||
.fill("Created from Playwright");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await page
|
||||
.getByTestId("create-stream-form")
|
||||
.getByRole("button", { name: "Create" })
|
||||
.click();
|
||||
|
||||
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
|
||||
157
desktop/tests/e2e/workflows.spec.ts
Normal file
157
desktop/tests/e2e/workflows.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
|
||||
async function navigateToWorkflows(page: import("@playwright/test").Page) {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-workflows-view").click();
|
||||
await expect(page.getByTestId("workflows-view")).toBeVisible();
|
||||
}
|
||||
|
||||
async function createWorkflow(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
) {
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
|
||||
// Fill the form — name field
|
||||
await page.getByLabel("Workflow name").fill(name);
|
||||
|
||||
// Add a step
|
||||
await page.getByRole("button", { name: "Add step" }).click();
|
||||
|
||||
// Submit
|
||||
await page
|
||||
.getByRole("dialog")
|
||||
.getByRole("button", { name: "Create" })
|
||||
.click();
|
||||
|
||||
// Wait for dialog to close
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
}
|
||||
|
||||
test("navigates to workflows view and shows empty state", async ({ page }) => {
|
||||
await navigateToWorkflows(page);
|
||||
|
||||
await expect(page.getByText("No workflows yet")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Create your first workflow" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("creates a workflow via the form builder", async ({ page }) => {
|
||||
const workflowName = `test_workflow_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
|
||||
// Verify workflow appears in the list
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
|
||||
});
|
||||
|
||||
test("edits an existing workflow", async ({ page }) => {
|
||||
const originalName = `edit_test_${Date.now()}`;
|
||||
const updatedName = `${originalName}_updated`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, originalName);
|
||||
|
||||
// Verify it exists
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(originalName);
|
||||
|
||||
// Open the dropdown menu and click Edit
|
||||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
|
||||
// Dialog should open in edit mode
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByText("Edit Workflow")).toBeVisible();
|
||||
|
||||
// Change the name
|
||||
const nameInput = page.getByLabel("Workflow name");
|
||||
await nameInput.clear();
|
||||
await nameInput.fill(updatedName);
|
||||
|
||||
// Save
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
|
||||
// Verify the updated name appears
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(updatedName);
|
||||
});
|
||||
|
||||
test("duplicates a workflow", async ({ page }) => {
|
||||
const originalName = `dup_test_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, originalName);
|
||||
|
||||
// Open the dropdown menu and click Duplicate
|
||||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||||
await page.getByRole("menuitem", { name: "Duplicate" }).click();
|
||||
|
||||
// Dialog should open in duplicate mode with "(copy)" suffix
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByText("Duplicate Workflow")).toBeVisible();
|
||||
|
||||
// Submit the duplicate
|
||||
await page.getByRole("button", { name: "Create Copy" }).click();
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
|
||||
// Both the original and copy should exist
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(originalName);
|
||||
});
|
||||
|
||||
test("deletes a workflow with confirmation", async ({ page }) => {
|
||||
const workflowName = `delete_test_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
|
||||
// Verify it exists
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
|
||||
|
||||
// Open the dropdown menu and click Delete
|
||||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
|
||||
// Confirmation dialog should appear with workflow name
|
||||
await expect(page.getByRole("alertdialog")).toBeVisible();
|
||||
await expect(page.getByRole("alertdialog")).toContainText(workflowName);
|
||||
|
||||
// Confirm deletion
|
||||
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();
|
||||
});
|
||||
|
||||
test("triggers a workflow from the detail panel", async ({ page }) => {
|
||||
const workflowName = `trigger_test_${Date.now()}`;
|
||||
|
||||
await navigateToWorkflows(page);
|
||||
await createWorkflow(page, workflowName);
|
||||
|
||||
// Click on the workflow card to open the detail panel
|
||||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||||
await expect(page.getByTestId("workflow-detail-panel")).toBeVisible();
|
||||
|
||||
// Click the Trigger button
|
||||
await page
|
||||
.getByTestId("workflow-detail-panel")
|
||||
.getByRole("button", { name: "Trigger" })
|
||||
.click();
|
||||
|
||||
// Wait for the trigger to complete (button text changes back from "Triggering...")
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workflow-detail-panel")
|
||||
.getByRole("button", { name: "Trigger" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
Reference in New Issue
Block a user