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:
TheDuke427
2026-03-04 12:34:37 -08:00
committed by GitHub
parent df81d0342f
commit 4b46bda4e6
8 changed files with 271 additions and 49 deletions
+174 -26
View File
@@ -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>
)}
/>
)}
</>
);
}