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

@@ -39,6 +39,7 @@
"serve-handler": "^6.1.5",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
"yaml": "^2.4.2",
"zod": "^3.22.4",
"zustand": "^4.5.2"
},
@@ -4324,6 +4325,15 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/lint-staged/node_modules/yaml": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz",
"integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==",
"dev": true,
"engines": {
"node": ">= 14"
}
},
"node_modules/listr2": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-8.0.1.tgz",
@@ -6522,9 +6532,12 @@
"dev": true
},
"node_modules/yaml": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz",
"integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==",
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz",
"integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}

View File

@@ -42,11 +42,11 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.1",
"react-medium-image-zoom": "^5.1.11",
"react-router-dom": "^6.22.3",
"serve-handler": "^6.1.5",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
"yaml": "^2.4.2",
"zod": "^3.22.4",
"zustand": "^4.5.2"
},

View File

@@ -3,7 +3,7 @@ import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
staleTime: Infinity,
},
},
});

View File

@@ -65,7 +65,7 @@ export type TaskApiResponse = {
navigation_payload: string | object; // stringified JSON
error_code_mapping: null;
proxy_location: string;
extracted_information_schema: string;
extracted_information_schema: string | object;
};
task_id: string;
status: Status;
@@ -101,3 +101,50 @@ export type ApiKeyApiResponse = {
token_type: string;
valid: boolean;
};
export type WorkflowParameter = {
workflow_parameter_id: string;
workflow_parameter_type?: string;
key: string;
description: string | null;
workflow_id: string;
parameter_type: "workflow"; // TODO other values
default_value?: string;
created_at: string | null;
modified_at: string | null;
deleted_at: string | null;
};
export type WorkflowBlock = {
label: string;
block_type: string;
output_parameter?: null;
continue_on_failure: boolean;
url: string;
title: string;
navigation_goal: string;
data_extraction_goal: string;
data_schema: object | null;
error_code_mapping: null; // ?
max_retries: number | null;
max_steps_per_run: number | null;
parameters: []; // ?
};
export type WorkflowApiResponse = {
workflow_id: string;
organization_id: string;
title: string;
workflow_permanent_id: string;
version: number;
description: string;
workflow_definition: {
parameters: Array<WorkflowParameter>;
blocks: Array<WorkflowBlock>;
};
proxy_location: string;
webhook_callback_url: string;
created_at: string;
modified_at: string;
deleted_at: string | null;
};

View File

@@ -86,7 +86,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}

View File

