Saving tasks in UI (#346)

Co-authored-by: Muhammed Salih Altun <muhammedsalihaltun@gmail.com>
This commit is contained in:
Kerem Yilmaz
2024-05-20 08:27:36 -07:00
committed by GitHub
parent 5acdddf67e
commit dc10ea3c32
27 changed files with 1513 additions and 325 deletions

View File

@@ -0,0 +1,219 @@
import { TaskApiResponse } from "@/api/types";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { stringify as convertToYAML } from "yaml";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod";
import { DotsHorizontalIcon, ReloadIcon } from "@radix-ui/react-icons";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { toast } from "@/components/ui/use-toast";
import {
TaskTemplateFormValues,
taskTemplateFormSchema,
} from "../create/TaskTemplateFormSchema";
function createTaskTemplateRequestObject(
values: TaskTemplateFormValues,
task: TaskApiResponse,
) {
return {
title: values.title,
description: values.description,
webhook_callback_url: task.request.webhook_callback_url,
proxy_location: task.request.proxy_location,
workflow_definition: {
parameters: [
{
parameter_type: "workflow",
workflow_parameter_type: "json",
key: "navigation_payload",
default_value: JSON.stringify(task.request.navigation_payload),
},
],
blocks: [
{
block_type: "task",
label: values.title,
url: task.request.url,
navigation_goal: task.request.navigation_goal,
data_extraction_goal:
task.request.data_extraction_goal === ""
? null
: task.request.data_extraction_goal,
data_schema:
task.request.extracted_information_schema === ""
? null
: task.request.extracted_information_schema,
},
],
},
};
}
type Props = {
task: TaskApiResponse;
};
function TaskActions({ task }: Props) {
const [open, setOpen] = useState(false);
const id = useId();
const queryClient = useQueryClient();
const credentialGetter = useCredentialGetter();
const form = useForm<TaskTemplateFormValues>({
resolver: zodResolver(taskTemplateFormSchema),
defaultValues: {
title: "",
description: "",
},
});
const mutation = useMutation({
mutationFn: async (values: TaskTemplateFormValues) => {
const request = createTaskTemplateRequestObject(values, task);
const client = await getClient(credentialGetter);
const yaml = convertToYAML(request);
return client
.post("/workflows", yaml, {
headers: {
"Content-Type": "text/plain",
},
})
.then((response) => response.data);
},
onError: (error) => {
toast({
variant: "destructive",
title: "There was an error while saving changes",
description: error.message,
});
},
onSuccess: () => {
toast({
title: "Template saved",
description: "Template saved successfully",
});
queryClient.invalidateQueries({
queryKey: ["workflows"],
});
setOpen(false);
},
});
function handleSubmit(values: TaskTemplateFormValues) {
mutation.mutate(values);
}
return (
<div className="flex">
<Dialog open={open} onOpenChange={setOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild className="ml-auto">
<Button size="icon" variant="ghost">
<DotsHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Task Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DialogTrigger asChild>
<DropdownMenuItem
onSelect={() => {
setOpen(true);
}}
>
Save as Template
</DropdownMenuItem>
</DialogTrigger>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent>
<DialogHeader>
<DialogTitle>Save Task as Template</DialogTitle>
<DialogDescription>
Save this task definition as a template that can be used later.
</DialogDescription>
</DialogHeader>
<Separator />
<Form {...form}>
<form
id={id}
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title *</FormLabel>
<FormControl>
<Input {...field} placeholder="Task title" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
{...field}
rows={5}
placeholder="Task description"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter className="pt-4">
<Button type="submit" form={id} disabled={mutation.isPending}>
{mutation.isPending && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export { TaskActions };

View File

@@ -0,0 +1,155 @@
import { getClient } from "@/api/AxiosClient";
import { TaskApiResponse } from "@/api/types";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom";
import { PAGE_SIZE } from "../constants";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TaskListSkeletonRows } from "./TaskListSkeletonRows";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { StatusBadge } from "@/components/StatusBadge";
import { basicTimeFormat } from "@/util/timeFormat";
import { cn } from "@/util/utils";
import { TaskActions } from "./TaskActions";
function TaskHistory() {
const credentialGetter = useCredentialGetter();
const [searchParams, setSearchParams] = useSearchParams();
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
const navigate = useNavigate();
const {
data: tasks,
isPending,
isError,
error,
} = useQuery<Array<TaskApiResponse>>({
queryKey: ["tasks", "history", page],
queryFn: async () => {
const client = await getClient(credentialGetter);
const params = new URLSearchParams();
params.append("page", String(page));
params.append("page_size", String(PAGE_SIZE));
params.append("task_status", "completed");
params.append("task_status", "failed");
params.append("task_status", "terminated");
return client
.get("/tasks", {
params,
})
.then((response) => response.data);
},
});
if (isError) {
return <div>Error: {error?.message}</div>;
}
return (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-1/3">URL</TableHead>
<TableHead className="w-1/4">Status</TableHead>
<TableHead className="w-1/3">Created At</TableHead>
<TableHead className="w-1/12" />
</TableRow>
</TableHeader>
<TableBody>
{isPending ? (
<TaskListSkeletonRows />
) : tasks?.length === 0 ? (
<TableRow>
<TableCell colSpan={3}>No tasks found</TableCell>
</TableRow>
) : (
tasks?.map((task) => {
return (
<TableRow key={task.task_id} className="w-4">
<TableCell
className="w-1/3 cursor-pointer"
onClick={() => {
navigate(task.task_id);
}}
>
{task.request.url}
</TableCell>
<TableCell
className="w-1/4 cursor-pointer"
onClick={() => {
navigate(task.task_id);
}}
>
<StatusBadge status={task.status} />
</TableCell>
<TableCell
className="w-1/3 cursor-pointer"
onClick={() => {
navigate(task.task_id);
}}
>
{basicTimeFormat(task.created_at)}
</TableCell>
<TableCell className="w-1/12">
<TaskActions task={task} />
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<Pagination className="pt-2">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
className={cn({ "cursor-not-allowed": page === 1 })}
onClick={() => {
if (page === 1) {
return;
}
const params = new URLSearchParams();
params.set("page", String(Math.max(1, page - 1)));
setSearchParams(params);
}}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">{page}</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
href="#"
onClick={() => {
const params = new URLSearchParams();
params.set("page", String(page + 1));
setSearchParams(params);
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</>
);
}
export { TaskHistory };

View File

@@ -1,30 +1,3 @@
import { getClient } from "@/api/AxiosClient";
import { TaskApiResponse } from "@/api/types";
import { useQuery } from "@tanstack/react-query";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { useNavigate, useSearchParams } from "react-router-dom";
import { TaskListSkeleton } from "./TaskListSkeleton";
import { RunningTasks } from "../running/RunningTasks";
import { cn } from "@/util/utils";
import { PAGE_SIZE } from "../constants";
import { StatusBadge } from "@/components/StatusBadge";
import { basicTimeFormat } from "@/util/timeFormat";
import { QueuedTasks } from "../running/QueuedTasks";
import {
Card,
CardContent,
@@ -32,48 +5,11 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { QueuedTasks } from "../running/QueuedTasks";
import { RunningTasks } from "../running/RunningTasks";
import { TaskHistory } from "./TaskHistory";
function TaskList() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
const credentialGetter = useCredentialGetter();
const {
data: tasks,
isPending,
isError,
error,
} = useQuery<Array<TaskApiResponse>>({
queryKey: ["tasks", "all", page],
queryFn: async () => {
const client = await getClient(credentialGetter);
const params = new URLSearchParams();
params.append("page", String(page));
params.append("page_size", String(PAGE_SIZE));
params.append("task_status", "completed");
params.append("task_status", "failed");
params.append("task_status", "terminated");
return client
.get("/tasks", {
params,
})
.then((response) => response.data);
},
});
if (isError) {
return <div>Error: {error?.message}</div>;
}
const resolvedTasks = tasks?.filter(
(task) =>
task.status === "completed" ||
task.status === "failed" ||
task.status === "terminated",
);
return (
<div className="flex flex-col gap-8 max-w-6xl mx-auto p-8 pt-0">
<Card>
@@ -102,81 +38,7 @@ function TaskList() {
<CardDescription>Tasks you have run previously</CardDescription>
</CardHeader>
<CardContent className="p-4">
{isPending ? (
<TaskListSkeleton />
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-1/3">URL</TableHead>
<TableHead className="w-1/3">Status</TableHead>
<TableHead className="w-1/3">Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.length === 0 ? (
<TableRow>
<TableCell colSpan={3}>No tasks found</TableCell>
</TableRow>
) : (
resolvedTasks?.map((task) => {
return (
<TableRow
key={task.task_id}
className="cursor-pointer w-4"
onClick={() => {
navigate(task.task_id);
}}
>
<TableCell className="w-1/3">
{task.request.url}
</TableCell>
<TableCell className="w-1/3">
<StatusBadge status={task.status} />
</TableCell>
<TableCell className="w-1/3">
{basicTimeFormat(task.created_at)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
className={cn({ "cursor-not-allowed": page === 1 })}
onClick={() => {
if (page === 1) {
return;
}
const params = new URLSearchParams();
params.set("page", String(Math.max(1, page - 1)));
setSearchParams(params);
}}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">{page}</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
href="#"
onClick={() => {
const params = new URLSearchParams();
params.set("page", String(page + 1));
setSearchParams(params);
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</>
)}
<TaskHistory />
</CardContent>
</Card>
</div>

View File

@@ -1,46 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const pageSizeArray = new Array(5).fill(null); // doesn't matter the value
function TaskListSkeleton() {
return (
<div className="flex flex-col gap-2">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-1/3">URL</TableHead>
<TableHead className="w-1/3">Status</TableHead>
<TableHead className="w-1/3">Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageSizeArray.map((_, index) => {
return (
<TableRow key={index}>
<TableCell className="w-1/3">
<Skeleton className="w-full h-4" />
</TableCell>
<TableCell className="w-1/3">
<Skeleton className="w-full h-4" />
</TableCell>
<TableCell className="w-1/3">
<Skeleton className="w-full h-4" />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
export { TaskListSkeleton };

View File

@@ -0,0 +1,27 @@
import { Skeleton } from "@/components/ui/skeleton";
import { TableCell, TableRow } from "@/components/ui/table";
const pageSizeArray = new Array(5).fill(null); // doesn't matter the value
function TaskListSkeletonRows() {
return pageSizeArray.map((_, index) => {
return (
<TableRow key={index}>
<TableCell className="w-1/3">
<Skeleton className="w-full h-6" />
</TableCell>
<TableCell className="w-1/4">
<Skeleton className="w-full h-6" />
</TableCell>
<TableCell className="w-1/3">
<Skeleton className="w-full h-6" />
</TableCell>
<TableCell className="w-1/12">
<Skeleton className="w-full h-6" />
</TableCell>
</TableRow>
);
});
}
export { TaskListSkeletonRows };