Polish workflow condition selectors

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-13 21:45:13 -07:00
parent c463f043e4
commit 13c9c73ffa
9 changed files with 673 additions and 59 deletions
@@ -0,0 +1,349 @@
import { Check, Search } from "lucide-react";
import * as React from "react";
import { useRelayMembersQuery } from "@/features/community-members/hooks";
import {
useFlattenedUserSearchResults,
useInfiniteUserSearchQuery,
useUsersBatchQuery,
} from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { UserProfileSummary, UserSearchResult } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { parsePubkeyInput } from "@/shared/lib/nostrUtils";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { PortalledScrollArea } from "@/shared/ui/PortalledScrollArea";
const AUTHOR_PAGE_SIZE = 50;
function authorLabel(
pubkey: string,
profile?: Pick<UserProfileSummary, "displayName" | "nip05Handle"> | null,
) {
return (
profile?.displayName?.trim() ||
profile?.nip05Handle?.trim() ||
truncatePubkey(pubkey)
);
}
function toSearchResult(
pubkey: string,
profile?: UserProfileSummary | null,
): UserSearchResult {
return {
pubkey,
displayName: profile?.displayName ?? profile?.name ?? null,
avatarUrl: profile?.avatarUrl ?? null,
nip05Handle: profile?.nip05Handle ?? null,
ownerPubkey: profile?.ownerPubkey ?? null,
isAgent: profile?.isAgent ?? false,
};
}
function matchesPubkeyPrefix(pubkey: string, query: string) {
return (
query.length >= 8 &&
/^[0-9a-f]+$/i.test(query) &&
pubkey.startsWith(query.toLowerCase())
);
}
export function AuthorGridPicker({
ariaLabel = "Author pubkey",
disabled,
id,
knownPubkeys = [],
onChange,
value,
}: {
ariaLabel?: string;
disabled?: boolean;
id?: string;
knownPubkeys?: string[];
onChange: (value: string) => void;
value: string;
}) {
const [query, setQuery] = React.useState("");
const [visibleMemberCount, setVisibleMemberCount] =
React.useState(AUTHOR_PAGE_SIZE);
const deferredQuery = React.useDeferredValue(query.trim());
const normalizedValue = parsePubkeyInput(value);
const membersQuery = useRelayMembersQuery(true);
const memberPubkeys = React.useMemo(
() => [
...new Set(
[
...knownPubkeys,
...(membersQuery.data ?? []).map((member) => member.pubkey),
]
.map((pubkey) => pubkey.trim().toLowerCase())
.filter((pubkey) => /^[0-9a-f]{64}$/.test(pubkey)),
),
],
[knownPubkeys, membersQuery.data],
);
const visibleMemberPubkeys = React.useMemo(
() => memberPubkeys.slice(0, visibleMemberCount),
[memberPubkeys, visibleMemberCount],
);
const visibleProfilesQuery = useUsersBatchQuery(visibleMemberPubkeys);
const visibleMemberResults = React.useMemo(
() =>
visibleMemberPubkeys.map((pubkey) =>
toSearchResult(pubkey, visibleProfilesQuery.data?.profiles[pubkey]),
),
[visibleMemberPubkeys, visibleProfilesQuery.data?.profiles],
);
const directorySearchQuery = useInfiniteUserSearchQuery(deferredQuery, {
allowEmpty: true,
enabled: true,
limit: AUTHOR_PAGE_SIZE,
});
const directoryResults = useFlattenedUserSearchResults(
directorySearchQuery.data,
);
const searchResults = React.useMemo(() => {
if (deferredQuery.length === 0) {
const resultsByPubkey = new Map<string, UserSearchResult>();
for (const result of visibleMemberResults) {
resultsByPubkey.set(result.pubkey.toLowerCase(), result);
}
for (const result of directoryResults) {
const pubkey = result.pubkey.toLowerCase();
resultsByPubkey.set(pubkey, { ...result, pubkey });
}
return [...resultsByPubkey.values()];
}
const resultsByPubkey = new Map<string, UserSearchResult>();
for (const result of directoryResults) {
const pubkey = result.pubkey.toLowerCase();
resultsByPubkey.set(pubkey, { ...result, pubkey });
}
if (memberPubkeys.length > 0) {
for (const pubkey of memberPubkeys) {
if (
matchesPubkeyPrefix(pubkey, deferredQuery) &&
!resultsByPubkey.has(pubkey)
) {
resultsByPubkey.set(pubkey, toSearchResult(pubkey));
}
}
}
return [...resultsByPubkey.values()];
}, [deferredQuery, directoryResults, memberPubkeys, visibleMemberResults]);
const directPubkey = parsePubkeyInput(deferredQuery);
const showDirectPubkey =
directPubkey !== null &&
!searchResults.some(
(user) => user.pubkey.toLowerCase() === directPubkey.toLowerCase(),
);
function selectAuthor(pubkey: string) {
onChange(pubkey.toLowerCase());
}
function loadNextPage() {
if (deferredQuery.length === 0) {
if (visibleMemberCount < memberPubkeys.length) {
setVisibleMemberCount((count) =>
Math.min(count + AUTHOR_PAGE_SIZE, memberPubkeys.length),
);
}
if (
directorySearchQuery.hasNextPage &&
!directorySearchQuery.isFetchingNextPage
) {
void directorySearchQuery.fetchNextPage();
}
return;
}
if (
directorySearchQuery.hasNextPage &&
!directorySearchQuery.isFetchingNextPage
) {
void directorySearchQuery.fetchNextPage();
}
}
function handleListScroll(event: React.UIEvent<HTMLDivElement>) {
const list = event.currentTarget;
if (list.scrollHeight - list.scrollTop - list.clientHeight < 64) {
loadNextPage();
}
}
const hasMoreResults =
deferredQuery.length === 0
? visibleMemberCount < memberPubkeys.length ||
directorySearchQuery.hasNextPage
: directorySearchQuery.hasNextPage;
const isSettling = deferredQuery !== query.trim();
const isLoading =
isSettling ||
(searchResults.length === 0 &&
(membersQuery.isLoading || directorySearchQuery.isLoading));
return (
<div
className={cn(
"overflow-hidden rounded-lg border border-input/40 bg-background",
disabled && "opacity-50",
)}
data-testid="author-grid-picker"
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
aria-label={`${ariaLabel} search`}
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className="min-w-0 flex-1 bg-transparent text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed"
disabled={disabled}
id={id}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search people or paste a public key..."
spellCheck={false}
value={query}
/>
</div>
<PortalledScrollArea
className="max-h-72 overflow-y-auto p-2"
data-testid="author-grid-list"
onScroll={handleListScroll}
>
<fieldset className="grid grid-cols-3 gap-2">
<legend className="sr-only">{ariaLabel}</legend>
{isLoading ? (
<p className="col-span-3 px-3 py-6 text-center text-xs text-muted-foreground">
Loading authors
</p>
) : showDirectPubkey && directPubkey ? (
<>
<AuthorOption
isSelected={directPubkey === normalizedValue}
label={truncatePubkey(directPubkey)}
onSelect={() => selectAuthor(directPubkey)}
pubkey={directPubkey}
/>
{searchResults.map((user) => (
<AuthorSearchOption
isSelected={user.pubkey.toLowerCase() === normalizedValue}
key={user.pubkey}
disabled={disabled}
onSelect={() => selectAuthor(user.pubkey)}
user={user}
/>
))}
</>
) : searchResults.length > 0 ? (
searchResults.map((user) => (
<AuthorSearchOption
isSelected={user.pubkey.toLowerCase() === normalizedValue}
key={user.pubkey}
disabled={disabled}
onSelect={() => selectAuthor(user.pubkey)}
user={user}
/>
))
) : (
<p className="col-span-3 px-3 py-6 text-center text-xs text-muted-foreground">
No authors found.
</p>
)}
{!isLoading && hasMoreResults ? (
<button
className="col-span-3 w-full rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
disabled={disabled || directorySearchQuery.isFetchingNextPage}
onClick={loadNextPage}
type="button"
>
{directorySearchQuery.isFetchingNextPage
? "Loading more…"
: "Load more authors"}
</button>
) : null}
</fieldset>
</PortalledScrollArea>
</div>
);
}
function AuthorSearchOption({
disabled,
isSelected,
onSelect,
user,
}: {
disabled?: boolean;
isSelected: boolean;
onSelect: () => void;
user: UserSearchResult;
}) {
const label = authorLabel(user.pubkey, user);
return (
<AuthorOption
avatarUrl={user.avatarUrl}
disabled={disabled}
isSelected={isSelected}
label={label}
onSelect={onSelect}
pubkey={user.pubkey}
/>
);
}
function AuthorOption({
avatarUrl,
disabled,
isSelected,
label,
onSelect,
pubkey,
}: {
avatarUrl?: string | null;
disabled?: boolean;
isSelected: boolean;
label: string;
onSelect: () => void;
pubkey: string;
}) {
return (
<button
aria-pressed={isSelected}
className={cn(
"relative flex min-h-24 min-w-0 flex-col items-center justify-center gap-2 rounded-lg border border-border bg-muted/20 p-3 text-center transition-colors hover:border-foreground/20 hover:bg-accent hover:text-accent-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed",
isSelected && "border-primary bg-primary/10",
)}
data-testid={`workflow-author-result-${pubkey}`}
disabled={disabled}
onClick={onSelect}
type="button"
>
<ProfileAvatar
avatarUrl={avatarUrl ?? null}
className="h-9 w-9"
iconClassName="h-5 w-5"
label={label}
/>
<span className="min-w-0 max-w-full">
<span className="block truncate text-sm font-medium">{label}</span>
<span className="block truncate text-xs text-muted-foreground">
{truncatePubkey(pubkey)}
</span>
</span>
<Check
className={cn(
"absolute right-2 top-2 h-4 w-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
</button>
);
}
@@ -4,6 +4,7 @@ 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";
import { PortalledScrollArea } from "@/shared/ui/PortalledScrollArea";
function ChannelPrivacyIcon({ channel }: { channel: Channel }) {
const Icon = channel.visibility === "private" ? Lock : Hash;
@@ -160,7 +161,7 @@ export function ChannelCombobox({
value={query}
/>
</div>
<div
<PortalledScrollArea
className="max-h-60 overflow-y-auto p-1"
data-testid="channel-combobox-list"
>
@@ -221,7 +222,7 @@ export function ChannelCombobox({
);
})
)}
</div>
</PortalledScrollArea>
</PopoverContent>
</Popover>
);
@@ -1,7 +1,11 @@
import * as React from "react";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { AuthorGridPicker } from "./AuthorGridPicker";
import { ChannelCombobox } from "./ChannelCombobox";
import { WorkflowEmojiField } from "./WorkflowEmojiField";
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
import {
@@ -77,6 +81,7 @@ function valuePlaceholder(field: string): string {
}
export function WorkflowConditionBuilder({
channels,
disabled,
idPrefix,
matchAllHint = "Leave empty to match every event.",
@@ -84,6 +89,7 @@ export function WorkflowConditionBuilder({
triggerType,
value,
}: {
channels: Channel[];
disabled?: boolean;
idPrefix: string;
matchAllHint?: string;
@@ -92,6 +98,39 @@ export function WorkflowConditionBuilder({
value: string;
}) {
const fields = conditionFieldsForTrigger(triggerType);
const identityQuery = useIdentityQuery();
const knownAuthorPubkeys = React.useMemo(() => {
const selfPubkey = identityQuery.data?.pubkey.trim().toLowerCase();
const rankedPubkeys = new Set<string>();
const addPubkeys = (pubkeys: string[]) => {
for (const pubkey of pubkeys) {
const normalized = pubkey.trim().toLowerCase();
if (normalized && normalized !== selfPubkey) {
rankedPubkeys.add(normalized);
}
}
};
// DM counterparts are the strongest browse-time signal that the user is
// likely to recognize the author. Group DMs keep participant order and
// duplicates are removed before the shared-channel tier is appended.
for (const channel of channels) {
if (channel.channelType !== "dm") continue;
addPubkeys(
channel.participantPubkeys.length > 0
? channel.participantPubkeys
: channel.memberPubkeys,
);
}
for (const channel of channels) {
if (channel.channelType === "dm") continue;
addPubkeys(channel.memberPubkeys);
addPubkeys(channel.participantPubkeys);
}
return [...rankedPubkeys];
}, [channels, identityQuery.data?.pubkey]);
const [editor, setEditor] = React.useState(() =>
initialEditorState(value, triggerType),
);
@@ -264,7 +303,30 @@ export function WorkflowConditionBuilder({
? "Value"
: valueLabel(editor.field)}
</FieldLabel>
{editor.field === "trigger_emoji" ? (
{editor.field === "trigger_author" ? (
<AuthorGridPicker
disabled={disabled}
id={`${idPrefix}-value`}
knownPubkeys={knownAuthorPubkeys}
onChange={(pubkey) =>
emitEditor({ ...editor, value: pubkey })
}
value={editor.value}
/>
) : editor.field === "trigger_channel_id" ? (
<ChannelCombobox
ariaLabel="Channel ID"
channels={channels}
disabled={disabled}
emptyLabel="Choose a channel"
id={`${idPrefix}-value`}
onChange={(channelId) =>
emitEditor({ ...editor, value: channelId })
}
value={editor.value}
variant="field"
/>
) : editor.field === "trigger_emoji" ? (
<WorkflowEmojiField
ariaLabel="Choose condition emoji"
clearAriaLabel="Clear condition emoji"
@@ -48,10 +48,12 @@ import type {
import { defaultScheduleTrigger } from "./workflowSchedule";
function TriggerConfigFields({
channels,
disabled,
trigger,
onUpdate,
}: {
channels: Channel[];
disabled?: boolean;
trigger: TriggerConfig;
onUpdate: (trigger: TriggerConfig) => void;
@@ -62,6 +64,7 @@ function TriggerConfigFields({
return (
<div>
<WorkflowConditionBuilder
channels={channels}
disabled={disabled}
idPrefix="wf-trigger-filter"
matchAllHint="Leave empty to trigger on every message."
@@ -667,6 +670,7 @@ export function WorkflowFormBuilder({
{selectedNode.type === "trigger" ? (
<div>
<TriggerConfigFields
channels={channels}
disabled={disabled}
onUpdate={(trigger) =>
updateFormState({ ...formState, trigger })
@@ -101,42 +101,34 @@ function StepConfigFields({
value={step.text ?? ""}
/>
</div>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-channel`}>
Channel override (optional)
</FieldLabel>
<ChannelCombobox
allowEmpty
ariaLabel="Channel override (optional)"
channels={channels}
disabled={disabled}
emptyLabel={
workflowChannelId
? "Use workflow channel"
: "Use trigger channel"
}
id={`${prefix}-channel`}
isChannelDisabled={(channel) =>
Boolean(workflowChannelId && channel.id !== workflowChannelId)
}
onChange={(channel) => onUpdate({ ...step, channel })}
value={step.channel ?? ""}
variant="field"
/>
<p className="text-xs text-muted-foreground">
{workflowChannelId
? "Defaults to this workflow's channel. Cross-channel posting is not permitted."
: "Defaults to the trigger channel. Webhook and manual triggers require a channel."}
</p>
{triggerType === "webhook" &&
!workflowChannelId &&
!(step.channel ?? "").trim() ? (
<p className="text-xs text-amber-700">
This step will fail for webhook-triggered runs until a channel
override is set.
{!workflowChannelId ? (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-channel`}>
Channel override (optional)
</FieldLabel>
<ChannelCombobox
allowEmpty
ariaLabel="Channel override (optional)"
channels={channels}
disabled={disabled}
emptyLabel="Use trigger channel"
id={`${prefix}-channel`}
onChange={(channel) => onUpdate({ ...step, channel })}
value={step.channel ?? ""}
variant="field"
/>
<p className="text-xs text-muted-foreground">
Defaults to the channel that triggered the workflow. Webhook and
manual triggers require a channel.
</p>
) : null}
</div>
{triggerType === "webhook" && !(step.channel ?? "").trim() ? (
<p className="text-xs text-amber-700">
This step will fail for webhook-triggered runs until a channel
override is set.
</p>
) : null}
</div>
) : null}
</div>
);
case "send_dm":
@@ -391,6 +383,7 @@ export function WorkflowStepCard({
<SectionHeading title="Run controls" />
<div>
<WorkflowConditionBuilder
channels={channels}
disabled={disabled}
idPrefix={`${prefix}-condition`}
matchAllHint="Leave empty to run this step every time the workflow starts."
@@ -0,0 +1,39 @@
import type * as React from "react";
type PortalledScrollAreaProps = React.ComponentPropsWithoutRef<"div">;
/**
* Keeps a portalled overflow container wheel-scrollable when it is rendered
* outside a modal's scroll-lock boundary.
*/
export function PortalledScrollArea({
onWheel,
...props
}: PortalledScrollAreaProps) {
function handleWheel(event: React.WheelEvent<HTMLDivElement>) {
onWheel?.(event);
if (event.defaultPrevented) return;
const area = event.currentTarget;
const maxScrollTop = area.scrollHeight - area.clientHeight;
if (maxScrollTop <= 0) return;
const multiplier =
event.deltaMode === WheelEvent.DOM_DELTA_LINE
? 16
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
? area.clientHeight
: 1;
const nextScrollTop = Math.max(
0,
Math.min(maxScrollTop, area.scrollTop + event.deltaY * multiplier),
);
if (nextScrollTop === area.scrollTop) return;
area.scrollTop = nextScrollTop;
event.preventDefault();
event.stopPropagation();
}
return <div {...props} onWheel={handleWheel} />;
}
+20
View File
@@ -158,6 +158,11 @@ type MockSearchProfileSeed = {
isAgent?: boolean;
};
type MockRelayMemberSeed = {
pubkey: string;
role?: "owner" | "admin" | "member";
};
type MockHuddleMemberSeed = {
pubkey: string;
role: "owner" | "admin" | "member" | "guest" | "bot";
@@ -417,6 +422,8 @@ type E2eConfig = {
/** Delay EOSE for membership snapshots after delivering the event. */
relayMembershipEoseDelayMs?: number;
relayRole?: "owner" | "admin" | "member" | null;
/** Additional members appended to the default NIP-43 roster. */
additionalRelayMembers?: MockRelayMemberSeed[];
// Descriptors returned by the mocked `pick_and_upload_media` /
// `upload_media_bytes` commands. Lets a spec drive the attachment flow
// (e.g. a generic PDF) without a real upload pipeline. See
@@ -1765,6 +1772,19 @@ function resetMockRelayMembers(config: E2eConfig | undefined) {
created_at: isoMinutesAgo(60),
},
];
for (const member of config?.mock?.additionalRelayMembers ?? []) {
const memberPubkey = member.pubkey.toLowerCase();
if (mockRelayMembers.some((existing) => existing.pubkey === memberPubkey)) {
continue;
}
mockRelayMembers.push({
pubkey: memberPubkey,
role: member.role ?? "member",
added_by: activeRoleMember?.pubkey ?? null,
created_at: isoMinutesAgo(30),
});
}
}
function buildMockConfigSurface(pubkey: string): {
+160 -21
View File
@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const TRIGGER_OPTION_LABELS: Record<string, string> = {
diff_posted: "Diff Posted",
@@ -10,8 +10,30 @@ const TRIGGER_OPTION_LABELS: Record<string, string> = {
webhook: "Webhook",
};
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
const WORKFLOW_AUTHOR_DIRECTORY = Array.from({ length: 60 }, (_, index) => {
const memberNumber = index + 1;
return {
pubkey: (10_000 + memberNumber).toString(16).padStart(64, "0"),
displayName: `Workflow member ${memberNumber.toString().padStart(2, "0")}`,
};
});
const PROFILELESS_WORKFLOW_MEMBER = "abcdef12".padEnd(64, "3");
test.beforeEach(async ({ page }, testInfo) => {
const needsLargeAuthorDirectory =
testInfo.title ===
"chooses a trigger author condition from live user search";
await installMockBridge(
page,
needsLargeAuthorDirectory
? {
additionalRelayMembers: WORKFLOW_AUTHOR_DIRECTORY.map(
({ pubkey }) => ({ pubkey }),
).concat({ pubkey: PROFILELESS_WORKFLOW_MEMBER }),
searchProfiles: WORKFLOW_AUTHOR_DIRECTORY,
}
: undefined,
);
});
async function navigateToWorkflows(page: import("@playwright/test").Page) {
@@ -293,6 +315,133 @@ test("builds a valid trigger condition from plain-language choices", async ({
await expect(inspector.getByLabel("Custom expression")).not.toBeVisible();
});
test("chooses a trigger channel condition from the live channel list", async ({
page,
}) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
const inspector = dialog.getByTestId("workflow-node-inspector");
await inspector.getByRole("button", { name: "Channel ID" }).click();
const channelCondition = inspector.getByRole("combobox", {
name: "Channel ID",
});
await expect(channelCondition).toContainText("Choose a channel");
await channelCondition.click();
const channelList = page.getByTestId("channel-combobox-list");
await channelList.hover();
await page.mouse.wheel(0, 500);
await expect
.poll(() => channelList.evaluate((element) => element.scrollTop))
.toBeGreaterThan(0);
const search = page.getByPlaceholder("Search channels...");
await search.fill("random");
await page.getByRole("button", { name: "random · stream" }).click();
await expect(channelCondition).toContainText("random");
await dialog.getByRole("tab", { name: "YAML" }).click();
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
/str_contains\(trigger_channel_id, "[0-9a-f-]{36}"\)/,
);
});
test("chooses a trigger author condition from live user search", async ({
page,
}) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
const inspector = dialog.getByTestId("workflow-node-inspector");
await inspector.getByRole("button", { name: "Author pubkey" }).click();
const authorPicker = inspector.getByTestId("author-grid-picker");
await expect(authorPicker).toBeVisible();
await expect
.poll(() =>
authorPicker
.locator("fieldset")
.evaluate(
(element) =>
getComputedStyle(element)
.gridTemplateColumns.split(" ")
.filter(Boolean).length,
),
)
.toBe(3);
const authorList = authorPicker.getByTestId("author-grid-list");
const authorResults = page.getByTestId(/^workflow-author-result-/);
await expect.poll(() => authorResults.count()).toBeGreaterThanOrEqual(50);
await expect
.poll(() =>
authorResults.evaluateAll((elements) =>
elements
.slice(0, 3)
.map((element) =>
element
.getAttribute("data-testid")
?.replace("workflow-author-result-", ""),
),
),
)
.toEqual([
TEST_IDENTITIES.alice.pubkey,
TEST_IDENTITIES.bob.pubkey,
TEST_IDENTITIES.charlie.pubkey,
]);
await authorList.hover();
await page.mouse.wheel(0, 10_000);
await expect.poll(() => authorResults.count()).toBeGreaterThan(60);
await page
.getByPlaceholder("Search people or paste a public key...")
.fill("abcdef12");
await expect(
page.getByTestId(`workflow-author-result-${PROFILELESS_WORKFLOW_MEMBER}`),
).toBeVisible();
await page
.getByPlaceholder("Search people or paste a public key...")
.fill("charlie");
await expect(authorList.getByText("charlie", { exact: true })).toBeVisible();
await page
.getByPlaceholder("Search people or paste a public key...")
.fill("outsider");
await expect(
page.getByTestId(
`workflow-author-result-${TEST_IDENTITIES.outsider.pubkey}`,
),
).toBeVisible();
await page
.getByPlaceholder("Search people or paste a public key...")
.fill("Workflow");
await expect(authorResults).toHaveCount(50);
await page.getByRole("button", { name: "Load more authors" }).click();
await expect(authorResults).toHaveCount(60);
await page
.getByPlaceholder("Search people or paste a public key...")
.fill("Workflow member 59");
const selectedAuthor = page.getByTestId(
`workflow-author-result-${WORKFLOW_AUTHOR_DIRECTORY[58].pubkey}`,
);
await selectedAuthor.click();
await expect(selectedAuthor).toHaveAttribute("aria-pressed", "true");
await dialog.getByRole("tab", { name: "YAML" }).click();
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
/str_contains\(trigger_author,\s+"[0-9a-f]{64}"\)/,
);
});
test("chooses and clears a reaction trigger with the app emoji picker", async ({
page,
}) => {
@@ -447,35 +596,25 @@ test("scrolls the channel list with the mouse wheel", async ({ page }) => {
.toBeGreaterThan(0);
});
test("selects a message destination from the channel lookup", async ({
page,
}) => {
test("omits destination controls for a channel workflow", async ({ page }) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog");
await selectFirstChannel(dialog);
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
await dialog.getByLabel("Trigger event").click();
await page.getByRole("menuitem", { name: "Webhook" }).click();
await dialog.getByRole("button", { name: "Add step" }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
const channelOverride = dialog.getByRole("combobox", {
name: "Channel override (optional)",
});
await expect(channelOverride).toContainText("Use workflow channel");
await channelOverride.click();
const search = page.getByPlaceholder("Search channels...");
await search.fill("random");
await expect(
page.getByRole("button", { name: "random · stream" }),
).toBeDisabled();
await search.fill("agents");
await page.getByRole("button", { name: "agents · stream" }).click();
await expect(channelOverride).toContainText("agents");
dialog.getByRole("combobox", { name: "Channel override (optional)" }),
).toHaveCount(0);
await expect(dialog.getByText("Posting channel")).toHaveCount(0);
await dialog.getByRole("tab", { name: "YAML" }).click();
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
await expect(dialog.getByLabel("Workflow YAML")).not.toHaveValue(
/channel: [0-9a-f-]{36}/,
);
});
+7
View File
@@ -54,6 +54,11 @@ type MockSearchProfileSeed = {
isAgent?: boolean;
};
type MockRelayMemberSeed = {
pubkey: string;
role?: "owner" | "admin" | "member";
};
type MockRelayAgentSeed = {
pubkey: string;
name: string;
@@ -374,6 +379,8 @@ type MockBridgeOptions = {
* evaluates false).
*/
relayRole?: "owner" | "admin" | "member" | null;
/** Additional members appended to the default NIP-43 roster. */
additionalRelayMembers?: MockRelayMemberSeed[];
/**
* Descriptors returned by the mocked `pick_and_upload_media` /
* `upload_media_bytes` commands. When omitted, the bridge returns a single