Skip to content

Commit 4b46bda

Browse files
TheDuke427rootamruthpillai
authored
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>
1 parent df81d03 commit 4b46bda

File tree

8 files changed

+271
-49
lines changed

8 files changed

+271
-49
lines changed

src/components/resume/shared/items/experience-item.tsx

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ type ExperienceItemProps = SectionItem<"experience"> & {
1010
};
1111

1212
export function ExperienceItem({ className, ...item }: ExperienceItemProps) {
13+
const hasRoles = Array.isArray(item.roles) && item.roles.length > 0;
14+
1315
return (
1416
<div className={cn("experience-item", className)}>
1517
{/* Header */}
1618
<div className="section-item-header experience-item-header">
17-
{/* Row 1 */}
19+
{/* Row 1: Company + Location */}
1820
<div className="flex items-start justify-between gap-x-2">
1921
<LinkedTitle
2022
title={item.company}
@@ -25,20 +27,56 @@ export function ExperienceItem({ className, ...item }: ExperienceItemProps) {
2527
<span className="section-item-metadata experience-item-location shrink-0 text-end">{item.location}</span>
2628
</div>
2729

28-
{/* Row 2 */}
29-
<div className="flex items-start justify-between gap-x-2">
30-
<span className="section-item-metadata experience-item-position">{item.position}</span>
31-
<span className="section-item-metadata experience-item-period shrink-0 text-end">{item.period}</span>
32-
</div>
33-
</div>
30+
{/* Row 2: Position + Period */}
31+
{(!hasRoles || item.position) && (
32+
<div className="flex items-start justify-between gap-x-2">
33+
<span className="section-item-metadata experience-item-position">{item.position}</span>
34+
<span className="section-item-metadata experience-item-period shrink-0 text-end">{item.period}</span>
35+
</div>
36+
)}
3437

35-
{/* Description */}
36-
<div
37-
className={cn("section-item-description experience-item-description", !stripHtml(item.description) && "hidden")}
38-
>
39-
<TiptapContent content={item.description} />
38+
{/* Overall period when hasRoles and no summary position */}
39+
{hasRoles && !item.position && item.period && (
40+
<div className="flex items-start justify-end gap-x-2">
41+
<span className="section-item-metadata experience-item-period shrink-0 text-end">{item.period}</span>
42+
</div>
43+
)}
4044
</div>
4145

46+
{/* Role Progression */}
47+
{hasRoles && (
48+
<div className="experience-item-roles mt-(--page-gap-y) flex flex-col gap-y-(--page-gap-y)">
49+
{item.roles.map((role) => (
50+
<div key={role.id} className="experience-item-role">
51+
<div className="flex items-start justify-between gap-x-2">
52+
<strong className="section-item-metadata experience-item-role-position">{role.position}</strong>
53+
<span className="section-item-metadata experience-item-role-period shrink-0 text-end">
54+
{role.period}
55+
</span>
56+
</div>
57+
58+
{stripHtml(role.description) && (
59+
<div className="section-item-description experience-item-role-description mt-0.5">
60+
<TiptapContent content={role.description} />
61+
</div>
62+
)}
63+
</div>
64+
))}
65+
</div>
66+
)}
67+
68+
{/* Single-role description */}
69+
{!hasRoles && (
70+
<div
71+
className={cn(
72+
"section-item-description experience-item-description",
73+
!stripHtml(item.description) && "hidden",
74+
)}
75+
>
76+
<TiptapContent content={item.description} />
77+
</div>
78+
)}
79+
4280
{/* Website */}
4381
{!item.options?.showLinkInTitle && (
4482
<div className="section-item-website experience-item-website">

src/components/ui/tabs.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,18 @@ function Tabs({ className, orientation = "horizontal", ...props }: React.Compone
88
<TabsPrimitive.Root
99
data-slot="tabs"
1010
data-orientation={orientation}
11-
className={cn("group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", className)}
11+
className={cn("group/tabs flex gap-2 data-horizontal:flex-col", className)}
1212
{...props}
1313
/>
1414
);
1515
}
1616

1717
const tabsListVariants = cva(
18-
"group/tabs-list inline-flex w-fit items-center justify-center rounded-full p-1 text-muted-foreground data-[variant=line]:rounded-none group-data-[orientation=vertical]/tabs:h-fit group-data-horizontal/tabs:h-9 group-data-[orientation=vertical]/tabs:flex-col",
18+
"group/tabs-list inline-flex w-fit items-center justify-center rounded-full px-1.5 py-0.25 text-muted-foreground data-[variant=line]:rounded-none group-data-horizontal/tabs:h-9 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
1919
{
2020
variants: {
2121
variant: {
22-
default: "bg-card",
22+
default: "bg-muted",
2323
line: "gap-1 bg-transparent",
2424
},
2525
},
@@ -49,10 +49,10 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
4949
<TabsPrimitive.Trigger
5050
data-slot="tabs-trigger"
5151
className={cn(
52-
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-full border border-transparent px-3 py-1 font-medium text-foreground/60 text-sm transition-all hover:text-foreground focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
52+
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 font-medium text-foreground/60 text-sm transition-all hover:text-foreground focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
5353
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
54-
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
55-
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=vertical]/tabs:after:-inset-e-1 group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
54+
"data-active:bg-background data-active:text-foreground",
55+
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
5656
className,
5757
)}
5858
{...props}

src/dialogs/resume/sections/experience.tsx

Lines changed: 174 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { zodResolver } from "@hookform/resolvers/zod";
22
import { Trans } from "@lingui/react/macro";
3-
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
4-
import { useForm, useFormContext } from "react-hook-form";
3+
import { PencilSimpleLineIcon, PlusIcon, RowsIcon, TrashSimpleIcon } from "@phosphor-icons/react";
4+
import { AnimatePresence, Reorder, useDragControls } from "motion/react";
5+
import { useMemo } from "react";
6+
import { useFieldArray, useForm, useFormContext } from "react-hook-form";
57
import type z from "zod";
68
import { RichInput } from "@/components/input/rich-input";
79
import { URLInput } from "@/components/input/url-input";
@@ -14,6 +16,7 @@ import { Switch } from "@/components/ui/switch";
1416
import type { DialogProps } from "@/dialogs/store";
1517
import { useDialogStore } from "@/dialogs/store";
1618
import { useFormBlocker } from "@/hooks/use-form-blocker";
19+
import type { RoleItem } from "@/schema/resume/data";
1720
import { experienceItemSchema } from "@/schema/resume/data";
1821
import { generateId } from "@/utils/string";
1922

@@ -37,6 +40,7 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
3740
period: data?.item?.period ?? "",
3841
website: data?.item?.website ?? { url: "", label: "" },
3942
description: data?.item?.description ?? "",
43+
roles: data?.item?.roles ?? [],
4044
},
4145
});
4246

@@ -49,6 +53,7 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
4953
draft.sections.experience.items.push(formData);
5054
}
5155
});
56+
5257
closeDialog();
5358
};
5459

