various hitl buffs (#3828)
This commit is contained in:
@@ -95,94 +95,79 @@ function HumanInteractionNode({
|
|||||||
</div>
|
</div>
|
||||||
{/* TODO(jdo): 'instructions' allows templating; but it requires adding a column to the workflow_block_runs
|
{/* TODO(jdo): 'instructions' allows templating; but it requires adding a column to the workflow_block_runs
|
||||||
table, and I don't want to do that just yet (see /timeline endpoint) */}
|
table, and I don't want to do that just yet (see /timeline endpoint) */}
|
||||||
<Input
|
<WorkflowBlockInput
|
||||||
onChange={(event) => {
|
nodeId={id}
|
||||||
update({ instructions: event.target.value });
|
onChange={(value) => {
|
||||||
|
update({ instructions: value });
|
||||||
}}
|
}}
|
||||||
value={data.instructions}
|
value={data.instructions}
|
||||||
placeholder="Please review and approve or reject to continue the workflow."
|
placeholder="Please review and approve or reject to continue the workflow."
|
||||||
className="nopan text-xs"
|
className="nopan text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Label className="text-xs text-slate-300">
|
|
||||||
Negative Button Label
|
|
||||||
</Label>
|
|
||||||
<HelpTooltip content={negativeDescriptorTooltip} />
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
onChange={(event) => {
|
|
||||||
update({ negativeDescriptor: event.target.value });
|
|
||||||
}}
|
|
||||||
value={data.negativeDescriptor}
|
|
||||||
placeholder="Reject"
|
|
||||||
className="nopan text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Label className="text-xs text-slate-300">
|
|
||||||
Positive Button Label
|
|
||||||
</Label>
|
|
||||||
<HelpTooltip content={positiveDescriptorTooltip} />
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
onChange={(event) => {
|
|
||||||
update({ positiveDescriptor: event.target.value });
|
|
||||||
}}
|
|
||||||
value={data.positiveDescriptor}
|
|
||||||
placeholder="Approve"
|
|
||||||
className="nopan text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* <div className="space-y-2">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Label className="text-xs text-slate-300">
|
|
||||||
Timeout (seconds)
|
|
||||||
</Label>
|
|
||||||
<HelpTooltip content={timeoutTooltip} />
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="nopan text-xs"
|
|
||||||
min="1"
|
|
||||||
value={data.timeoutSeconds}
|
|
||||||
onChange={(event) => {
|
|
||||||
if (!editable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const value = Number(event.target.value);
|
|
||||||
update({ timeoutSeconds: value });
|
|
||||||
}}
|
|
||||||
placeholder="7200"
|
|
||||||
/>
|
|
||||||
</div> */}
|
|
||||||
<div className="space-between flex items-center gap-2">
|
<div className="space-between flex items-center gap-2">
|
||||||
<Label className="text-xs text-slate-300">Timeout (seconds)</Label>
|
<Label className="text-xs text-slate-300">Timeout (minutes)</Label>
|
||||||
<HelpTooltip content={timeoutTooltip} />
|
<HelpTooltip content={timeoutTooltip} />
|
||||||
<Input
|
<Input
|
||||||
className="ml-auto w-16 text-right"
|
className="ml-auto w-16 text-right"
|
||||||
value={data.timeoutSeconds}
|
value={data.timeoutSeconds / 60}
|
||||||
placeholder="7200"
|
placeholder="120"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
if (!editable) {
|
if (!editable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const value = Number(event.target.value);
|
const value = Number(event.target.value);
|
||||||
update({ timeoutSeconds: value });
|
update({ timeoutSeconds: value * 60 });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md bg-slate-800 p-2">
|
<div className="flex items-center justify-center gap-2 rounded-md bg-slate-800 p-2">
|
||||||
|
<span className="rounded bg-slate-700 p-1 text-lg">💡</span>
|
||||||
<div className="space-y-1 text-xs text-slate-400">
|
<div className="space-y-1 text-xs text-slate-400">
|
||||||
Tip: The workflow will pause and send an email notification to the
|
The workflow will pause and send an email notification to the
|
||||||
recipients. The workflow continues or terminates based on the
|
recipients. The workflow continues or terminates based on the
|
||||||
user's response.
|
user's response.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-4 rounded-md bg-slate-800 p-4">
|
||||||
|
<h2>Email Settings</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-slate-300">Recipients</Label>
|
||||||
|
<WorkflowBlockInput
|
||||||
|
nodeId={id}
|
||||||
|
onChange={(value) => {
|
||||||
|
update({ recipients: value });
|
||||||
|
}}
|
||||||
|
value={data.recipients}
|
||||||
|
placeholder="example@gmail.com, example2@gmail.com..."
|
||||||
|
className="nopan text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-slate-300">Subject</Label>
|
||||||
|
<WorkflowBlockInput
|
||||||
|
nodeId={id}
|
||||||
|
onChange={(value) => {
|
||||||
|
update({ subject: value });
|
||||||
|
}}
|
||||||
|
value={data.subject}
|
||||||
|
placeholder="Human interaction required for workflow run"
|
||||||
|
className="nopan text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-slate-300">Body</Label>
|
||||||
|
<WorkflowBlockInputTextarea
|
||||||
|
nodeId={id}
|
||||||
|
onChange={(value) => {
|
||||||
|
update({ body: value });
|
||||||
|
}}
|
||||||
|
value={data.body}
|
||||||
|
placeholder="Your interaction is required for a workflow run!"
|
||||||
|
className="nopan text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Accordion
|
<Accordion
|
||||||
@@ -194,44 +179,46 @@ function HumanInteractionNode({
|
|||||||
collapsible
|
collapsible
|
||||||
>
|
>
|
||||||
<AccordionItem value="email" className="border-b-0">
|
<AccordionItem value="email" className="border-b-0">
|
||||||
<AccordionTrigger className="py-0">Email Settings</AccordionTrigger>
|
<AccordionTrigger className="py-0">
|
||||||
|
Advanced Settings
|
||||||
|
</AccordionTrigger>
|
||||||
<AccordionContent className="pl-6 pr-1 pt-1">
|
<AccordionContent className="pl-6 pr-1 pt-1">
|
||||||
<div key={rerender.key} className="space-y-4">
|
<div key={rerender.key} className="space-y-4 pt-4">
|
||||||
<div className="space-y-2">
|
<div className="flex gap-4">
|
||||||
<Label className="text-xs text-slate-300">Recipients</Label>
|
<div className="flex-1 space-y-2">
|
||||||
<WorkflowBlockInput
|
<div className="flex gap-2">
|
||||||
nodeId={id}
|
<Label className="text-xs text-slate-300">
|
||||||
onChange={(value) => {
|
Negative Button Label
|
||||||
update({ recipients: value });
|
</Label>
|
||||||
}}
|
<HelpTooltip content={negativeDescriptorTooltip} />
|
||||||
value={data.recipients}
|
</div>
|
||||||
placeholder="example@gmail.com, example2@gmail.com..."
|
<WorkflowBlockInput
|
||||||
className="nopan text-xs"
|
nodeId={id}
|
||||||
/>
|
onChange={(value) => {
|
||||||
</div>
|
update({ negativeDescriptor: value });
|
||||||
<div className="space-y-2">
|
}}
|
||||||
<Label className="text-xs text-slate-300">Subject</Label>
|
value={data.negativeDescriptor}
|
||||||
<WorkflowBlockInput
|
placeholder="Reject"
|
||||||
nodeId={id}
|
className="nopan text-xs"
|
||||||
onChange={(value) => {
|
/>
|
||||||
update({ subject: value });
|
</div>
|
||||||
}}
|
<div className="flex-1 space-y-2">
|
||||||
value={data.subject}
|
<div className="flex gap-2">
|
||||||
placeholder="Human interaction required for workflow run"
|
<Label className="text-xs text-slate-300">
|
||||||
className="nopan text-xs"
|
Positive Button Label
|
||||||
/>
|
</Label>
|
||||||
</div>
|
<HelpTooltip content={positiveDescriptorTooltip} />
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<Label className="text-xs text-slate-300">Body</Label>
|
<WorkflowBlockInput
|
||||||
<WorkflowBlockInputTextarea
|
nodeId={id}
|
||||||
nodeId={id}
|
onChange={(value) => {
|
||||||
onChange={(value) => {
|
update({ positiveDescriptor: value });
|
||||||
update({ body: value });
|
}}
|
||||||
}}
|
value={data.positiveDescriptor}
|
||||||
value={data.body}
|
placeholder="Approve"
|
||||||
placeholder="Your interaction is required for a workflow run!"
|
className="nopan text-xs"
|
||||||
className="nopan text-xs"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
|
|||||||
@@ -2318,8 +2318,10 @@ function getWorkflowErrors(nodes: Array<AppNode>): Array<string> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const interactionNodes = nodes.filter(isHumanInteractionNode);
|
const interactionNodes = nodes.filter(isHumanInteractionNode);
|
||||||
interactionNodes.forEach((/* node */) => {
|
interactionNodes.forEach((node) => {
|
||||||
// pass for now
|
if (node.data.recipients.trim().length === 0) {
|
||||||
|
errors.push(`${node.data.label}: Recipients is required.`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const navigationNodes = nodes.filter(isNavigationNode);
|
const navigationNodes = nodes.filter(isNavigationNode);
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ export type WorkflowRunBlock = {
|
|||||||
// for blocks in loop
|
// for blocks in loop
|
||||||
current_value: string | null;
|
current_value: string | null;
|
||||||
current_index: number | null;
|
current_index: number | null;
|
||||||
|
|
||||||
|
// human interaction block
|
||||||
|
instructions?: string | null;
|
||||||
|
positive_descriptor?: string | null;
|
||||||
|
negative_descriptor?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowRunTimelineBlockItem = {
|
export type WorkflowRunTimelineBlockItem = {
|
||||||
|
|||||||
@@ -4,16 +4,26 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||||
import { cn } from "@/util/utils";
|
import { cn } from "@/util/utils";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { toast } from "@/components/ui/use-toast";
|
import { toast } from "@/components/ui/use-toast";
|
||||||
import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery";
|
import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery";
|
||||||
import { HumanInteractionBlock } from "../types/workflowTypes";
|
import { WorkflowRunBlock } from "../types/workflowRunTypes";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
humanInteractionBlock: HumanInteractionBlock;
|
workflowRunBlock: WorkflowRunBlock;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkflowRunHumanInteraction({ humanInteractionBlock }: Props) {
|
export function WorkflowRunHumanInteraction({ workflowRunBlock }: Props) {
|
||||||
const credentialGetter = useCredentialGetter();
|
const credentialGetter = useCredentialGetter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: workflowRun } = useWorkflowRunQuery();
|
const { data: workflowRun } = useWorkflowRunQuery();
|
||||||
@@ -21,11 +31,14 @@ export function WorkflowRunHumanInteraction({ humanInteractionBlock }: Props) {
|
|||||||
workflowRun && workflowRun.status === WorkflowRunStatus.Paused;
|
workflowRun && workflowRun.status === WorkflowRunStatus.Paused;
|
||||||
|
|
||||||
const buttonLayout =
|
const buttonLayout =
|
||||||
humanInteractionBlock.positive_descriptor.length < 8 &&
|
(workflowRunBlock.positive_descriptor?.length ?? 0) < 8 &&
|
||||||
humanInteractionBlock.negative_descriptor.length < 8
|
(workflowRunBlock.negative_descriptor?.length ?? 0) < 8
|
||||||
? "inline"
|
? "inline"
|
||||||
: "stacked";
|
: "stacked";
|
||||||
|
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [choice, setChoice] = useState<"approve" | "reject" | null>(null);
|
||||||
|
|
||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!workflowRun) {
|
if (!workflowRun) {
|
||||||
@@ -45,8 +58,8 @@ export function WorkflowRunHumanInteraction({ humanInteractionBlock }: Props) {
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
title: `${humanInteractionBlock.positive_descriptor}`,
|
title: `${workflowRunBlock.positive_descriptor}`,
|
||||||
description: `Successfully chose: ${humanInteractionBlock.positive_descriptor}`,
|
description: `Successfully chose: ${workflowRunBlock.positive_descriptor}`,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -77,8 +90,8 @@ export function WorkflowRunHumanInteraction({ humanInteractionBlock }: Props) {
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
title: `${humanInteractionBlock.negative_descriptor}`,
|
title: `${workflowRunBlock.negative_descriptor}`,
|
||||||
description: `Successfully chose: ${humanInteractionBlock.negative_descriptor}`,
|
description: `Successfully chose: ${workflowRunBlock.negative_descriptor}`,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -96,18 +109,60 @@ export function WorkflowRunHumanInteraction({ humanInteractionBlock }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 flex flex-col gap-4 rounded-md bg-slate-elevation4 p-4">
|
<div className="mt-4 flex flex-col gap-4 rounded-md bg-slate-elevation4 p-4">
|
||||||
<div className="text-sm">{humanInteractionBlock.instructions}</div>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{choice === "approve"
|
||||||
|
? workflowRunBlock.positive_descriptor
|
||||||
|
: workflowRunBlock.negative_descriptor}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>Are you sure?</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="secondary">Back</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
variant={choice === "reject" ? "destructive" : "default"}
|
||||||
|
onClick={() => {
|
||||||
|
if (choice === "approve") {
|
||||||
|
approveMutation.mutate();
|
||||||
|
} else if (choice === "reject") {
|
||||||
|
rejectMutation.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Proceed
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<div className="text-sm">{workflowRunBlock.instructions}</div>
|
||||||
<div
|
<div
|
||||||
className={cn("flex gap-2", {
|
className={cn("flex gap-2", {
|
||||||
"justify-between": buttonLayout === "inline",
|
"justify-between": buttonLayout === "inline",
|
||||||
"flex-col": buttonLayout === "stacked",
|
"flex-col": buttonLayout === "stacked",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Button variant="destructive" onClick={() => rejectMutation.mutate()}>
|
<Button
|
||||||
<div>{humanInteractionBlock.negative_descriptor}</div>
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setChoice("reject");
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>{workflowRunBlock.negative_descriptor}</div>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="default" onClick={() => approveMutation.mutate()}>
|
<Button
|
||||||
<div>{humanInteractionBlock.positive_descriptor}</div>
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
setChoice("approve");
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>{workflowRunBlock.positive_descriptor}</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
} from "@radix-ui/react-icons";
|
} from "@radix-ui/react-icons";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useParams, Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { Status } from "@/api/types";
|
import { Status } from "@/api/types";
|
||||||
import { formatDuration, toDuration } from "@/routes/workflows/utils";
|
import { formatDuration, toDuration } from "@/routes/workflows/utils";
|
||||||
@@ -27,13 +27,8 @@ import {
|
|||||||
WorkflowRunOverviewActiveElement,
|
WorkflowRunOverviewActiveElement,
|
||||||
} from "./WorkflowRunOverview";
|
} from "./WorkflowRunOverview";
|
||||||
import { ThoughtCard } from "./ThoughtCard";
|
import { ThoughtCard } from "./ThoughtCard";
|
||||||
import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
|
|
||||||
import { ObserverThought } from "../types/workflowRunTypes";
|
import { ObserverThought } from "../types/workflowRunTypes";
|
||||||
import {
|
import { isTaskVariantBlock } from "../types/workflowTypes";
|
||||||
HumanInteractionBlock,
|
|
||||||
isTaskVariantBlock,
|
|
||||||
type WorkflowApiResponse,
|
|
||||||
} from "../types/workflowTypes";
|
|
||||||
import { WorkflowRunHumanInteraction } from "./WorkflowRunHumanInteraction";
|
import { WorkflowRunHumanInteraction } from "./WorkflowRunHumanInteraction";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -45,28 +40,6 @@ type Props = {
|
|||||||
onThoughtCardClick: (thought: ObserverThought) => void;
|
onThoughtCardClick: (thought: ObserverThought) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getHumanInteractionBlock = (
|
|
||||||
block: WorkflowRunBlock,
|
|
||||||
workflow?: WorkflowApiResponse,
|
|
||||||
): HumanInteractionBlock | null => {
|
|
||||||
if (block.block_type !== "human_interaction") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!workflow) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocks = workflow.workflow_definition.blocks;
|
|
||||||
const candidate = blocks.find((b) => b.label === block.label);
|
|
||||||
|
|
||||||
if (!candidate || candidate.block_type !== "human_interaction") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidate as HumanInteractionBlock;
|
|
||||||
};
|
|
||||||
|
|
||||||
function WorkflowRunTimelineBlockItem({
|
function WorkflowRunTimelineBlockItem({
|
||||||
activeItem,
|
activeItem,
|
||||||
block,
|
block,
|
||||||
@@ -75,12 +48,6 @@ function WorkflowRunTimelineBlockItem({
|
|||||||
onActionClick,
|
onActionClick,
|
||||||
onThoughtCardClick,
|
onThoughtCardClick,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { workflowPermanentId } = useParams();
|
|
||||||
const { data: workflow } = useWorkflowQuery({
|
|
||||||
workflowPermanentId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const humanInteractionBlock = getHumanInteractionBlock(block, workflow);
|
|
||||||
const actions = block.actions ?? [];
|
const actions = block.actions ?? [];
|
||||||
|
|
||||||
const hasActiveAction =
|
const hasActiveAction =
|
||||||
@@ -203,10 +170,8 @@ function WorkflowRunTimelineBlockItem({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{humanInteractionBlock && (
|
{block.block_type === "human_interaction" && (
|
||||||
<WorkflowRunHumanInteraction
|
<WorkflowRunHumanInteraction workflowRunBlock={block} />
|
||||||
humanInteractionBlock={humanInteractionBlock}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{actions.map((action, index) => {
|
{actions.map((action, index) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user