From 1c6ed9bc6dcd0d3ee8eada003d3ab320fd1816b7 Mon Sep 17 00:00:00 2001 From: sabaimran <65192171+sabaimran@users.noreply.github.com> Date: Mon, 15 Jul 2024 09:12:33 -0700 Subject: [PATCH] Migrate the existing automations page to use React (#849) Migrates the Automations page to React, mostly keeping the overall design consistent with organization. Use component library, with some changes in color. Add easier management with straightforward form and editing experience. Use system preference for determining dark mode if not explicitly set. --- .../app/automations/automations.module.css | 11 + .../automations/automationsLayout.module.css | 11 + src/interface/web/app/automations/layout.tsx | 28 + src/interface/web/app/automations/page.tsx | 983 ++++++++++++++++++ src/interface/web/app/common/utils.ts | 24 + .../web/app/components/navMenu/navMenu.tsx | 25 +- .../app/components/shareLink/shareLink.tsx | 50 +- src/interface/web/components/ui/alert.tsx | 59 ++ src/interface/web/components/ui/form.tsx | 178 ++++ src/interface/web/components/ui/select.tsx | 160 +++ src/interface/web/components/ui/toast.tsx | 129 +++ src/interface/web/components/ui/toaster.tsx | 35 + src/interface/web/components/ui/use-toast.ts | 194 ++++ src/interface/web/package.json | 8 +- src/interface/web/yarn.lock | 62 +- 15 files changed, 1933 insertions(+), 24 deletions(-) create mode 100644 src/interface/web/app/automations/automations.module.css create mode 100644 src/interface/web/app/automations/automationsLayout.module.css create mode 100644 src/interface/web/app/automations/layout.tsx create mode 100644 src/interface/web/app/automations/page.tsx create mode 100644 src/interface/web/components/ui/alert.tsx create mode 100644 src/interface/web/components/ui/form.tsx create mode 100644 src/interface/web/components/ui/select.tsx create mode 100644 src/interface/web/components/ui/toast.tsx create mode 100644 src/interface/web/components/ui/toaster.tsx create mode 100644 src/interface/web/components/ui/use-toast.ts diff --git a/src/interface/web/app/automations/automations.module.css b/src/interface/web/app/automations/automations.module.css new file mode 100644 index 00000000..206d4c36 --- /dev/null +++ b/src/interface/web/app/automations/automations.module.css @@ -0,0 +1,11 @@ +div.automationsLayout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +@media screen and (max-width: 768px) { + div.automationsLayout { + grid-template-columns: 1fr; + } +} diff --git a/src/interface/web/app/automations/automationsLayout.module.css b/src/interface/web/app/automations/automationsLayout.module.css new file mode 100644 index 00000000..6e27ee7a --- /dev/null +++ b/src/interface/web/app/automations/automationsLayout.module.css @@ -0,0 +1,11 @@ +.automationsLayout { + max-width: 70vw; + margin: auto; + margin-bottom: 2rem; +} + +@media screen and (max-width: 700px) { + .automationsLayout { + max-width: 90vw; + } +} diff --git a/src/interface/web/app/automations/layout.tsx b/src/interface/web/app/automations/layout.tsx new file mode 100644 index 00000000..23493c75 --- /dev/null +++ b/src/interface/web/app/automations/layout.tsx @@ -0,0 +1,28 @@ + +import type { Metadata } from "next"; +import NavMenu from '../components/navMenu/navMenu'; +import styles from './automationsLayout.module.css'; +import { Toaster } from "@/components/ui/toaster"; + + +export const metadata: Metadata = { + title: "Khoj AI - Automations", + description: "Use Autoomations with Khoj to simplify the process of running repetitive tasks.", + icons: { + icon: '/static/favicon.ico', + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+ + {children} + +
+ ); +} diff --git a/src/interface/web/app/automations/page.tsx b/src/interface/web/app/automations/page.tsx new file mode 100644 index 00000000..8c20918e --- /dev/null +++ b/src/interface/web/app/automations/page.tsx @@ -0,0 +1,983 @@ +'use client' + +import useSWR from 'swr'; +import Loading, { InlineLoading } from '../components/loading/loading'; +import { + Card, + CardDescription, + CardContent, + CardFooter, + CardHeader, + CardTitle + +} from '@/components/ui/card'; +import { Button, buttonVariants } from '@/components/ui/button'; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface AutomationsData { + id: number; + subject: string; + query_to_run: string; + scheduling_request: string; + schedule: string; + crontime: string; + next: string; +} + +import cronstrue from 'cronstrue'; +import { zodResolver } from "@hookform/resolvers/zod" +import { UseFormReturn, useForm } from "react-hook-form" +import { z } from "zod" +import { Suspense, useEffect, useState } from 'react'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'; +import { DialogTitle } from '@radix-ui/react-dialog'; +import { Textarea } from '@/components/ui/textarea'; +import { LocationData, useIPLocationData } from '../common/utils'; + +import styles from './automations.module.css'; +import ShareLink from '../components/shareLink/shareLink'; +import { useSearchParams } from 'next/navigation'; +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'; +import { Clock, DotsThreeVertical, Envelope, Info, MapPinSimple, Pencil, Play, Plus, Trash } from '@phosphor-icons/react'; +import { useAuthenticatedData } from '../common/auth'; +import LoginPrompt from '../components/loginPrompt/loginPrompt'; +import { useToast } from '@/components/ui/use-toast'; +import { ToastAction } from '@/components/ui/toast'; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" + +const automationsFetcher = () => window.fetch('/api/automations').then(res => res.json()).catch(err => console.log(err)); + +// Standard cron format: minute hour dayOfMonth month dayOfWeek + +function getEveryBlahFromCron(cron: string) { + const cronParts = cron.split(' '); + const dayOfMonth = cronParts[2]; + const dayOfWeek = cronParts[4]; + + // If both dayOfMonth and dayOfWeek are '*', it runs every day + if (dayOfMonth === '*' && dayOfWeek === '*') { + return 'Day'; + } + // If dayOfWeek is not '*', it suggests a specific day of the week, implying a weekly schedule + else if (dayOfWeek !== '*') { + return 'Week'; + } + // If dayOfMonth is not '*', it suggests a specific day of the month, implying a monthly schedule + else if (dayOfMonth !== '*') { + return 'Month'; + } + // Default to 'Day' if none of the above conditions are met + else { + return 'Day'; + } +} + +function getDayOfWeekFromCron(cron: string) { + const cronParts = cron.split(' '); + if (cronParts[3] === '*' && cronParts[4] !== '*') { + return Number(cronParts[4]); + } + + return undefined; +} + +function getTimeRecurrenceFromCron(cron: string) { + const cronParts = cron.split(' '); + const hour = cronParts[1]; + const minute = cronParts[0]; + const period = Number(hour) >= 12 ? 'PM' : 'AM'; + + let friendlyHour = Number(hour) > 12 ? Number(hour) - 12 : hour; + if (friendlyHour === '00') { + friendlyHour = '12'; + } + + let friendlyMinute = minute; + if (Number(friendlyMinute) < 10 && friendlyMinute !== '00') { + friendlyMinute = `0${friendlyMinute}`; + } + return `${friendlyHour}:${friendlyMinute} ${period}`; +} + +function getDayOfMonthFromCron(cron: string) { + const cronParts = cron.split(' '); + + return String(cronParts[2]); +} + +function cronToHumanReadableString(cron: string) { + return cronstrue.toString(cron); +} + +const frequencies = ['Day', 'Week', 'Month']; + +const daysOfMonth = Array.from({ length: 31 }, (_, i) => String(i + 1)); + +const weekDays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +const timeOptions: string[] = []; + +const timePeriods = ['AM', 'PM']; + +// Populate the time selector with options for each hour of the day +for (var i = 0; i < timePeriods.length; i++) { + for (var hour = 0; hour < 12; hour++) { + for (var minute = 0; minute < 60; minute += 15) { + // Ensure all minutes are two digits + const paddedMinute = String(minute).padStart(2, '0'); + const friendlyHour = hour === 0 ? 12 : hour; + timeOptions.push(`${friendlyHour}:${paddedMinute} ${timePeriods[i]}`); + } + } +} + +const timestamp = Date.now(); + +const suggestedAutomationsMetadata: AutomationsData[] = [ + { + "subject": "Weekly Newsletter", + "query_to_run": "Compile a message including: 1. A recap of news from last week 2. An at-home workout I can do before work 3. A quote to inspire me for the week ahead", + "schedule": "9AM every Monday", + "next": "Next run at 9AM on Monday", + "crontime": "0 9 * * 1", + "id": timestamp, + "scheduling_request": "", + }, + { + "subject": "Daily Bedtime Story", + "query_to_run": "Compose a bedtime story that a five-year-old might enjoy. It should not exceed five paragraphs. Appeal to the imagination, but weave in learnings.", + "schedule": "9PM every night", + "next": "Next run at 9PM today", + "crontime": "0 21 * * *", + "id": timestamp + 1, + "scheduling_request": "", + }, + { + "subject": "Front Page of Hacker News", + "query_to_run": "Summarize the top 5 posts from https://news.ycombinator.com/best and share them with me, including links", + "schedule": "9PM on every Wednesday", + "next": "Next run at 9PM on Wednesday", + "crontime": "0 21 * * 3", + "id": timestamp + 2, + "scheduling_request": "", + }, + { + "subject": "Market Summary", + "query_to_run": "Get the market summary for today and share it with me. Focus on tech stocks and the S&P 500.", + "schedule": "9AM on every weekday", + "next": "Next run at 9AM on Monday", + "crontime": "0 9 * * *", + "id": timestamp + 3, + "scheduling_request": "", + } +]; + +function createShareLink(automation: AutomationsData) { + const encodedSubject = encodeURIComponent(automation.subject); + const encodedQuery = encodeURIComponent(automation.query_to_run); + const encodedCrontime = encodeURIComponent(automation.crontime); + + const shareLink = `${window.location.origin}/automations?subject=${encodedSubject}&query=${encodedQuery}&crontime=${encodedCrontime}`; + + return shareLink; +} + +function deleteAutomation(automationId: string, setIsDeleted: (isDeleted: boolean) => void) { + fetch(`/api/automation?automation_id=${automationId}`, { method: 'DELETE' } + ).then(response => response.json()) + .then(data => { + setIsDeleted(true); + }); +} + +function sendAPreview(automationId: string, setToastMessage: (toastMessage: string) => void) { + fetch(`/api/trigger/automation?automation_id=${automationId}`, { method: 'POST' }) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response; + }) + .then(automations => { + setToastMessage("Automation triggered. Check your inbox in a few minutes!"); + }) + .catch(error => { + setToastMessage("Sorry, something went wrong. Try again later."); + }) +} + +interface AutomationsCardProps { + automation: AutomationsData; + locationData?: LocationData | null; + suggestedCard?: boolean; + setNewAutomationData?: (data: AutomationsData) => void; + isLoggedIn: boolean; + setShowLoginPrompt: (showLoginPrompt: boolean) => void; +} + + +function AutomationsCard(props: AutomationsCardProps) { + const [isEditing, setIsEditing] = useState(false); + const [updatedAutomationData, setUpdatedAutomationData] = useState(null); + const [isDeleted, setIsDeleted] = useState(false); + const [toastMessage, setToastMessage] = useState(''); + const { toast } = useToast(); + + const automation = props.automation; + + useEffect(() => { + const toastTitle = `Automation: ${updatedAutomationData?.subject || automation.subject}`; + if (toastMessage) { + toast({ + title: toastTitle, + description: toastMessage, + action: ( + Ok + ), + }) + setToastMessage(''); + } + }, [toastMessage]); + + if (isDeleted) { + return null; + } + + return ( +
+ + + + {updatedAutomationData?.subject || automation.subject} + + + + + + + { + !props.suggestedCard && ( + { + setIsEditing(open); + }} + > + + + + + Edit Automation + + + + ) + } + { + !props.suggestedCard && ( + + ) + } + + + + + + {updatedAutomationData?.schedule || cronToHumanReadableString(automation.crontime)} + + + + {updatedAutomationData?.query_to_run || automation.query_to_run} + + + { + props.suggestedCard && props.setNewAutomationData && ( + { + setIsEditing(open); + }} + > + + + + + Add Automation + + + + ) + } + { + navigator.clipboard.writeText(createShareLink(automation)); + }} /> + + +
+ ) +} + +interface SharedAutomationCardProps { + locationData?: LocationData | null; + setNewAutomationData: (data: AutomationsData) => void; + isLoggedIn: boolean; + setShowLoginPrompt: (showLoginPrompt: boolean) => void; +} + +function SharedAutomationCard(props: SharedAutomationCardProps) { + const searchParams = useSearchParams(); + const [isCreating, setIsCreating] = useState(true); + + const subject = searchParams.get('subject'); + const query = searchParams.get('query'); + const crontime = searchParams.get('crontime'); + + if (!subject || !query || !crontime) { + return null; + } + + const automation: AutomationsData = { + id: 0, + subject: decodeURIComponent(subject), + query_to_run: decodeURIComponent(query), + scheduling_request: '', + schedule: cronToHumanReadableString(decodeURIComponent(crontime)), + crontime: decodeURIComponent(crontime), + next: '', + } + + return ( + { + setIsCreating(open); + }} + > + + + + Create Automation + + + + ) +} + +const EditAutomationSchema = z.object({ + subject: z.optional(z.string()), + everyBlah: z.string({ required_error: "Every is required" }), + dayOfWeek: z.optional(z.number()), + dayOfMonth: z.optional(z.string()), + timeRecurrence: z.string({ required_error: "Time Recurrence is required" }), + queryToRun: z.string({ required_error: "Query to Run is required" }), +}); + +interface EditCardProps { + automation?: AutomationsData; + setIsEditing: (completed: boolean) => void; + setUpdatedAutomationData: (data: AutomationsData) => void; + locationData?: LocationData | null; + createNew?: boolean; + isLoggedIn: boolean; + setShowLoginPrompt: (showLoginPrompt: boolean) => void; +} + +function EditCard(props: EditCardProps) { + const automation = props.automation; + + const form = useForm>({ + resolver: zodResolver(EditAutomationSchema), + defaultValues: { + subject: automation?.subject, + everyBlah: (automation?.crontime ? getEveryBlahFromCron(automation.crontime) : 'Day'), + dayOfWeek: (automation?.crontime ? getDayOfWeekFromCron(automation.crontime) : undefined), + timeRecurrence: (automation?.crontime ? getTimeRecurrenceFromCron(automation.crontime) : '12:00 PM'), + dayOfMonth: (automation?.crontime ? getDayOfMonthFromCron(automation.crontime) : "1"), + queryToRun: automation?.query_to_run, + }, + }) + + const onSubmit = (values: z.infer) => { + const cronFrequency = convertFrequencyToCron(values.everyBlah, values.timeRecurrence, values.dayOfWeek, values.dayOfMonth); + + let updateQueryUrl = `/api/automation?`; + + updateQueryUrl += `q=${values.queryToRun}`; + + if (automation?.id && !props.createNew) { + updateQueryUrl += `&automation_id=${automation.id}`; + } + + if (values.subject) { + updateQueryUrl += `&subject=${values.subject}`; + } + + updateQueryUrl += `&crontime=${cronFrequency}`; + + if (props.locationData) { + updateQueryUrl += `&city=${props.locationData.city}`; + updateQueryUrl += `®ion=${props.locationData.region}`; + updateQueryUrl += `&country=${props.locationData.country}`; + updateQueryUrl += `&timezone=${props.locationData.timezone}`; + } + + let method = props.createNew ? 'POST' : 'PUT'; + + fetch(updateQueryUrl, { method: method }) + .then(response => response.json()) + .then + ((data: AutomationsData) => { + props.setIsEditing(false); + props.setUpdatedAutomationData({ + id: data.id, + subject: data.subject || '', + query_to_run: data.query_to_run, + scheduling_request: data.scheduling_request, + schedule: cronToHumanReadableString(data.crontime), + crontime: data.crontime, + next: data.next, + }); + }); + } + + function convertFrequencyToCron(frequency: string, timeRecurrence: string, dayOfWeek?: number, dayOfMonth?: string) { + let cronString = ''; + + const minutes = timeRecurrence.split(':')[1].split(' ')[0]; + const period = timeRecurrence.split(':')[1].split(' ')[1]; + const rawHourAsNumber = Number(timeRecurrence.split(':')[0]); + const hours = period === 'PM' && (rawHourAsNumber < 12) ? String(rawHourAsNumber + 12) : rawHourAsNumber; + + const dayOfWeekNumber = dayOfWeek ? dayOfWeek : '*'; + + switch (frequency) { + case 'Day': + cronString = `${minutes} ${hours} * * *`; + break; + case 'Week': + cronString = `${minutes} ${hours} * * ${dayOfWeekNumber}`; + break; + case 'Month': + cronString = `${minutes} ${hours} ${dayOfMonth} * *`; + break; + } + + return cronString; + } + + return ( + + ) + +} + +interface AutomationModificationFormProps { + form: UseFormReturn>; + onSubmit: (values: z.infer) => void; + create?: boolean; + isLoggedIn: boolean; + setShowLoginPrompt: (showLoginPrompt: boolean) => void; +} + +function AutomationModificationForm(props: AutomationModificationFormProps) { + + const [isSaving, setIsSaving] = useState(false); + const { errors } = props.form.formState; + + console.log(errors); + + function recommendationPill(recommendationText: string, onChange: (value: any, event: React.MouseEvent) => void) { + return ( + + ) + } + + const recommendationPills = [ + "Make a picture of", + "Generate a summary of", + "Create a newsletter of", + "Notify me when" + ]; + + return ( +
+ { + props.onSubmit(values); + setIsSaving(true); + })} className="space-y-8"> + { + !props.create && ( + ( + + Subject + + This is the subject of the email you will receive. + + + + + + {errors.subject && {errors.subject?.message}} + + )} + />) + } + + ( + + Frequency + + How frequently should this automation run? + + + + {errors.subject && {errors.everyBlah?.message}} + + )} + /> + { + props.form.watch('everyBlah') === 'Week' && ( + ( + + Day of Week + + + {errors.subject && {errors.dayOfWeek?.message}} + + )} + /> + ) + } + { + props.form.watch('everyBlah') === 'Month' && ( + ( + + Day of Month + + + {errors.subject && {errors.dayOfMonth?.message}} + + )} + /> + ) + } + { + ( + props.form.watch('everyBlah') === 'Day' || + props.form.watch('everyBlah') == 'Week' || + props.form.watch('everyBlah') == 'Month') && ( + ( + + Time + + On the days this automation runs, at what time should it run? + + + + {errors.subject && {errors.timeRecurrence?.message}} + + )} + /> + ) + } + ( + + Instructions + + What do you want Khoj to do? + + { + props.create && ( +
+ { + recommendationPills.map((recommendation) => recommendationPill(recommendation, field.onChange)) + } +
+ ) + } + +