Skip to content

Commit c361d4d

Browse files
rebelchrisclaudegithub-actions[bot]
authored
feat(recruiter): support multiple locations in opportunity edit screen (#5452)
Co-authored-by: Claude Opus 4.5 <[email protected]> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Chris Bongers <[email protected]>
1 parent ee6fd88 commit c361d4d

File tree

6 files changed

+177
-55
lines changed

6 files changed

+177
-55
lines changed

AGENTS.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,33 @@ import ControlledTextarea from '@dailydotdev/shared/src/components/fields/Contro
131131
import ControlledSwitch from '@dailydotdev/shared/src/components/fields/ControlledSwitch';
132132
```
133133

134+
**IMPORTANT - Zod Type Inference:**
135+
- **ALWAYS use `z.infer` to derive TypeScript types from Zod schemas**
136+
- **NEVER manually define types that duplicate Zod schema structure**
137+
138+
```typescript
139+
// ❌ WRONG: Manual type definition that duplicates schema
140+
const userSchema = z.object({
141+
name: z.string(),
142+
age: z.number(),
143+
});
144+
145+
interface User {
146+
name: string;
147+
age: number;
148+
}
149+
150+
// ✅ RIGHT: Infer type from schema
151+
const userSchema = z.object({
152+
name: z.string(),
153+
age: z.number(),
154+
});
155+
156+
export type User = z.infer<typeof userSchema>;
157+
```
158+
159+
This ensures type safety, reduces duplication, and keeps types automatically in sync with schemas.
160+
134161
## Quick Commands
135162

136163
```bash

packages/shared/src/components/opportunity/SideBySideEdit/hooks/useOpportunityEditForm.tsx

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,20 @@ export function opportunityToFormData(
7575
title: opportunity.title || '',
7676
tldr: opportunity.tldr || '',
7777
keywords: opportunity.keywords?.map((k) => ({ keyword: k.keyword })) || [],
78-
externalLocationId: opportunity.locations?.[0]?.location?.city || undefined,
7978
locationType: opportunity.locations?.[0]?.type,
80-
locationData: opportunity.locations?.[0]?.location
81-
? {
82-
id: '',
83-
city: opportunity.locations[0].location.city,
84-
country: opportunity.locations[0].location.country || '',
85-
subdivision: opportunity.locations[0].location.subdivision,
86-
}
87-
: undefined,
79+
locations:
80+
opportunity.locations?.map((loc) => ({
81+
locationId: loc.locationId || undefined,
82+
externalLocationId: undefined,
83+
locationData: loc.location
84+
? {
85+
id: '',
86+
city: loc.location.city,
87+
country: loc.location.country || '',
88+
subdivision: loc.location.subdivision,
89+
}
90+
: undefined,
91+
})) || [],
8892
meta: {
8993
employmentType: opportunity.meta?.employmentType ?? 0,
9094
teamSize: opportunity.meta?.teamSize ?? 1,
@@ -123,20 +127,17 @@ export function formDataToPreviewOpportunity(
123127
title: formData.title,
124128
tldr: formData.tldr,
125129
keywords: formData.keywords,
126-
locations: formData.locationType
127-
? [
128-
{
129-
type: formData.locationType,
130-
location: formData.locationData
131-
? {
132-
city: formData.locationData.city,
133-
country: formData.locationData.country,
134-
subdivision: formData.locationData.subdivision,
135-
}
136-
: null,
137-
},
138-
]
139-
: undefined,
130+
locations:
131+
formData.locations?.map((loc) => ({
132+
type: formData.locationType,
133+
location: loc.locationData
134+
? {
135+
city: loc.locationData.city,
136+
country: loc.locationData.country,
137+
subdivision: loc.locationData.subdivision,
138+
}
139+
: null,
140+
})) || [],
140141
meta: formData.meta
141142
? {
142143
employmentType: formData.meta.employmentType,
@@ -186,8 +187,14 @@ export function formDataToMutationPayload(
186187
title: formData.title,
187188
tldr: formData.tldr,
188189
keywords: formData.keywords,
189-
externalLocationId: formData.externalLocationId,
190-
locationType: formData.locationType,
190+
location: formData.locations?.map((loc) => ({
191+
locationId: loc.locationId,
192+
externalLocationId: loc.externalLocationId,
193+
type: formData.locationType,
194+
city: loc.locationData?.city,
195+
country: loc.locationData?.country,
196+
subdivision: loc.locationData?.subdivision,
197+
})),
191198
meta: {
192199
employmentType: formData.meta.employmentType,
193200
teamSize: formData.meta.teamSize,

packages/shared/src/components/opportunity/SideBySideEdit/sections/RoleInfoSection.tsx

Lines changed: 94 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ReactElement } from 'react';
22
import React, { useCallback } from 'react';
3-
import { Controller, useFormContext } from 'react-hook-form';
3+
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
44
import { TextField } from '../../../fields/TextField';
55
import Textarea from '../../../fields/Textarea';
66
import {
@@ -14,6 +14,12 @@ import type { TLocation } from '../../../../graphql/autocomplete';
1414
import { LocationDataset } from '../../../../graphql/autocomplete';
1515
import type { Opportunity } from '../../../../features/opportunity/types';
1616
import type { OpportunitySideBySideEditFormData } from '../hooks/useOpportunityEditForm';
17+
import { Button, ButtonSize, ButtonVariant } from '../../../buttons/Button';
18+
import { PlusIcon } from '../../../icons/Plus';
19+
import { TrashIcon } from '../../../icons/Trash';
20+
import { IconSize } from '../../../Icon';
21+
import { LocationType } from '../../../../features/opportunity/protobuf/util';
22+
import { Radio } from '../../../fields/Radio';
1723

1824
export interface RoleInfoSectionProps {
1925
opportunity: Opportunity;
@@ -26,13 +32,24 @@ export function RoleInfoSection({
2632
register,
2733
control,
2834
setValue,
35+
watch,
2936
formState: { errors },
3037
} = useFormContext<OpportunitySideBySideEditFormData>();
3138

39+
const { fields, append, remove } = useFieldArray({
40+
control,
41+
name: 'locations',
42+
});
43+
3244
const handleLocationSelect = useCallback(
33-
(location: TLocation | null) => {
45+
(location: TLocation | null, index: number) => {
46+
setValue(
47+
`locations.${index}.externalLocationId`,
48+
location?.id || undefined,
49+
{ shouldDirty: true },
50+
);
3451
setValue(
35-
'locationData',
52+
`locations.${index}.locationData`,
3653
location
3754
? {
3855
id: location.id,
@@ -47,6 +64,25 @@ export function RoleInfoSection({
4764
[setValue],
4865
);
4966

67+
const locationType = watch('locationType');
68+
69+
const locationTypeOptions = [
70+
{ label: 'Remote', value: `${LocationType.REMOTE}` },
71+
{ label: 'Hybrid', value: `${LocationType.HYBRID}` },
72+
{ label: 'On-site', value: `${LocationType.OFFICE}` },
73+
];
74+
75+
const handleAddLocation = useCallback(() => {
76+
append({});
77+
}, [append]);
78+
79+
const handleRemoveLocation = useCallback(
80+
(index: number) => {
81+
remove(index);
82+
},
83+
[remove],
84+
);
85+
5086
return (
5187
<div className="flex flex-col gap-4">
5288
<div data-field-key="title">
@@ -110,24 +146,63 @@ export function RoleInfoSection({
110146
/>
111147
</div>
112148

113-
<div data-field-key="location">
114-
<ProfileLocation
115-
locationName="externalLocationId"
116-
typeName="locationType"
117-
dataset={LocationDataset.Internal}
118-
defaultValue={
119-
opportunity?.locations?.[0]?.location
120-
? {
121-
id: '',
122-
city: opportunity.locations[0].location.city,
123-
country: opportunity.locations[0].location.country || '',
124-
subdivision: opportunity.locations[0].location.subdivision,
125-
type: opportunity.locations[0].type,
126-
}
127-
: undefined
149+
<div data-field-key="locations" className="flex flex-col gap-3">
150+
<Typography bold type={TypographyType.Caption1}>
151+
Locations
152+
</Typography>
153+
<Radio
154+
name="locationType"
155+
options={locationTypeOptions}
156+
value={`${locationType}`}
157+
onChange={(val) =>
158+
setValue('locationType', Number(val), { shouldDirty: true })
128159
}
129-
onLocationSelect={handleLocationSelect}
160+
className={{ container: '!flex-row' }}
130161
/>
162+
{fields.map((field, index) => (
163+
<div key={field.id} className="flex items-start gap-2">
164+
<div className="flex-1">
165+
<ProfileLocation
166+
locationName={`locations.${index}.externalLocationId`}
167+
dataset={LocationDataset.Internal}
168+
defaultValue={
169+
opportunity?.locations?.[index]?.location
170+
? {
171+
id: '',
172+
city: opportunity.locations[index].location.city,
173+
country:
174+
opportunity.locations[index].location.country || '',
175+
subdivision:
176+
opportunity.locations[index].location.subdivision,
177+
}
178+
: undefined
179+
}
180+
onLocationSelect={(location) =>
181+
handleLocationSelect(location, index)
182+
}
183+
/>
184+
</div>
185+
{fields.length > 1 && (
186+
<Button
187+
type="button"
188+
variant={ButtonVariant.Tertiary}
189+
size={ButtonSize.Small}
190+
icon={<TrashIcon size={IconSize.Small} />}
191+
onClick={() => handleRemoveLocation(index)}
192+
className="mt-6"
193+
/>
194+
)}
195+
</div>
196+
))}
197+
<Button
198+
type="button"
199+
variant={ButtonVariant.Secondary}
200+
size={ButtonSize.Small}
201+
icon={<PlusIcon size={IconSize.Small} />}
202+
onClick={handleAddLocation}
203+
>
204+
Add location
205+
</Button>
131206
</div>
132207
</div>
133208
);

packages/shared/src/features/opportunity/graphql.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export const OPPORTUNITY_FRAGMENT = gql`
121121
equity
122122
}
123123
locations {
124+
locationId
124125
type
125126
location {
126127
city

packages/shared/src/features/opportunity/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,11 @@ export type Opportunity = {
125125
};
126126
meta: OpportunityMeta;
127127
recruiters: RecruiterProfile[];
128-
locations: Array<{ location: OpportunityLocation; type?: ProtoEnumValue }>;
128+
locations: Array<{
129+
location: OpportunityLocation;
130+
type?: ProtoEnumValue;
131+
locationId?: string;
132+
}>;
129133
keywords?: Keyword[];
130134
questions?: OpportunityScreeningQuestion[];
131135
feedbackQuestions?: OpportunityFeedbackQuestion[];

packages/shared/src/lib/schema/opportunity.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,22 @@ const processSalaryValue = (val: unknown) => {
1313
return val;
1414
};
1515

16+
const locationEntrySchema = z.object({
17+
locationId: z.string().optional(),
18+
externalLocationId: z.string().optional(),
19+
locationData: z
20+
.object({
21+
id: z.string(),
22+
city: z.string().nullish(),
23+
country: z.string(),
24+
subdivision: z.string().nullish(),
25+
})
26+
.nullable()
27+
.optional(),
28+
});
29+
30+
export type LocationEntry = z.infer<typeof locationEntrySchema>;
31+
1632
export const opportunityEditInfoSchema = z.object({
1733
title: z.string().nonempty('Add a job title').max(240),
1834
tldr: z.string().nonempty('Add a short description').max(480),
@@ -24,17 +40,8 @@ export const opportunityEditInfoSchema = z.object({
2440
)
2541
.min(1, 'Add at least one skill')
2642
.max(100),
27-
externalLocationId: z.string().optional(),
2843
locationType: z.number().optional(),
29-
locationData: z
30-
.object({
31-
id: z.string(),
32-
city: z.string().nullish(),
33-
country: z.string(),
34-
subdivision: z.string().nullish(),
35-
})
36-
.nullable()
37-
.optional(),
44+
locations: z.array(locationEntrySchema).optional().default([]),
3845
meta: z.object({
3946
employmentType: z.coerce.number().min(1, 'Select an employment type'),
4047
teamSize: z
@@ -165,6 +172,7 @@ export interface LocationInput {
165172
city?: string;
166173
subdivision?: string;
167174
type?: number;
175+
locationId?: string;
168176
externalLocationId?: string;
169177
}
170178

0 commit comments

Comments
 (0)