@@ -1,11 +1,13 @@
import { Navigate, createBrowserRouter } from "react-router-dom";
import { RootLayout } from "./routes/root/RootLayout";
import { TasksPageLayout } from "./routes/tasks/TasksPageLayout";
import { CreateNewTask } from "./routes/tasks/create/CreateNewTask";
import { TaskTemplates } from "./routes/tasks/create/TaskTemplates";
import { TaskList } from "./routes/tasks/list/TaskList";
import { Settings } from "./routes/settings/Settings";
import { SettingsPageLayout } from "./routes/settings/SettingsPageLayout";
import { TaskDetails } from "./routes/tasks/detail/TaskDetails";
import { CreateNewTaskLayout } from "./routes/tasks/create/CreateNewTaskLayout";
import { CreateNewTaskFormPage } from "./routes/tasks/create/CreateNewTaskFormPage";
const router = createBrowserRouter([
{
@@ -32,7 +34,17 @@ const router = createBrowserRouter([
},
{
path: "create",
element: <CreateNewTask />,
element: <CreateNewTaskLayout />,
children: [
{
index: true,
element: <TaskTemplates />,
},
{
path: ":template",
element: <CreateNewTaskFormPage />,
},
],
},
{
path: "settings",

View File

@@ -1,67 +0,0 @@
import { useId, useState } from "react";
import { CreateNewTaskForm } from "./CreateNewTaskForm";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SampleCase } from "../types";
import { getSampleForInitialFormValues } from "../data/sampleTaskData";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
function CreateNewTask() {
const [selectedCase, setSelectedCase] = useState<SampleCase>("geico");
const caseInputId = useId();
return (
<div className="flex flex-col gap-8 max-w-6xl mx-auto p-8 pt-0">
<div className="flex gap-4 items-center">
<Label htmlFor={caseInputId} className="whitespace-nowrap">
Select a sample:
</Label>
<Select
value={selectedCase}
onValueChange={(value) => {
setSelectedCase(value as SampleCase);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a case" />
</SelectTrigger>
<SelectContent>
<SelectItem value="geico">Geico</SelectItem>
<SelectItem value="finditparts">Finditparts</SelectItem>
<SelectItem value="california_edd">California_EDD</SelectItem>
<SelectItem value="bci_seguros">bci_seguros</SelectItem>
</SelectContent>
</Select>
</div>
<Card>
<CardHeader className="border-b-2">
<CardTitle className="text-lg">Create a new task</CardTitle>
<CardDescription>
Fill out the form below to create a new task. You can select a
sample from above to prefill the form with sample data.
</CardDescription>
</CardHeader>
<CardContent>
<CreateNewTaskForm
key={selectedCase}
initialValues={getSampleForInitialFormValues(selectedCase)}
/>
</CardContent>
</Card>
</div>
);
}
export { CreateNewTask };

View File

@@ -23,7 +23,7 @@ import { Textarea } from "@/components/ui/textarea";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { useToast } from "@/components/ui/use-toast";
import { InfoCircledIcon } from "@radix-ui/react-icons";
import { InfoCircledIcon, ReloadIcon } from "@radix-ui/react-icons";
import {
Tooltip,
TooltipContent,
@@ -41,11 +41,11 @@ const createNewTaskFormSchema = z.object({
url: z.string().url({
message: "Invalid URL",
}),
webhookCallbackUrl: z.string().optional(), // url maybe, but shouldn't be validated as one
navigationGoal: z.string().optional(),
dataExtractionGoal: z.string().optional(),
navigationPayload: z.string().optional(),
extractedInformationSchema: z.string().optional(),
webhookCallbackUrl: z.string().or(z.null()).optional(), // url maybe, but shouldn't be validated as one
navigationGoal: z.string().or(z.null()).optional(),
dataExtractionGoal: z.string().or(z.null()).optional(),
navigationPayload: z.string().or(z.null()).optional(),
extractedInformationSchema: z.string().or(z.null()).optional(),
});
export type CreateNewTaskFormValues = z.infer<typeof createNewTaskFormSchema>;
@@ -62,8 +62,8 @@ function createTaskRequestObject(formValues: CreateNewTaskFormValues) {
data_extraction_goal: formValues.dataExtractionGoal ?? "",
proxy_location: "NONE",
error_code_mapping: null,
navigation_payload: formValues.navigationPayload ?? "",
extracted_information_schema: formValues.extractedInformationSchema ?? "",
navigation_payload: formValues.navigationPayload,
extracted_information_schema: formValues.extractedInformationSchema,
};
}
@@ -90,7 +90,7 @@ function CreateNewTaskForm({ initialValues }: Props) {
onError: (error) => {
toast({
variant: "destructive",
title: "Error",
title: "There was an error creating the task.",
description: error.message,
});
},
@@ -126,7 +126,7 @@ function CreateNewTaskForm({ initialValues }: Props) {
<FormItem>
<FormLabel>
<div className="flex gap-2">
URL*
URL *
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -167,7 +167,11 @@ function CreateNewTaskForm({ initialValues }: Props) {
</div>
</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
<Input
placeholder="example.com"
{...field}
value={field.value === null ? "" : field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -194,7 +198,12 @@ function CreateNewTaskForm({ initialValues }: Props) {
</div>
</FormLabel>
<FormControl>
<Textarea rows={5} placeholder="Navigation Goal" {...field} />
<Textarea
rows={5}
placeholder="Navigation Goal"
{...field}
value={field.value === null ? "" : field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -225,6 +234,7 @@ function CreateNewTaskForm({ initialValues }: Props) {
rows={5}
placeholder="Data Extraction Goal"
{...field}
value={field.value === null ? "" : field.value}
/>
</FormControl>
<FormMessage />
@@ -256,6 +266,7 @@ function CreateNewTaskForm({ initialValues }: Props) {
rows={5}
placeholder="Navigation Payload"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
@@ -287,6 +298,7 @@ function CreateNewTaskForm({ initialValues }: Props) {
placeholder="Extracted Information Schema"
rows={5}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
@@ -296,7 +308,7 @@ function CreateNewTaskForm({ initialValues }: Props) {
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
variant="secondary"
onClick={async () => {
const curl = fetchToCurl({
method: "POST",
@@ -316,7 +328,12 @@ function CreateNewTaskForm({ initialValues }: Props) {
>
Copy cURL
</Button>
<Button type="submit">Create</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}
Create
</Button>
</div>
</form>
</Form>

View File

@@ -0,0 +1,61 @@
import { useParams } from "react-router-dom";
import { CreateNewTaskForm } from "./CreateNewTaskForm";
import { getSampleForInitialFormValues } from "../data/sampleTaskData";
import { SampleCase, sampleCases } from "../types";
import { SavedTaskForm } from "./SavedTaskForm";
import { useQuery } from "@tanstack/react-query";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { getClient } from "@/api/AxiosClient";
function CreateNewTaskFormPage() {
const { template } = useParams();
const credentialGetter = useCredentialGetter();
const { data, isFetching } = useQuery({
queryKey: ["workflows", template],
queryFn: async () => {
const client = await getClient(credentialGetter);
return client
.get(`/workflows/${template}`)
.then((response) => response.data);
},
enabled: !!template && !sampleCases.includes(template as SampleCase),
});
if (!template) {
return <div>Invalid template</div>;
}
if (sampleCases.includes(template as SampleCase)) {
return (
<CreateNewTaskForm
key={template}
initialValues={getSampleForInitialFormValues(template as SampleCase)}
/>
);
}
if (isFetching) {
return <div>Loading...</div>;
}
return (
<SavedTaskForm
initialValues={{
title: data.title,
description: data.description,
webhookCallbackUrl: data.webhook_callback_url,
proxyLocation: data.proxy_location,
url: data.workflow_definition.blocks[0].url,
navigationGoal: data.workflow_definition.blocks[0].navigation_goal,
dataExtractionGoal:
data.workflow_definition.blocks[0].data_extraction_goal,
extractedInformationSchema:
data.workflow_definition.blocks[0].data_schema,
navigationPayload: data.workflow_definition.parameters[0].default_value,
}}
/>
);
}
export { CreateNewTaskFormPage };

View File

@@ -0,0 +1,11 @@
import { Outlet } from "react-router-dom";
function CreateNewTaskLayout() {
return (
<main className="max-w-6xl mx-auto px-8">
<Outlet />
</main>
);
}
export { CreateNewTaskLayout };

View File

@@ -0,0 +1,144 @@
import { getClient } from "@/api/AxiosClient";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
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 { toast } from "@/components/ui/use-toast";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { DotsHorizontalIcon, ReloadIcon } from "@radix-ui/react-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
type Props = {
workflowId: string;
title: string;
description: string;
url: string;
};
function SavedTaskCard({ workflowId, title, url, description }: Props) {
const [open, setOpen] = useState(false);
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const navigate = useNavigate();
const deleteTaskMutation = useMutation({
mutationFn: async (id: string) => {
const client = await getClient(credentialGetter);
return client
.delete(`/workflows/${id}`)
.then((response) => response.data);
},
onError: (error) => {
toast({
variant: "destructive",
title: "There was an error while deleting the template",
description: error.message,
});
setOpen(false);
},
onSuccess: () => {
toast({
title: "Template deleted",
description: "Template deleted successfully",
});
queryClient.invalidateQueries({
queryKey: ["workflows"],
});
setOpen(false);
navigate("/create");
},
});
return (
<Card>
<CardHeader>
<CardTitle className="flex justify-between items-center">
<span className="overflow-hidden text-ellipsis whitespace-nowrap ">
{title}
</span>
<Dialog open={open} onOpenChange={setOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<DotsHorizontalIcon className="cursor-pointer" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Template Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DialogTrigger asChild>
<DropdownMenuItem
onSelect={() => {
setOpen(true);
}}
>
Delete Template
</DropdownMenuItem>
</DialogTrigger>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
Are you sure you want to delete this task template?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
deleteTaskMutation.mutate(workflowId);
}}
disabled={deleteTaskMutation.isPending}
>
{deleteTaskMutation.isPending && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardTitle>
<CardDescription className="overflow-hidden text-ellipsis whitespace-nowrap">
{url}
</CardDescription>
</CardHeader>
<CardContent
className="h-48 overflow-scroll hover:bg-muted/40 cursor-pointer"
onClick={() => {
navigate(workflowId);
}}
>
{description}
</CardContent>
</Card>
);
}
export { SavedTaskCard };

View File

@@ -0,0 +1,452 @@
import { getClient } from "@/api/AxiosClient";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useToast } from "@/components/ui/use-toast";
import { useApiCredential } from "@/hooks/useApiCredential";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { apiBaseUrl } from "@/util/env";
import { zodResolver } from "@hookform/resolvers/zod";
import { InfoCircledIcon, ReloadIcon } from "@radix-ui/react-icons";
import { ToastAction } from "@radix-ui/react-toast";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import fetchToCurl from "fetch-to-curl";
import { useForm, useFormState } from "react-hook-form";
import { Link, useParams } from "react-router-dom";
import { stringify as convertToYAML } from "yaml";
import { z } from "zod";
import {
dataExtractionGoalDescription,
extractedInformationSchemaDescription,
navigationGoalDescription,
navigationPayloadDescription,
urlDescription,
webhookCallbackUrlDescription,
} from "../data/descriptionHelperContent";
const savedTaskFormSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string(),
url: z.string().url({
message: "Invalid URL",
}),
proxyLocation: z.string().or(z.null()).optional(),
webhookCallbackUrl: z.string().or(z.null()).optional(), // url maybe, but shouldn't be validated as one
navigationGoal: z.string().or(z.null()).optional(),
dataExtractionGoal: z.string().or(z.null()).optional(),
navigationPayload: z.string().or(z.null()).optional(),
extractedInformationSchema: z.string().or(z.null()).optional(),
});
export type SavedTaskFormValues = z.infer<typeof savedTaskFormSchema>;
type Props = {
initialValues: SavedTaskFormValues;
};
function createTaskRequestObject(formValues: SavedTaskFormValues) {
return {
url: formValues.url,
webhook_callback_url: formValues.webhookCallbackUrl ?? "",
navigation_goal: formValues.navigationGoal ?? "",
data_extraction_goal: formValues.dataExtractionGoal ?? "",
proxy_location: formValues.proxyLocation,
error_code_mapping: null,
navigation_payload: formValues.navigationPayload,
extracted_information_schema: formValues.extractedInformationSchema,
};
}
function createTaskTemplateRequestObject(values: SavedTaskFormValues) {
return {
title: values.title,
description: values.description,
webhook_callback_url: values.webhookCallbackUrl,
proxy_location: values.proxyLocation,
workflow_definition: {
parameters: [
{
parameter_type: "workflow",
workflow_parameter_type: "json",
key: "navigation_payload",
default_value: JSON.stringify(values.navigationPayload),
},
],
blocks: [
{
block_type: "task",
label: "Task 1",
url: values.url,
navigation_goal: values.navigationGoal,
data_extraction_goal: values.dataExtractionGoal,
data_schema: values.extractedInformationSchema,
},
],
},
};
}
function SavedTaskForm({ initialValues }: Props) {
const queryClient = useQueryClient();
const { toast } = useToast();
const credentialGetter = useCredentialGetter();
const apiCredential = useApiCredential();
const { template } = useParams();
const form = useForm<SavedTaskFormValues>({
resolver: zodResolver(savedTaskFormSchema),
defaultValues: initialValues,
});
const { isDirty } = useFormState({ control: form.control });
const createTaskMutation = useMutation({
mutationFn: async (formValues: SavedTaskFormValues) => {
const taskRequest = createTaskRequestObject(formValues);
const client = await getClient(credentialGetter);
return client.post<
ReturnType<typeof createTaskRequestObject>,
{ data: { task_id: string } }
>("/tasks", taskRequest);
},
onError: (error) => {
toast({
variant: "destructive",
title: "Error",
description: error.message,
});
},
onSuccess: (response) => {
toast({
title: "Task Created",
description: `${response.data.task_id} created successfully.`,
action: (
<ToastAction altText="View">
<Button asChild>
<Link to={`/tasks/${response.data.task_id}`}>View</Link>
</Button>
</ToastAction>
),
});
queryClient.invalidateQueries({
queryKey: ["tasks"],
});
},
});
const saveTaskMutation = useMutation({
mutationFn: async (formValues: SavedTaskFormValues) => {
const saveTaskRequest = createTaskTemplateRequestObject(formValues);
const client = await getClient(credentialGetter);
const yaml = convertToYAML(saveTaskRequest);
return client
.put(`/workflows/${template}`, 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: "Changes saved",
description: "Changes saved successfully",
});
queryClient.invalidateQueries({
queryKey: ["workflows", template],
});
},
});
function onSubmit(values: SavedTaskFormValues) {
createTaskMutation.mutate(values);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title *</FormLabel>
<FormControl>
<Input placeholder="Title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder="Description" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>
<div className="flex gap-2">
URL *
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InfoCircledIcon />
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">
<p>{urlDescription}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="webhookCallbackUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
<div className="flex gap-2">
Webhook Callback URL
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InfoCircledIcon />
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">
<p>{webhookCallbackUrlDescription}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</FormLabel>
<FormControl>
<Input
placeholder="example.com"
{...field}
value={field.value === null ? "" : field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="navigationGoal"
render={({ field }) => (
<FormItem>
<FormLabel>
<div className="flex gap-2">
Navigation Goal
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InfoCircledIcon />
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">
<p>{navigationGoalDescription}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</FormLabel>
<FormControl>
<Textarea
rows={5}
placeholder="Navigation Goal"
{...field}
value={field.value === null ? "" : field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dataExtractionGoal"
render={({ field }) => (
<FormItem>
<FormLabel>
<div className="flex gap-2">
Data Extraction Goal
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InfoCircledIcon />
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">
<p>{dataExtractionGoalDescription}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</FormLabel>
<FormControl>
<Textarea
rows={5}
placeholder="Data Extraction Goal"
{...field}
value={field.value === null ? "" : field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="navigationPayload"
render={({ field }) => (
<FormItem>
<FormLabel>
<div className="flex gap-2">
Navigation Payload
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InfoCircledIcon />
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">
<p>{navigationPayloadDescription}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</FormLabel>
<FormControl>
<Textarea
rows={5}
placeholder="Navigation Payload"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="extractedInformationSchema"
render={({ field }) => (
<FormItem>
<FormLabel>
<div className="flex gap-2">
Extracted Information Schema
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InfoCircledIcon />
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">
<p>{extractedInformationSchemaDescription}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</FormLabel>
<FormControl>
<Textarea
placeholder="Extracted Information Schema"
rows={5}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={async () => {
const curl = fetchToCurl({
method: "POST",
url: `${apiBaseUrl}/tasks`,
body: createTaskRequestObject(form.getValues()),
headers: {
"Content-Type": "application/json",
"x-api-key": apiCredential ?? "<your-api-key>",
},
});
await navigator.clipboard.writeText(curl);
toast({
title: "Copied cURL",
description: "cURL copied to clipboard",
});
}}
>
Copy cURL
</Button>
{isDirty && (
<Button
type="button"
variant="secondary"
onClick={() => {
saveTaskMutation.mutate(form.getValues());
}}
disabled={saveTaskMutation.isPending}
>
{saveTaskMutation.isPending && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}
Save Changes
</Button>
)}
<Button type="submit" disabled={createTaskMutation.isPending}>
{createTaskMutation.isPending && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}
Run Task
</Button>
</div>
</form>
</Form>
);
}
export { SavedTaskForm };

View File

@@ -0,0 +1,134 @@
import { getClient } from "@/api/AxiosClient";
import { queryClient } from "@/api/QueryClient";
import { WorkflowApiResponse } from "@/api/types";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { toast } from "@/components/ui/use-toast";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { PlusIcon, ReloadIcon } from "@radix-ui/react-icons";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { stringify as convertToYAML } from "yaml";
import { SavedTaskCard } from "./SavedTaskCard";
function createEmptyTaskTemplate() {
return {
title: "New Template",
description: "",
webhook_callback_url: null,
proxy_location: "NONE",
workflow_definition: {
parameters: [
{
parameter_type: "workflow",
workflow_parameter_type: "json",
key: "navigation_payload",
default_value: JSON.stringify({}),
},
],
blocks: [
{
block_type: "task",
label: "New Template",
url: "https://example.com",
navigation_goal: "",
data_extraction_goal: null,
data_schema: null,
},
],
},
};
}
function SavedTasks() {
const credentialGetter = useCredentialGetter();
const navigate = useNavigate();
const { data } = useQuery<Array<WorkflowApiResponse>>({
queryKey: ["workflows"],
queryFn: async () => {
const client = await getClient(credentialGetter);
return client.get("/workflows").then((response) => response.data);
},
});
const mutation = useMutation({
mutationFn: async () => {
const request = createEmptyTaskTemplate();
const client = await getClient(credentialGetter);
const yaml = convertToYAML(request);
return client
.post<string, { data: { workflow_permanent_id: string } }>(
"/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: (response) => {
toast({
title: "New template created",
description: "Your template was created successfully",
});
queryClient.invalidateQueries({
queryKey: ["workflows"],
});
navigate(`/create/${response.workflow_permanent_id}`);
},
});
return (
<div className="grid grid-cols-4 gap-4">
<Card
onClick={() => {
if (mutation.isPending) {
return;
}
mutation.mutate();
}}
>
<CardHeader>
<CardTitle>New Template</CardTitle>
<CardDescription className="whitespace-nowrap overflow-hidden text-ellipsis">
Create your own template
</CardDescription>
</CardHeader>
<CardContent className="flex h-48 justify-center items-center hover:bg-muted/40 cursor-pointer">
{!mutation.isPending && <PlusIcon className="w-12 h-12" />}
{mutation.isPending && (
<ReloadIcon className="animate-spin w-12 h-12" />
)}
</CardContent>
</Card>
{data?.map((workflow) => {
return (
<SavedTaskCard
key={workflow.workflow_permanent_id}
workflowId={workflow.workflow_permanent_id}
title={workflow.title}
description={workflow.description}
url={workflow.workflow_definition.blocks[0]?.url ?? ""}
/>
);
})}
</div>
);
}
export { SavedTasks };

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const taskTemplateFormSchema = z.object({
title: z.string().min(1, "Title can't be empty"),
description: z.string(),
});
export type TaskTemplateFormValues = z.infer<typeof taskTemplateFormSchema>;

View File

@@ -0,0 +1,86 @@
import { SampleCase } from "../types";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { useNavigate } from "react-router-dom";
import { SavedTasks } from "./SavedTasks";
import { getSample } from "../data/sampleTaskData";
const templateSamples: {
[key in SampleCase]: {
title: string;
description: string;
};
} = {
blank: {
title: "Blank",
description: "Create task from a blank template",
},
geico: {
title: "Geico",
description: "Generate an auto insurance quote",
},
finditparts: {
title: "Finditparts",
description: "Find a product and add it to cart",
},
california_edd: {
title: "California_EDD",
description: "Fill the employer services online enrollment form",
},
bci_seguros: {
title: "bci_seguros",
description: "Generate an auto insurance quote",
},
};
function TaskTemplates() {
const navigate = useNavigate();
return (
<div className="max-w-6xl mx-auto px-8">
<section className="py-4">
<header>
<h1 className="text-3xl">Skyvern Templates</h1>
</header>
<Separator className="mt-2 mb-8" />
<div className="grid grid-cols-4 gap-4">
{Object.entries(templateSamples).map(([sampleKey, sample]) => {
return (
<Card key={sampleKey}>
<CardHeader>
<CardTitle>{sample.title}</CardTitle>
<CardDescription className="overflow-hidden text-ellipsis whitespace-nowrap">
{getSample(sampleKey as SampleCase).url}
</CardDescription>
</CardHeader>
<CardContent
className="h-48 hover:bg-muted/40 cursor-pointer"
onClick={() => {
navigate(sampleKey);
}}
>
{sample.description}
</CardContent>
</Card>
);
})}
</div>
</section>
<section className="py-4">
<header>
<h1 className="text-3xl">Your Templates</h1>
</header>
<Separator className="mt-2 mb-8" />
<SavedTasks />
</section>
</div>
);
}
export { TaskTemplates };

View File

@@ -1,5 +1,9 @@
import { SampleCase } from "../types";
export const blank = {
url: "https://www.example.com",
};
export const bci_seguros = {
url: "https://www.bciseguros.cl/nuestros_seguros/personas/seguro-automotriz/",
navigationGoal:
@@ -239,6 +243,26 @@ export const geico = {
},
};
export function getSample(sample: SampleCase) {
switch (sample) {
case "geico": {
return geico;
}
case "finditparts": {
return finditparts;
}
case "california_edd": {
return california_edd;
}
case "bci_seguros": {
return bci_seguros;
}
case "blank": {
return blank;
}
}
}
export function getSampleForInitialFormValues(sample: SampleCase) {
switch (sample) {
case "geico":
@@ -278,5 +302,10 @@ export function getSampleForInitialFormValues(sample: SampleCase) {
2,
),
};
case "blank": {
return {
...blank,
};
}
}
}

View File

@@ -2,6 +2,7 @@ import { StepApiResponse } from "@/api/types";
import { StatusBadge } from "@/components/StatusBadge";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { basicTimeFormat } from "@/util/timeFormat";
type Props = {
isFetching: boolean;
@@ -32,7 +33,7 @@ function StepInfo({ isFetching, stepProps }: Props) {
{isFetching ? (
<Skeleton className="h-4 w-40" />
) : stepProps ? (
<span>{stepProps.created_at}</span>
<span>{basicTimeFormat(stepProps.created_at)}</span>
) : null}
</div>
</div>

View File

@@ -187,7 +187,26 @@ function TaskDetails() {
<Label className="w-40 shrink-0">Data Extraction Goal</Label>
<Textarea
rows={5}
value={task.request.data_extraction_goal}
value={task.request.data_extraction_goal ?? ""}
readOnly
/>
</div>
<div className="flex items-center">
<Label className="w-40 shrink-0">
Extracted Information Schema
</Label>
<Textarea
rows={5}
value={
typeof task.request.extracted_information_schema ===
"object"
? JSON.stringify(
task.request.extracted_information_schema,
null,
2,
)
: task.request.extracted_information_schema
}
readOnly
/>
</div>

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 };

View File

@@ -60,7 +60,7 @@ function LatestScreenshot({ id }: Props) {
return Promise.reject("No screenshots found");
},
refetchInterval: 5000,
refetchInterval: 10000,
placeholderData: keepPreviousData,
});

View File

@@ -32,47 +32,45 @@ function QueuedTasks() {
},
});
if (tasks?.length === 0) {
return <div>No queued tasks</div>;
}
return (
<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 ? (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={3}>No queued tasks</TableCell>
<TableHead className="w-1/3">URL</TableHead>
<TableHead className="w-1/3">Status</TableHead>
<TableHead className="w-1/3">Created At</TableHead>
</TableRow>
) : (
tasks?.map((task) => {
return (
<TableRow
key={task.task_id}
className="cursor-pointer w-4 hover:bg-muted/50"
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>
</TableHeader>
<TableBody>
{tasks?.length === 0 ? (
<TableRow>
<TableCell colSpan={3}>No queued tasks</TableCell>
</TableRow>
) : (
tasks?.map((task) => {
return (
<TableRow
key={task.task_id}
className="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>
</div>
);
}

View File

@@ -46,7 +46,9 @@ function RunningTasks() {
}}
>
<CardHeader>
<CardTitle>{task.task_id}</CardTitle>
<CardTitle className="overflow-hidden text-ellipsis whitespace-nowrap">
{task.task_id}
</CardTitle>
<CardDescription className="whitespace-nowrap overflow-hidden text-ellipsis">
{task.request.url}
</CardDescription>

View File

@@ -1,5 +1,9 @@
export type SampleCase =
| "geico"
| "finditparts"
| "california_edd"
| "bci_seguros";
export const sampleCases = [
"blank",
"geico",
"finditparts",
"california_edd",
"bci_seguros",
] as const;
export type SampleCase = (typeof sampleCases)[number];