From d39c1a69bc5e737d62b3f9c16d4b1ae5a4cc2b4c Mon Sep 17 00:00:00 2001 From: rebelchris Date: Wed, 4 Feb 2026 13:11:58 +0000 Subject: [PATCH 1/2] feat(recruiter): support multiple locations in opportunity edit screen Update the opportunity edit form to support adding, editing, and removing multiple locations instead of just one. - Add LocationEntry schema type for location array items - Replace singular location fields with locations array in schema - Update opportunityToFormData to map all locations from opportunity - Update formDataToPreviewOpportunity to transform locations array - Update formDataToMutationPayload to send locations array with externalLocationId to backend - Implement multi-location UI with useFieldArray in RoleInfoSection - Add "Add location" button and remove button for each location Co-Authored-By: Claude Opus 4.5 --- .../hooks/useOpportunityEditForm.tsx | 61 +++++++----- .../sections/RoleInfoSection.tsx | 98 +++++++++++++++---- packages/shared/src/lib/schema/opportunity.ts | 28 +++--- 3 files changed, 130 insertions(+), 57 deletions(-) diff --git a/packages/shared/src/components/opportunity/SideBySideEdit/hooks/useOpportunityEditForm.tsx b/packages/shared/src/components/opportunity/SideBySideEdit/hooks/useOpportunityEditForm.tsx index 31d8015e8e..6516722108 100644 --- a/packages/shared/src/components/opportunity/SideBySideEdit/hooks/useOpportunityEditForm.tsx +++ b/packages/shared/src/components/opportunity/SideBySideEdit/hooks/useOpportunityEditForm.tsx @@ -75,16 +75,19 @@ export function opportunityToFormData( title: opportunity.title || '', tldr: opportunity.tldr || '', keywords: opportunity.keywords?.map((k) => ({ keyword: k.keyword })) || [], - externalLocationId: opportunity.locations?.[0]?.location?.city || undefined, - locationType: opportunity.locations?.[0]?.type, - locationData: opportunity.locations?.[0]?.location - ? { - id: '', - city: opportunity.locations[0].location.city, - country: opportunity.locations[0].location.country || '', - subdivision: opportunity.locations[0].location.subdivision, - } - : undefined, + locations: + opportunity.locations?.map((loc) => ({ + externalLocationId: undefined, + locationType: loc.type, + locationData: loc.location + ? { + id: '', + city: loc.location.city, + country: loc.location.country || '', + subdivision: loc.location.subdivision, + } + : undefined, + })) || [], meta: { employmentType: opportunity.meta?.employmentType ?? 0, teamSize: opportunity.meta?.teamSize ?? 1, @@ -123,20 +126,19 @@ export function formDataToPreviewOpportunity( title: formData.title, tldr: formData.tldr, keywords: formData.keywords, - locations: formData.locationType - ? [ - { - type: formData.locationType, - location: formData.locationData - ? { - city: formData.locationData.city, - country: formData.locationData.country, - subdivision: formData.locationData.subdivision, - } - : null, - }, - ] - : undefined, + locations: + formData.locations + ?.filter((loc) => loc.locationType) + .map((loc) => ({ + type: loc.locationType, + location: loc.locationData + ? { + city: loc.locationData.city, + country: loc.locationData.country, + subdivision: loc.locationData.subdivision, + } + : null, + })) || [], meta: formData.meta ? { employmentType: formData.meta.employmentType, @@ -186,8 +188,15 @@ export function formDataToMutationPayload( title: formData.title, tldr: formData.tldr, keywords: formData.keywords, - externalLocationId: formData.externalLocationId, - locationType: formData.locationType, + location: formData.locations + ?.filter((loc) => loc.locationType) + .map((loc) => ({ + externalLocationId: loc.externalLocationId, + type: loc.locationType, + city: loc.locationData?.city, + country: loc.locationData?.country, + subdivision: loc.locationData?.subdivision, + })), meta: { employmentType: formData.meta.employmentType, teamSize: formData.meta.teamSize, diff --git a/packages/shared/src/components/opportunity/SideBySideEdit/sections/RoleInfoSection.tsx b/packages/shared/src/components/opportunity/SideBySideEdit/sections/RoleInfoSection.tsx index 3b1ee7b995..45cd8c9b2d 100644 --- a/packages/shared/src/components/opportunity/SideBySideEdit/sections/RoleInfoSection.tsx +++ b/packages/shared/src/components/opportunity/SideBySideEdit/sections/RoleInfoSection.tsx @@ -1,6 +1,6 @@ import type { ReactElement } from 'react'; import React, { useCallback } from 'react'; -import { Controller, useFormContext } from 'react-hook-form'; +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; import { TextField } from '../../../fields/TextField'; import Textarea from '../../../fields/Textarea'; import { @@ -14,6 +14,11 @@ import type { TLocation } from '../../../../graphql/autocomplete'; import { LocationDataset } from '../../../../graphql/autocomplete'; import type { Opportunity } from '../../../../features/opportunity/types'; import type { OpportunitySideBySideEditFormData } from '../hooks/useOpportunityEditForm'; +import { Button, ButtonSize, ButtonVariant } from '../../../buttons/Button'; +import { PlusIcon } from '../../../icons/Plus'; +import { TrashIcon } from '../../../icons/Trash'; +import { IconSize } from '../../../Icon'; +import { LocationType } from '../../../../features/opportunity/protobuf/util'; export interface RoleInfoSectionProps { opportunity: Opportunity; @@ -29,10 +34,20 @@ export function RoleInfoSection({ formState: { errors }, } = useFormContext(); + const { fields, append, remove } = useFieldArray({ + control, + name: 'locations', + }); + const handleLocationSelect = useCallback( - (location: TLocation | null) => { + (location: TLocation | null, index: number) => { setValue( - 'locationData', + `locations.${index}.externalLocationId`, + location?.id || undefined, + { shouldDirty: true }, + ); + setValue( + `locations.${index}.locationData`, location ? { id: location.id, @@ -47,6 +62,17 @@ export function RoleInfoSection({ [setValue], ); + const handleAddLocation = useCallback(() => { + append({ locationType: LocationType.REMOTE }); + }, [append]); + + const handleRemoveLocation = useCallback( + (index: number) => { + remove(index); + }, + [remove], + ); + return (
@@ -110,24 +136,56 @@ export function RoleInfoSection({ />
-
- + + Locations + + {fields.map((field, index) => ( +
+
+ + onLocationSelect={(location) => + handleLocationSelect(location, index) + } + /> +
+ {fields.length > 1 && ( +
+ ))} +
); diff --git a/packages/shared/src/lib/schema/opportunity.ts b/packages/shared/src/lib/schema/opportunity.ts index a156e705f8..4b1a4c8f29 100644 --- a/packages/shared/src/lib/schema/opportunity.ts +++ b/packages/shared/src/lib/schema/opportunity.ts @@ -13,17 +13,7 @@ const processSalaryValue = (val: unknown) => { return val; }; -export const opportunityEditInfoSchema = z.object({ - title: z.string().nonempty('Add a job title').max(240), - tldr: z.string().nonempty('Add a short description').max(480), - keywords: z - .array( - z.object({ - keyword: z.string().nonempty(), - }), - ) - .min(1, 'Add at least one skill') - .max(100), +const locationEntrySchema = z.object({ externalLocationId: z.string().optional(), locationType: z.number().optional(), locationData: z @@ -35,6 +25,22 @@ export const opportunityEditInfoSchema = z.object({ }) .nullable() .optional(), +}); + +export type LocationEntry = z.infer; + +export const opportunityEditInfoSchema = z.object({ + title: z.string().nonempty('Add a job title').max(240), + tldr: z.string().nonempty('Add a short description').max(480), + keywords: z + .array( + z.object({ + keyword: z.string().nonempty(), + }), + ) + .min(1, 'Add at least one skill') + .max(100), + locations: z.array(locationEntrySchema).optional().default([]), meta: z.object({ employmentType: z.coerce.number().min(1, 'Select an employment type'), teamSize: z From d882a0a647bc97b1ce7d82b23e65bafa469b80f3 Mon Sep 17 00:00:00 2001 From: rebelchris Date: Wed, 4 Feb 2026 13:31:39 +0000 Subject: [PATCH 2/2] fix(shared): add missing properties to settings test fixture Add required properties to createTestSettings fixture to match SettingsContextData interface: - companionExpanded, sortCommentsBy (from RemoteSettings) - updateSortCommentsBy, updateFlag, updateFlagRemote, updatePromptFlag, onToggleHeaderPlacement, setSettings, applyThemeMode (required methods) Co-Authored-By: Claude Opus 4.5 --- packages/shared/__tests__/fixture/settings.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/shared/__tests__/fixture/settings.ts b/packages/shared/__tests__/fixture/settings.ts index e1ab3c6459..236d094621 100644 --- a/packages/shared/__tests__/fixture/settings.ts +++ b/packages/shared/__tests__/fixture/settings.ts @@ -1,5 +1,6 @@ import type { SettingsContextData } from '../../src/contexts/SettingsContext'; import { ThemeMode } from '../../src/contexts/SettingsContext'; +import { SortCommentsBy } from '../../src/graphql/comments'; export const createTestSettings = ( props: Partial = {}, @@ -19,7 +20,9 @@ export const createTestSettings = ( optOutCompanion: true, optOutReadingStreak: true, sidebarExpanded: true, + companionExpanded: false, sortingEnabled: true, + sortCommentsBy: SortCommentsBy.NewestFirst, showFeedbackButton: true, toggleShowFeedbackButton: jest.fn(), toggleAutoDismissNotifications: jest.fn(), @@ -29,6 +32,13 @@ export const createTestSettings = ( toggleSortingEnabled: jest.fn(), syncSettings: jest.fn(), updateCustomLinks: jest.fn(), + updateSortCommentsBy: jest.fn(), + updateFlag: jest.fn(), + updateFlagRemote: jest.fn(), + updatePromptFlag: jest.fn(), + onToggleHeaderPlacement: jest.fn(), + setSettings: jest.fn(), + applyThemeMode: jest.fn(), ...props, });