11import { zodResolver } from "@hookform/resolvers/zod" ;
22import { 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" ;
57import type z from "zod" ;
68import { RichInput } from "@/components/input/rich-input" ;
79import { URLInput } from "@/components/input/url-input" ;
@@ -14,6 +16,7 @@ import { Switch } from "@/components/ui/switch";
1416import type { DialogProps } from "@/dialogs/store" ;
1517import { useDialogStore } from "@/dialogs/store" ;
1618import { useFormBlocker } from "@/hooks/use-form-blocker" ;
19+ import type { RoleItem } from "@/schema/resume/data" ;
1720import { experienceItemSchema } from "@/schema/resume/data" ;
1821import { 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+
151251function 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