refactor(v4.0.0-alpha): beginning of a new era

This commit is contained in:
Amruth Pillai
2023-11-05 12:31:42 +01:00
parent 0ba6a444e2
commit 22933bd412
505 changed files with 81829 additions and 0 deletions

View File

@ -0,0 +1,315 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Check, DownloadSimple, Warning } from "@phosphor-icons/react";
import {
JsonResume,
JsonResumeParser,
LinkedIn,
LinkedInParser,
ReactiveResumeParser,
ReactiveResumeV3,
ReactiveResumeV3Parser,
} from "@reactive-resume/parser";
import { ResumeData } from "@reactive-resume/schema";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Label,
ScrollArea,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@reactive-resume/ui";
import { AnimatePresence } from "framer-motion";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z, ZodError } from "zod";
import { useToast } from "@/client/hooks/use-toast";
import { useImportResume } from "@/client/services/resume/import";
import { useDialog } from "@/client/stores/dialog";
enum ImportType {
"reactive-resume-json" = "reactive-resume-json",
"reactive-resume-v3-json" = "reactive-resume-v3-json",
"json-resume-json" = "json-resume-json",
"linkedin-data-export-zip" = "linkedin-data-export-zip",
}
const formSchema = z.object({
file: z.instanceof(File),
type: z.nativeEnum(ImportType),
});
type FormValues = z.infer<typeof formSchema>;
type ValidationResult =
| {
isValid: false;
errors: string;
}
| {
isValid: true;
type: ImportType;
result: ResumeData | ReactiveResumeV3 | LinkedIn | JsonResume;
};
export const ImportDialog = () => {
const { toast } = useToast();
const { isOpen, close } = useDialog("import");
const { importResume, loading, error: importError } = useImportResume();
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
});
const filetype = form.watch("type");
useEffect(() => {
if (isOpen) onReset();
}, [isOpen]);
useEffect(() => {
form.reset({ file: undefined, type: filetype });
setValidationResult(null);
}, [filetype]);
const accept = useMemo(() => {
if (!filetype) return "";
if (filetype.includes("json")) return ".json";
if (filetype.includes("zip")) return ".zip";
return "";
}, [filetype]);
const onValidate = async () => {
const { file, type } = formSchema.parse(form.getValues());
try {
if (type === ImportType["reactive-resume-json"]) {
const parser = new ReactiveResumeParser();
const data = await parser.readFile(file);
const result = parser.validate(data);
setValidationResult({ isValid: true, type, result });
}
if (type === ImportType["reactive-resume-v3-json"]) {
const parser = new ReactiveResumeV3Parser();
const data = await parser.readFile(file);
const result = parser.validate(data);
setValidationResult({ isValid: true, type, result });
}
if (type === ImportType["json-resume-json"]) {
const parser = new JsonResumeParser();
const data = await parser.readFile(file);
const result = parser.validate(data);
setValidationResult({ isValid: true, type, result });
}
if (type === ImportType["linkedin-data-export-zip"]) {
const parser = new LinkedInParser();
const data = await parser.readFile(file);
const result = await parser.validate(data);
setValidationResult({ isValid: true, type, result });
}
} catch (error) {
if (error instanceof ZodError) {
setValidationResult({
isValid: false,
errors: error.toString(),
});
toast({
variant: "error",
icon: <Warning size={16} weight="bold" />,
title: "An error occurred while validating the file.",
});
}
}
};
const onImport = async () => {
const { type } = formSchema.parse(form.getValues());
if (!validationResult?.isValid || validationResult.type !== type) return;
try {
if (type === ImportType["reactive-resume-json"]) {
const parser = new ReactiveResumeParser();
const data = parser.convert(validationResult.result as ResumeData);
await importResume({ data });
}
if (type === ImportType["reactive-resume-v3-json"]) {
const parser = new ReactiveResumeV3Parser();
const data = parser.convert(validationResult.result as ReactiveResumeV3);
await importResume({ data });
}
if (type === ImportType["json-resume-json"]) {
const parser = new JsonResumeParser();
const data = parser.convert(validationResult.result as JsonResume);
await importResume({ data });
}
if (type === ImportType["linkedin-data-export-zip"]) {
const parser = new LinkedInParser();
const data = parser.convert(validationResult.result as LinkedIn);
await importResume({ data });
}
close();
} catch (error) {
toast({
variant: "error",
icon: <Warning size={16} weight="bold" />,
title: "An error occurred while importing your resume.",
description: importError?.message,
});
}
};
const onReset = () => {
form.reset();
setValidationResult(null);
};
return (
<Dialog open={isOpen} onOpenChange={close}>
<DialogContent>
<Form {...form}>
<form className="space-y-4">
<DialogHeader>
<DialogTitle>
<div className="flex items-center space-x-2.5">
<DownloadSimple />
<h2>Import an existing resume</h2>
</div>
</DialogTitle>
<DialogDescription>
Upload a file from an external source to parse an existing resume and import it into
Reactive Resume for easier editing.
</DialogDescription>
</DialogHeader>
<FormField
name="type"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Please select a file type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="reactive-resume-json">
Reactive Resume (.json)
</SelectItem>
<SelectItem value="reactive-resume-v3-json">
Reactive Resume v3 (.json)
</SelectItem>
<SelectItem value="json-resume-json">JSON Resume (.json)</SelectItem>
<SelectItem value="linkedin-data-export-zip">
LinkedIn Data Export (.zip)
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="file"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>File</FormLabel>
<FormControl>
<Input
type="file"
key={accept}
accept={accept}
onChange={(event) => {
if (!event.target.files || !event.target.files.length) return;
field.onChange(event.target.files[0]);
}}
/>
</FormControl>
<FormMessage />
{accept && <FormDescription>Accepts only {accept} files</FormDescription>}
</FormItem>
)}
/>
{validationResult?.isValid === false && validationResult.errors !== undefined && (
<div className="space-y-2">
<Label className="text-error">Errors during Validation</Label>
<ScrollArea orientation="vertical" className="h-[180px]">
<div className="whitespace-pre-wrap rounded bg-secondary-accent p-4 font-mono text-xs leading-relaxed">
{JSON.stringify(validationResult.errors, null, 4)}
</div>
</ScrollArea>
</div>
)}
<DialogFooter>
<AnimatePresence presenceAffectsLayout>
{(!validationResult ?? false) && (
<Button type="button" onClick={onValidate}>
Validate
</Button>
)}
{validationResult !== null && !validationResult.isValid && (
<Button type="button" variant="secondary" onClick={onReset}>
Reset
</Button>
)}
{validationResult !== null && validationResult.isValid && (
<>
<Button type="button" onClick={onImport} disabled={loading}>
Import
</Button>
<Button disabled type="button" variant="success">
<Check size={16} weight="bold" className="mr-2" />
Validated
</Button>
</>
)}
</AnimatePresence>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,247 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { MagicWand, Plus } from "@phosphor-icons/react";
import { createResumeSchema, ResumeDto } from "@reactive-resume/dto";
import { idSchema } from "@reactive-resume/schema";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Tooltip,
} from "@reactive-resume/ui";
import { generateRandomName, kebabCase } from "@reactive-resume/utils";
import { AxiosError } from "axios";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useToast } from "@/client/hooks/use-toast";
import { useCreateResume, useDeleteResume, useUpdateResume } from "@/client/services/resume";
import { useImportResume } from "@/client/services/resume/import";
import { useDialog } from "@/client/stores/dialog";
const formSchema = createResumeSchema.extend({ id: idSchema.optional() });
type FormValues = z.infer<typeof formSchema>;
export const ResumeDialog = () => {
const { toast } = useToast();
const { isOpen, mode, payload, close } = useDialog<ResumeDto>("resume");
const isCreate = mode === "create";
const isUpdate = mode === "update";
const isDelete = mode === "delete";
const isDuplicate = mode === "duplicate";
const { createResume, loading: createLoading } = useCreateResume();
const { updateResume, loading: updateLoading } = useUpdateResume();
const { deleteResume, loading: deleteLoading } = useDeleteResume();
const { importResume: duplicateResume, loading: duplicateLoading } = useImportResume();
const loading = createLoading || updateLoading || deleteLoading || duplicateLoading;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { title: "", slug: "" },
});
useEffect(() => {
if (isOpen) onReset();
}, [isOpen, payload]);
useEffect(() => {
const slug = kebabCase(form.watch("title"));
form.setValue("slug", slug);
}, [form.watch("title")]);
const onSubmit = async (values: FormValues) => {
try {
if (isCreate) {
await createResume({ slug: values.slug, title: values.title, visibility: "private" });
}
if (isUpdate) {
if (!payload.item?.id) return;
await updateResume({
...payload.item,
title: values.title,
slug: values.slug,
});
}
if (isDuplicate) {
if (!payload.item?.id) return;
await duplicateResume({
title: values.title,
slug: values.slug,
data: payload.item.data,
});
}
if (isDelete) {
if (!payload.item?.id) return;
await deleteResume({ id: payload.item?.id });
}
close();
} catch (error) {
if (error instanceof AxiosError) {
const message = error.response?.data?.message || error.message;
toast({
variant: "error",
title: "An error occurred while trying process your request.",
description: message,
});
}
}
};
const onReset = () => {
if (isCreate) form.reset({ title: "", slug: "" });
if (isUpdate)
form.reset({ id: payload.item?.id, title: payload.item?.title, slug: payload.item?.slug });
if (isDuplicate)
form.reset({ title: `${payload.item?.title} (Copy)`, slug: `${payload.item?.slug}-copy` });
if (isDelete)
form.reset({ id: payload.item?.id, title: payload.item?.title, slug: payload.item?.slug });
};
const onGenerateRandomName = () => {
const name = generateRandomName();
form.setValue("title", name);
form.setValue("slug", kebabCase(name));
};
if (isDelete) {
return (
<AlertDialog open={isOpen} onOpenChange={close}>
<AlertDialogContent>
<Form {...form}>
<form>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete your resume?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your resume and cannot
be recovered.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="error" onClick={form.handleSubmit(onSubmit)}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={close}>
<DialogContent>
<Form {...form}>
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>
<div className="flex items-center space-x-2.5">
<Plus />
<h2>
{isCreate && "Create a new resume"}
{isUpdate && "Update an existing resume"}
{isDuplicate && "Duplicate an existing resume"}
</h2>
</div>
</DialogTitle>
<DialogDescription>
{isCreate && "Start building your resume by giving it a name."}
{isUpdate && "Changed your mind about the name? Give it a new one."}
{isDuplicate && "Give your old resume a new name."}
</DialogDescription>
</DialogHeader>
<FormField
name="title"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<div className="flex items-center justify-between gap-x-2">
<Input {...field} className="flex-1" />
{(isCreate || isDuplicate) && (
<Tooltip content="Generate a random name">
<Button
size="icon"
type="button"
variant="outline"
onClick={onGenerateRandomName}
>
<MagicWand />
</Button>
</Tooltip>
)}
</div>
</FormControl>
<FormDescription>
Tip: You can name the resume referring to the position you are applying for.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="slug"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" disabled={loading}>
{isCreate && "Create"}
{isUpdate && "Save Changes"}
{isDuplicate && "Duplicate"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};