v1.6.0 — Partners: let partners set the regions they serve on My Profile (#23605)
**Partners app version: `1.6.0`** (minor — new backwards-compatible
field, no schema change)
## Problem
`Partner.region` is a `MULTI_SELECT` (`EUROPE`, `US`, `LATAM`, `MENA`,
`APAC`, `AFRICA`) that was **readable everywhere but writable nowhere**:
- the public partner marketplace **filters** on it
(`filter-partners.ts`) and **displays** it (`PartnerProfile.tsx`)
- the self-service loader already selected and mapped it
(`find-my-partner-profile.ts`, `get-my-partner-profile.mapper.ts`)
- but the **My Profile form never rendered it**, and
`save-my-partner-profile.mapper.ts` uses a `.strict()` zod schema that
rejected the key outright
The only way a partner got a region was `deriveRegion(country)` at
application intake — a single region, derived from their home country,
set once. So a partner serving several regions could not say so, and
anyone who applied without a mapped country had a null region and was
invisible to every region filter on the marketplace.
## Change
"Regions served" becomes partner-editable, in the Location section of My
Profile under Country/City.
| File | |
|---|---|
| `self-service/constants/my-profile.constants.ts` | six region options,
mirroring `partner.object.ts` |
| `self-service/mappers/save-my-partner-profile.mapper.ts` | schema key,
value validation, mapping onto the update payload |
| `front-components/my-profile/profile-form.ts` | **new** — the form's
pure helpers, extracted so they can be unit-tested |
| `front-components/my-profile/profile-form.test.ts` | **new** |
| `front-components/my-profile.front-component.tsx` | the
`ChipMultiSelect` field; ~110 lines lighter after the extraction |
The extraction follows the pattern already used by
`my-case-studies/case-study-rows.ts` and its co-located test. It is a
separate, behaviour-free commit (`321f2c42`) to keep it reviewable apart
from the feature.
## Deliberately out of scope
- **No country → region coupling.** Country is where you are; region is
where you sell. Changing Country does not re-derive, seed, or clear
Region. `deriveRegion` stays intake-only.
- **No backfill.** Existing partners with a null region keep it until
they edit their profile.
- **No marketplace or `completenessScore` change** — both already handle
`region` correctly.
- **No object/schema change.** `yarn twenty plan` reports `0 to add, 3
to change, 0 to destroy` (two logic-function checksums plus the
front-component checksum).
## Permissions
This does not widen partner privileges. `region`'s field UUID is absent
from the locked-field list in `src/roles/partner.role.ts`, so the
partner role already permitted region writes on the partner's own record
via the CRM page — this only surfaces it in the self-service form. The
save path resolves `partnerId` from the request JWT
(`resolve-partner-from-request.service.ts`); the body never supplies a
record id, and `.strict()` rejects one if sent.
## Verification
- `yarn test:unit` — 217/217
- `yarn lint` (oxlint) — 0 warnings, 0 errors
- SDK build typecheck — passes
- Verified end to end against a local workspace: the field renders with
six chips, pre-selects from the stored value, saving two regions
persists `["EUROPE","MENA"]`, and deselecting all persists `[]` while
leaving name/city/country untouched.
## Known, pre-existing, not addressed here
`toMoneyField` round-trips the stored `currencyCode`, but
`saveProfileSchema` pins `currencyCode: z.literal('USD')`. A partner
whose `hourlyRate` was set to a non-USD currency in the CRM therefore
has **every** profile save rejected, with no currency picker in the UI
to correct it. Surfaced while reviewing this branch; it predates it and
is left for a separate fix.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23605?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-partners",
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
|
||||
+1
-1
@@ -207,7 +207,7 @@ describe('save-my-partner-profile', () => {
|
||||
expect(partner.hourlyRate).toEqual({ amountMicros: 150000000, currencyCode: 'USD' });
|
||||
expect(partner.website?.primaryLinkUrl).toBe('https://updated.example.com');
|
||||
|
||||
// Admin-only fields are never in the editable schema, so they must survive untouched.
|
||||
// Fields absent from the body must survive untouched.
|
||||
expect(partner.region).toEqual(['EUROPE']);
|
||||
expect(partner.deploymentExpertise).toEqual(['CLOUD']);
|
||||
});
|
||||
|
||||
+9
@@ -15,6 +15,7 @@ export type ProfileOptions = {
|
||||
partnerScope: SelectOption[];
|
||||
typeOfTeam: SelectOption[];
|
||||
availability: SelectOption[];
|
||||
region: SelectOption[];
|
||||
};
|
||||
|
||||
// These mirror the option lists declared on the Partner object (partner.object.ts).
|
||||
@@ -36,6 +37,14 @@ export const PROFILE_OPTIONS: ProfileOptions = {
|
||||
{ value: 'AVAILABLE', label: 'Available' },
|
||||
{ value: 'UNAVAILABLE', label: 'Unavailable' },
|
||||
],
|
||||
region: [
|
||||
{ value: 'EUROPE', label: 'Europe' },
|
||||
{ value: 'US', label: 'US' },
|
||||
{ value: 'LATAM', label: 'LATAM' },
|
||||
{ value: 'MENA', label: 'MENA' },
|
||||
{ value: 'APAC', label: 'APAC' },
|
||||
{ value: 'AFRICA', label: 'Africa' },
|
||||
],
|
||||
languagesSpoken: [
|
||||
{ value: 'ENGLISH', label: 'English' },
|
||||
{ value: 'FRENCH', label: 'French' },
|
||||
|
||||
+14
-102
@@ -17,61 +17,13 @@ import {
|
||||
TagInput,
|
||||
TextInput,
|
||||
UrlInput,
|
||||
type SelectOption,
|
||||
} from './my-profile/form-fields';
|
||||
|
||||
type Currency = { amountMicros: number | null; currencyCode: string | null } | null;
|
||||
|
||||
type ProfilePayload = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
profilePictureUrl: string | null;
|
||||
introduction: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
languagesSpoken: string[] | null;
|
||||
partnerScope: string[] | null;
|
||||
skills: string[] | null;
|
||||
typeOfTeam: string | null;
|
||||
availability: string | null;
|
||||
hourlyRate: Currency;
|
||||
projectBudgetMin: Currency;
|
||||
website: string | null;
|
||||
linkedin: string | null;
|
||||
calendarLink: string | null;
|
||||
};
|
||||
|
||||
type ProfileOptions = {
|
||||
country: SelectOption[];
|
||||
languagesSpoken: SelectOption[];
|
||||
partnerScope: SelectOption[];
|
||||
typeOfTeam: SelectOption[];
|
||||
availability: SelectOption[];
|
||||
};
|
||||
|
||||
type LoadResult =
|
||||
| { ok: true; profile: ProfilePayload; options: ProfileOptions }
|
||||
| { ok: false; reason: string };
|
||||
type SaveResult = { ok: true } | { ok: false; reason: string };
|
||||
|
||||
type MoneyField = { amount: number | null; currencyCode: string };
|
||||
|
||||
type ProfileForm = {
|
||||
name: string;
|
||||
introduction: string;
|
||||
availability: string;
|
||||
typeOfTeam: string;
|
||||
hourlyRate: MoneyField;
|
||||
projectBudgetMin: MoneyField;
|
||||
partnerScope: string[];
|
||||
skills: string[];
|
||||
languagesSpoken: string[];
|
||||
country: string;
|
||||
city: string;
|
||||
website: string;
|
||||
linkedin: string;
|
||||
calendarLink: string;
|
||||
};
|
||||
import { toProfileForm, toSaveBody, type ProfileForm } from './my-profile/profile-form';
|
||||
import type {
|
||||
MyPartnerProfileResult,
|
||||
ProfileOptions,
|
||||
SaveResult,
|
||||
} from './my-profile/types';
|
||||
|
||||
const SKILL_SUGGESTIONS = [
|
||||
'Migrations',
|
||||
@@ -90,53 +42,6 @@ const SKILL_SUGGESTIONS = [
|
||||
'Custom development',
|
||||
];
|
||||
|
||||
const MICROS = 1_000_000;
|
||||
|
||||
const toMoneyField = (value: Currency): MoneyField => ({
|
||||
amount: value?.amountMicros != null ? value.amountMicros / MICROS : null,
|
||||
currencyCode: value?.currencyCode ?? 'USD',
|
||||
});
|
||||
|
||||
const toProfileForm = (profile: ProfilePayload): ProfileForm => ({
|
||||
name: profile.name ?? '',
|
||||
introduction: profile.introduction ?? '',
|
||||
availability: profile.availability ?? '',
|
||||
typeOfTeam: profile.typeOfTeam ?? '',
|
||||
hourlyRate: toMoneyField(profile.hourlyRate),
|
||||
projectBudgetMin: toMoneyField(profile.projectBudgetMin),
|
||||
partnerScope: profile.partnerScope ?? [],
|
||||
skills: profile.skills ?? [],
|
||||
languagesSpoken: profile.languagesSpoken ?? [],
|
||||
country: profile.country ?? '',
|
||||
city: profile.city ?? '',
|
||||
website: profile.website ?? '',
|
||||
linkedin: profile.linkedin ?? '',
|
||||
calendarLink: profile.calendarLink ?? '',
|
||||
});
|
||||
|
||||
const toMicros = (money: MoneyField) =>
|
||||
money.amount == null
|
||||
? null
|
||||
: { amountMicros: Math.round(money.amount * MICROS), currencyCode: money.currencyCode || 'USD' };
|
||||
|
||||
// Enum/country selectors send null (not '') when reset to blank so the field clears.
|
||||
const toSaveBody = (form: ProfileForm): Record<string, unknown> => ({
|
||||
name: form.name,
|
||||
introduction: form.introduction,
|
||||
city: form.city,
|
||||
languagesSpoken: form.languagesSpoken,
|
||||
partnerScope: form.partnerScope,
|
||||
skills: form.skills,
|
||||
website: form.website,
|
||||
linkedin: form.linkedin,
|
||||
calendarLink: form.calendarLink,
|
||||
hourlyRate: toMicros(form.hourlyRate),
|
||||
projectBudgetMin: toMicros(form.projectBudgetMin),
|
||||
availability: form.availability === '' ? null : form.availability,
|
||||
typeOfTeam: form.typeOfTeam === '' ? null : form.typeOfTeam,
|
||||
country: form.country === '' ? null : form.country,
|
||||
});
|
||||
|
||||
const styles = {
|
||||
root: {
|
||||
display: 'flex',
|
||||
@@ -213,7 +118,7 @@ const MyProfile = () => {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = (await callAppRoute('/my-partner-profile', {})) as LoadResult;
|
||||
const res = (await callAppRoute('/my-partner-profile', {})) as MyPartnerProfileResult;
|
||||
if (res.ok) {
|
||||
setForm(toProfileForm(res.profile));
|
||||
setOptions(res.options);
|
||||
@@ -370,6 +275,13 @@ const MyProfile = () => {
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Regions served">
|
||||
<ChipMultiSelect
|
||||
value={form.region}
|
||||
options={options.region}
|
||||
onChange={(value) => set('region', value)}
|
||||
/>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title="Links">
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { toProfileForm, toSaveBody, type ProfilePayload } from './profile-form';
|
||||
|
||||
const emptyPayload: ProfilePayload = {
|
||||
id: 'partner-1',
|
||||
name: 'Nine Dots Ventures',
|
||||
profilePictureUrl: null,
|
||||
introduction: null,
|
||||
city: null,
|
||||
country: null,
|
||||
languagesSpoken: null,
|
||||
region: null,
|
||||
partnerScope: null,
|
||||
skills: null,
|
||||
typeOfTeam: null,
|
||||
availability: null,
|
||||
hourlyRate: null,
|
||||
projectBudgetMin: null,
|
||||
website: null,
|
||||
linkedin: null,
|
||||
calendarLink: null,
|
||||
};
|
||||
|
||||
describe('toProfileForm', () => {
|
||||
it('carries region through', () => {
|
||||
expect(toProfileForm({ ...emptyPayload, region: ['EUROPE', 'MENA'] }).region).toEqual([
|
||||
'EUROPE',
|
||||
'MENA',
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults a null region to an empty array', () => {
|
||||
expect(toProfileForm(emptyPayload).region).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSaveBody', () => {
|
||||
it('sends region as an array', () => {
|
||||
const body = toSaveBody({ ...toProfileForm(emptyPayload), region: ['APAC'] });
|
||||
expect(body.region).toEqual(['APAC']);
|
||||
});
|
||||
|
||||
it('sends an empty region array when nothing is selected', () => {
|
||||
expect(toSaveBody(toProfileForm(emptyPayload)).region).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends null for a blank country, not an empty string', () => {
|
||||
expect(toSaveBody(toProfileForm(emptyPayload)).country).toBeNull();
|
||||
});
|
||||
|
||||
it('converts a money field to micros', () => {
|
||||
const form = toProfileForm({
|
||||
...emptyPayload,
|
||||
hourlyRate: { amountMicros: 150000000, currencyCode: 'USD' },
|
||||
});
|
||||
expect(toSaveBody(form).hourlyRate).toEqual({ amountMicros: 150000000, currencyCode: 'USD' });
|
||||
});
|
||||
});
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import type { MyProfilePayload } from './types';
|
||||
|
||||
// Narrowed from the loader's payload so a field rename upstream breaks the build here.
|
||||
export type ProfilePayload = Pick<
|
||||
MyProfilePayload,
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'profilePictureUrl'
|
||||
| 'introduction'
|
||||
| 'city'
|
||||
| 'country'
|
||||
| 'languagesSpoken'
|
||||
| 'region'
|
||||
| 'partnerScope'
|
||||
| 'skills'
|
||||
| 'typeOfTeam'
|
||||
| 'availability'
|
||||
| 'hourlyRate'
|
||||
| 'projectBudgetMin'
|
||||
| 'website'
|
||||
| 'linkedin'
|
||||
| 'calendarLink'
|
||||
>;
|
||||
|
||||
export type Currency = MyProfilePayload['hourlyRate'];
|
||||
|
||||
export type MoneyField = { amount: number | null; currencyCode: string };
|
||||
|
||||
export type ProfileForm = {
|
||||
name: string;
|
||||
introduction: string;
|
||||
availability: string;
|
||||
typeOfTeam: string;
|
||||
hourlyRate: MoneyField;
|
||||
projectBudgetMin: MoneyField;
|
||||
partnerScope: string[];
|
||||
skills: string[];
|
||||
languagesSpoken: string[];
|
||||
region: string[];
|
||||
country: string;
|
||||
city: string;
|
||||
website: string;
|
||||
linkedin: string;
|
||||
calendarLink: string;
|
||||
};
|
||||
|
||||
const MICROS = 1_000_000;
|
||||
|
||||
export const toMoneyField = (value: Currency): MoneyField => ({
|
||||
amount: value?.amountMicros != null ? value.amountMicros / MICROS : null,
|
||||
currencyCode: value?.currencyCode ?? 'USD',
|
||||
});
|
||||
|
||||
export const toProfileForm = (profile: ProfilePayload): ProfileForm => ({
|
||||
name: profile.name ?? '',
|
||||
introduction: profile.introduction ?? '',
|
||||
availability: profile.availability ?? '',
|
||||
typeOfTeam: profile.typeOfTeam ?? '',
|
||||
hourlyRate: toMoneyField(profile.hourlyRate),
|
||||
projectBudgetMin: toMoneyField(profile.projectBudgetMin),
|
||||
partnerScope: profile.partnerScope ?? [],
|
||||
skills: profile.skills ?? [],
|
||||
languagesSpoken: profile.languagesSpoken ?? [],
|
||||
region: profile.region ?? [],
|
||||
country: profile.country ?? '',
|
||||
city: profile.city ?? '',
|
||||
website: profile.website ?? '',
|
||||
linkedin: profile.linkedin ?? '',
|
||||
calendarLink: profile.calendarLink ?? '',
|
||||
});
|
||||
|
||||
export const toMicros = (money: MoneyField) =>
|
||||
money.amount == null
|
||||
? null
|
||||
: { amountMicros: Math.round(money.amount * MICROS), currencyCode: money.currencyCode || 'USD' };
|
||||
|
||||
// Enum/country selectors send null (not '') when reset to blank so the field clears.
|
||||
export const toSaveBody = (form: ProfileForm): Record<string, unknown> => ({
|
||||
name: form.name,
|
||||
introduction: form.introduction,
|
||||
city: form.city,
|
||||
languagesSpoken: form.languagesSpoken,
|
||||
region: form.region,
|
||||
partnerScope: form.partnerScope,
|
||||
skills: form.skills,
|
||||
website: form.website,
|
||||
linkedin: form.linkedin,
|
||||
calendarLink: form.calendarLink,
|
||||
hourlyRate: toMicros(form.hourlyRate),
|
||||
projectBudgetMin: toMicros(form.projectBudgetMin),
|
||||
availability: form.availability === '' ? null : form.availability,
|
||||
typeOfTeam: form.typeOfTeam === '' ? null : form.typeOfTeam,
|
||||
country: form.country === '' ? null : form.country,
|
||||
});
|
||||
+23
-3
@@ -16,9 +16,9 @@ describe('saveProfileSchema', () => {
|
||||
expect(saveProfileSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown key (region)', () => {
|
||||
const parsed = saveProfileSchema.safeParse({ region: ['EUROPE'] });
|
||||
expect(parsed.success).toBe(false);
|
||||
it('accepts a region array', () => {
|
||||
const parsed = saveProfileSchema.safeParse({ region: ['EUROPE', 'MENA'] });
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown key (validationStage)', () => {
|
||||
@@ -101,6 +101,15 @@ describe('validateProfileOptionValues', () => {
|
||||
it('accepts a null country (clearing it)', () => {
|
||||
expect(validateProfileOptionValues({ country: null })).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an unknown region', () => {
|
||||
const result = validateProfileOptionValues({ region: ['EUROPE', 'ATLANTIS'] });
|
||||
expect(result).toEqual({ error: 'Unknown region: ATLANTIS' });
|
||||
});
|
||||
|
||||
it('accepts known regions', () => {
|
||||
expect(validateProfileOptionValues({ region: ['EUROPE', 'MENA'] })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPartnerUpdateData', () => {
|
||||
@@ -166,4 +175,15 @@ describe('buildPartnerUpdateData', () => {
|
||||
it('returns an empty object for an empty input', () => {
|
||||
expect(buildPartnerUpdateData({})).toEqual({});
|
||||
});
|
||||
|
||||
it('maps region values as provided', () => {
|
||||
expect(buildPartnerUpdateData({ region: ['EUROPE', 'MENA'] }).region).toEqual([
|
||||
'EUROPE',
|
||||
'MENA',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves region off the payload when absent', () => {
|
||||
expect('region' in buildPartnerUpdateData({ name: 'Nine Dots Ventures' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+9
@@ -25,6 +25,7 @@ export const saveProfileSchema = z
|
||||
country: z.string().nullable().optional(),
|
||||
languagesSpoken: z.array(z.string()).optional(),
|
||||
partnerScope: z.array(z.string()).optional(),
|
||||
region: z.array(z.string()).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
typeOfTeam: z.enum(['SOLO', 'AGENCY']).nullable().optional(),
|
||||
availability: z.enum(['AVAILABLE', 'UNAVAILABLE']).nullable().optional(),
|
||||
@@ -44,6 +45,7 @@ const optionValueSet = (options: { value: string }[]): Set<string> =>
|
||||
const COUNTRY_VALUES = optionValueSet(PROFILE_OPTIONS.country);
|
||||
const LANGUAGE_VALUES = optionValueSet(PROFILE_OPTIONS.languagesSpoken);
|
||||
const PARTNER_SCOPE_VALUES = optionValueSet(PROFILE_OPTIONS.partnerScope);
|
||||
const REGION_VALUES = optionValueSet(PROFILE_OPTIONS.region);
|
||||
|
||||
// Kept separate from buildPartnerUpdateData so each concern (validation vs.
|
||||
// mapping) is independently unit-testable.
|
||||
@@ -61,6 +63,10 @@ export function validateProfileOptionValues(
|
||||
const unknown = input.partnerScope.find((value) => !PARTNER_SCOPE_VALUES.has(value));
|
||||
if (unknown !== undefined) return { error: `Unknown partner scope: ${unknown}` };
|
||||
}
|
||||
if (input.region !== undefined) {
|
||||
const unknown = input.region.find((value) => !REGION_VALUES.has(value));
|
||||
if (unknown !== undefined) return { error: `Unknown region: ${unknown}` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -88,6 +94,9 @@ export function buildPartnerUpdateData(
|
||||
(value) => value as CoreSchema.PartnerPartnerScopeEnum,
|
||||
);
|
||||
}
|
||||
if (input.region !== undefined) {
|
||||
data.region = input.region.map((value) => value as CoreSchema.PartnerRegionEnum);
|
||||
}
|
||||
if (input.skills !== undefined) data.skills = input.skills;
|
||||
if (input.typeOfTeam !== undefined) data.typeOfTeam = input.typeOfTeam;
|
||||
if (input.availability !== undefined) data.availability = input.availability;
|
||||
|
||||
Reference in New Issue
Block a user