@@ -99,6 +104,7 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
99104
period: data.item.period,
100105
website: data.item.website,
101106
description: data.item.description,
107+
roles: data.item.roles ?? [],
102108
},
103109
});
104110

@@ -114,6 +120,7 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
114120
if (index !== -1) draft.sections.experience.items[index] = formData;
115121
}
116122
});
123+
117124
closeDialog();
118125
};
119126

@@ -148,9 +155,114 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
148155
);
149156
}
150157

158+
type RoleFieldsProps = {
159+
role: RoleItem;
160+
index: number;
161+
onRemove: () => void;
162+
};
163+
164+
function RoleFields({ role, index, onRemove }: RoleFieldsProps) {
165+
const form = useFormContext<FormValues>();
166+
const controls = useDragControls();
167+
168+
return (
169+
<Reorder.Item
170+
value={role}
171+
dragListener={false}
172+
dragControls={controls}
173+
initial={{ opacity: 1, y: -10 }}
174+
animate={{ opacity: 1, y: 0 }}
175+
exit={{ opacity: 0, y: -10 }}
176+
className="relative grid rounded border sm:col-span-full sm:grid-cols-2"
177+
>
178+
<div className="col-span-full flex items-center justify-between rounded-t bg-border/30 px-2 py-1.5">
179+
<Button
180+
size="sm"
181+
variant="ghost"
182+
className="cursor-grab touch-none"
183+
onPointerDown={(e) => {
184+
e.preventDefault();
185+
controls.start(e);
186+
}}
187+
>
188+
<RowsIcon />
189+
<Trans>Reorder</Trans>
190+
</Button>
191+
192+
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive" onClick={onRemove}>
193+
<TrashSimpleIcon />
194+
<Trans>Remove</Trans>
195+
</Button>
196+
</div>
197+
198+
<div className="grid gap-4 p-4 sm:col-span-full sm:grid-cols-2">
199+
<FormField
200+
control={form.control}
201+
name={`roles.${index}.position`}
202+
render={({ field }) => (
203+
<FormItem>
204+
<FormLabel>
205+
<Trans>Position</Trans>
206+
</FormLabel>
207+
<FormControl>
208+
<Input {...field} />
209+
</FormControl>
210+
<FormMessage />
211+
</FormItem>
212+
)}
213+
/>
214+
215+
<FormField
216+
control={form.control}
217+
name={`roles.${index}.period`}
218+
render={({ field }) => (
219+
<FormItem>
220+
<FormLabel>
221+
<Trans>Period</Trans>
222+
</FormLabel>
223+
<FormControl>
224+
<Input {...field} />
225+
</FormControl>
226+
<FormMessage />
227+
</FormItem>
228+
)}
229+
/>
230+
231+
<FormField
232+
control={form.control}
233+
name={`roles.${index}.description`}
234+
render={({ field }) => (
235+
<FormItem className="sm:col-span-full">
236+
<FormLabel>
237+
<Trans>Description</Trans>
238+
</FormLabel>
239+
<FormControl>
240+
<RichInput {...field} value={field.value} onChange={field.onChange} />
241+
</FormControl>
242+
<FormMessage />
243+
</FormItem>
244+
)}
245+
/>
246+
</div>
247+
</Reorder.Item>
248+
);
249+
}
250+
151251
function ExperienceForm() {
152252
const form = useFormContext<FormValues>();
153253

254+
const { fields, append, remove } = useFieldArray({
255+
name: "roles",
256+
keyName: "fieldId",
257+
control: form.control,
258+
});
259+
260+
const hasRoles = useMemo(() => fields.length > 0, [fields]);
261+
262+
const handleReorderRoles = (newOrder: RoleItem[]) => {
263+
form.setValue("roles", newOrder);
264+
};
265+
154266
return (
155267
<>
156268
<FormField
@@ -174,11 +286,9 @@ function ExperienceForm() {
174286
name="position"
175287
render={({ field }) => (
176288
<FormItem>
177-
<FormLabel>
178-
<Trans>Position</Trans>
179-
</FormLabel>
289+
<FormLabel>{hasRoles ? <Trans>Overall Title (optional)</Trans> : <Trans>Position</Trans>}</FormLabel>
180290
<FormControl>
181-
<Input {...field} />
291+
<Input {...field} placeholder={hasRoles ? "e.g. Software Engineer → Senior Engineer" : ""} />
182292
</FormControl>
183293
<FormMessage />
184294
</FormItem>
@@ -206,11 +316,9 @@ function ExperienceForm() {
206316
name="period"
207317
render={({ field }) => (
208318
<FormItem>
209-
<FormLabel>
210-
<Trans>Period</Trans>
211-
</FormLabel>
319+
<FormLabel>{hasRoles ? <Trans>Overall Period</Trans> : <Trans>Period</Trans>}</FormLabel>
212320
<FormControl>
213-
<Input {...field} />
321+
<Input {...field} placeholder={hasRoles ? "e.g. 2018 – Present" : ""} />
214322
</FormControl>
215323
<FormMessage />
216324
</FormItem>
@@ -246,28 +354,68 @@ function ExperienceForm() {
246354
<FormControl>
247355
<Switch checked={field.value} onCheckedChange={field.onChange} />
248356
</FormControl>
249-
<FormLabel className="!mt-0">
357+
<FormLabel>
250358
<Trans>Show link in title</Trans>
251359
</FormLabel>
252360
</FormItem>
253361
)}
254362
/>
255363

256-
<FormField
257-
control={form.control}
258-
name="description"
259-
render={({ field }) => (
260-
<FormItem className="sm:col-span-full">
261-
<FormLabel>
262-
<Trans>Description</Trans>
263-
</FormLabel>
264-
<FormControl>
265-
<RichInput {...field} value={field.value} onChange={field.onChange} />
266-
</FormControl>
267-
<FormMessage />
268-
</FormItem>
269-
)}
270-
/>
364+
{/* Role Progression */}
365+
<div className="flex items-center justify-between sm:col-span-full">
366+
<div className="space-y-1">
367+
<p className="font-medium text-foreground">
368+
<Trans>Role Progression</Trans>
369+
</p>
370+
<p className="text-muted-foreground text-xs">
371+
<Trans>Add multiple roles to show career progression at the same company.</Trans>
372+
</p>
373+
</div>
374+
375+
<Button
376+
size="sm"
377+
variant="outline"
378+
className="shrink-0"
379+
onClick={() => append({ id: generateId(), position: "", period: "", description: "" })}
380+
>
381+
<PlusIcon />
382+
<Trans>Add Role</Trans>
383+
</Button>
384+
</div>
385+
386+
{hasRoles && (
387+
<Reorder.Group
388+
axis="y"
389+
values={fields}
390+
onReorder={handleReorderRoles}
391+
className="flex flex-col gap-4 sm:col-span-full"
392+
>
393+
<AnimatePresence>
394+
{fields.map((field, index) => (
395+
<RoleFields key={field.id} role={fields[index]} index={index} onRemove={() => remove(index)} />
396+
))}
397+
</AnimatePresence>
398+
</Reorder.Group>
399+
)}
400+
401+
{/* Single Role Description — only show when no roles are defined */}
402+
{!hasRoles && (
403+
<FormField
404+
control={form.control}
405+
name="description"
406+
render={({ field }) => (
407+
<FormItem className="sm:col-span-full">
408+
<FormLabel>
409+
<Trans>Description</Trans>
410+
</FormLabel>
411+
<FormControl>
412+
<RichInput {...field} value={field.value} onChange={field.onChange} />
413+
</FormControl>
414+
<FormMessage />
415+
</FormItem>
416+
)}
417+
/>
418+
)}
271419
</>
272420
);
273421
}

0 commit comments

Comments
 (0)