mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish workflow condition selectors
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
@@ -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): {
|
||||
|
||||
@@ -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}/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user