mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 17:03:55 +10:00
feat(experience): add role progression to show career advancement within a company (#2761)
* feat(experience): add role progression support for career advancement * fix(experience): remove indent and border from role progression items * refactor(experience): enhance role progression UI and functionality - Updated the ExperienceItem component to improve role display and styling. - Refactored the CreateExperienceDialog and UpdateExperienceDialog to support role reordering using drag-and-drop. - Added role handling in JSON and ReactiveResume importers. - Enhanced experience schema to include roles, ensuring better data structure for career progression. - Improved overall user experience with clearer role management in the experience section. --------- Co-authored-by: root <root@reactive-resume-dev.one.one.one.one> Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
import { PencilSimpleLineIcon, PlusIcon, RowsIcon, TrashSimpleIcon } from "@phosphor-icons/react";
|
||||
import { AnimatePresence, Reorder, useDragControls } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { useFieldArray, useForm, useFormContext } from "react-hook-form";
|
||||
import type z from "zod";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
@@ -14,6 +16,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import type { DialogProps } from "@/dialogs/store";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import type { RoleItem } from "@/schema/resume/data";
|
||||
import { experienceItemSchema } from "@/schema/resume/data";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
@@ -37,6 +40,7 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
|
||||
period: data?.item?.period ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
description: data?.item?.description ?? "",
|
||||
roles: data?.item?.roles ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,6 +53,7 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
|
||||
draft.sections.experience.items.push(formData);
|
||||
}
|
||||
});
|
||||
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -99,6 +104,7 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
|
||||
period: data.item.period,
|
||||
website: data.item.website,
|
||||
description: data.item.description,
|
||||
roles: data.item.roles ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -114,6 +120,7 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
|
||||
if (index !== -1) draft.sections.experience.items[index] = formData;
|
||||
}
|
||||
});
|
||||
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -148,9 +155,114 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
|
||||
);
|
||||
}
|
||||
|
||||
type RoleFieldsProps = {
|
||||
role: RoleItem;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
function RoleFields({ role, index, onRemove }: RoleFieldsProps) {
|
||||
const form = useFormContext<FormValues>();
|
||||
const controls = useDragControls();
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={role}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
initial={{ opacity: 1, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="relative grid rounded border sm:col-span-full sm:grid-cols-2"
|
||||
>
|
||||
<div className="col-span-full flex items-center justify-between rounded-t bg-border/30 px-2 py-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="cursor-grab touch-none"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
controls.start(e);
|
||||
}}
|
||||
>
|
||||
<RowsIcon />
|
||||
<Trans>Reorder</Trans>
|
||||
</Button>
|
||||
|
||||
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive" onClick={onRemove}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 p-4 sm:col-span-full sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`roles.${index}.position`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Position</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`roles.${index}.period`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Period</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`roles.${index}.description`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RichInput {...field} value={field.value} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function ExperienceForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: "roles",
|
||||
keyName: "fieldId",
|
||||
control: form.control,
|
||||
});
|
||||
|
||||
const hasRoles = useMemo(() => fields.length > 0, [fields]);
|
||||
|
||||
const handleReorderRoles = (newOrder: RoleItem[]) => {
|
||||
form.setValue("roles", newOrder);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
@@ -174,11 +286,9 @@ function ExperienceForm() {
|
||||
name="position"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Position</Trans>
|
||||
</FormLabel>
|
||||
<FormLabel>{hasRoles ? <Trans>Overall Title (optional)</Trans> : <Trans>Position</Trans>}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input {...field} placeholder={hasRoles ? "e.g. Software Engineer → Senior Engineer" : ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -206,11 +316,9 @@ function ExperienceForm() {
|
||||
name="period"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Period</Trans>
|
||||
</FormLabel>
|
||||
<FormLabel>{hasRoles ? <Trans>Overall Period</Trans> : <Trans>Period</Trans>}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input {...field} placeholder={hasRoles ? "e.g. 2018 – Present" : ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -246,28 +354,68 @@ function ExperienceForm() {
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0">
|
||||
<FormLabel>
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RichInput {...field} value={field.value} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Role Progression */}
|
||||
<div className="flex items-center justify-between sm:col-span-full">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">
|
||||
<Trans>Role Progression</Trans>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>Add multiple roles to show career progression at the same company.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={() => append({ id: generateId(), position: "", period: "", description: "" })}
|
||||
>
|
||||
<PlusIcon />
|
||||
<Trans>Add Role</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasRoles && (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={fields}
|
||||
onReorder={handleReorderRoles}
|
||||
className="flex flex-col gap-4 sm:col-span-full"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{fields.map((field, index) => (
|
||||
<RoleFields key={field.id} role={fields[index]} index={index} onRemove={() => remove(index)} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
)}
|
||||
|
||||
{/* Single Role Description — only show when no roles are defined */}
|
||||
{!hasRoles && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RichInput {...field} value={field.value} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user