feat(partners): require Twenty experience on apply, drop Cal success (#23223)

## Summary
- Add a dedicated **Experience** step to the partner apply wizard
(milestones, ≥200-char narrative, proof URL) before commercials
- Stop collecting `applicationNotes`; rename Expertise chrome away from
“experience”
- Replace the post-submit Cal.com embed with a review-and-reach-out
thank-you so unqualified inbound no longer books intros automatically

**Companion PR (app):** #23224 — Partner schema, submit persistence,
triage views, Tally CSV mapper (`twenty-partners` v1.4.0). Land the app
PR with or before this one.

## Test plan
- [ ] Open apply modal: wizard order is identity → profile → expertise →
experience → commercials
- [ ] Experience step blocks continue without ≥1 milestone, narrative
≥200 chars, and a valid https URL
- [ ] Submit creates/updates Partner with the three experience fields
(with #23224 deployed)
- [ ] Success screen has no Cal embed / book-later CTA
- [ ] `npx jest --config=jest.config.mjs partner-application` passes
locally (71 tests)

---------

Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
This commit is contained in:
Rashad Karanouh
2026-07-24 06:40:22 +02:00
committed by GitHub
parent 34c2e11dcb
commit 6b99bcea7f
26 changed files with 534 additions and 195 deletions
@@ -2,6 +2,11 @@ const ORIGINAL_FETCH = global.fetch;
const ORIGINAL_WEBHOOK_URL = process.env.PARTNER_APPLICATION_WEBHOOK_URL;
const ORIGINAL_API_KEY = process.env.PARTNER_APPLICATION_SECRET;
const VALID_EXPERIENCE_NOTES =
'Built a custom Twenty app for a property-management client, modeled leases and ' +
'tenants as data models, automated renewal workflows, and shipped a front component ' +
'for the broker dashboard with role-based views.';
const VALID_PAYLOAD = {
email: 'a@b.co',
name: 'Ada Lovelace',
@@ -10,6 +15,9 @@ const VALID_PAYLOAD = {
city: 'London',
hourlyRate: 150,
projectBudgetMin: 5000,
twentyExperience: ['WORKFLOWS'],
twentyExperienceNotes: VALID_EXPERIENCE_NOTES,
twentyExperienceProofLink: 'https://www.loom.com/share/example',
};
const VALID_BODY = JSON.stringify(VALID_PAYLOAD);
@@ -128,6 +136,48 @@ describe('POST /api/partner-application', () => {
expect(response.status).toBe(400);
});
it('returns 400 when applicationNotes is present (removed from apply)', async () => {
const { POST } = await loadRoute();
const response = await POST(
buildRequest({
body: JSON.stringify({
...VALID_PAYLOAD,
applicationNotes: 'legacy catch-all notes',
}),
ip: '203.0.113.17',
}),
);
expect(response.status).toBe(400);
});
it('returns 400 when experience narrative is too short', async () => {
const { POST } = await loadRoute();
const response = await POST(
buildRequest({
body: JSON.stringify({
...VALID_PAYLOAD,
twentyExperienceNotes: 'Too short for a real implementation.',
}),
ip: '203.0.113.18',
}),
);
expect(response.status).toBe(400);
});
it('returns 400 when twentyExperience is empty', async () => {
const { POST } = await loadRoute();
const response = await POST(
buildRequest({
body: JSON.stringify({
...VALID_PAYLOAD,
twentyExperience: [],
}),
ip: '203.0.113.19',
}),
);
expect(response.status).toBe(400);
});
it('returns 400 when country enum is unknown', async () => {
const { POST } = await loadRoute();
const response = await POST(
@@ -168,6 +218,9 @@ describe('POST /api/partner-application', () => {
city: 'London',
hourlyRate: 150,
projectBudgetMin: 5000,
twentyExperience: ['WORKFLOWS'],
twentyExperienceNotes: VALID_EXPERIENCE_NOTES,
twentyExperienceProofLink: 'https://www.loom.com/share/example',
});
expect(init.signal).toBeInstanceOf(AbortSignal);
});
@@ -5,12 +5,9 @@ import { useLingui } from '@lingui/react';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import dynamic from 'next/dynamic';
import { useEffect, useState } from 'react';
import {
buildSchemeContext,
DURATION,
EASING,
fontSize,
mediaUp,
semanticColor,
@@ -40,11 +37,7 @@ const PartnerApplicationWizard = dynamic(
{ loading: () => <WizardLoadingFallback />, ssr: false },
);
// The form sits in a narrow panel; the booking screen eases wider for the
// calendar. The Modal reads --modal-panel-width, so the change is a smooth
// transition rather than the old site's instant jump.
const formPanelClass = css`
transition: width ${DURATION.md} ${EASING.standard};
--modal-panel-width: min(360px, 100%);
${mediaUp('md')} {
@@ -52,11 +45,6 @@ const formPanelClass = css`
}
`;
const bookingPanelClass = css`
transition: width ${DURATION.md} ${EASING.standard};
--modal-panel-width: min(960px, 100%);
`;
// The near-black Modal panel is scheme-agnostic; the wizard reads semantic
// colours and the Button keys on [data-scheme], so it runs on a dark scope —
// the same context the full-page mount establishes.
@@ -72,27 +60,16 @@ export function PartnerApplicationModal({
open: boolean;
}) {
const { i18n } = useLingui();
const [hasReachedBooking, setHasReachedBooking] = useState(false);
// Reset the widened panel after the modal closes, while nothing is on screen,
// so a prior session's booking width can't flash on the next open.
useEffect(() => {
if (!open) setHasReachedBooking(false);
}, [open]);
return (
<Modal
ariaLabel={i18n._(msg`Partner application`)}
className={hasReachedBooking ? bookingPanelClass : formPanelClass}
className={formPanelClass}
onClose={onClose}
open={open}
>
<WizardScope data-scheme="dark">
<PartnerApplicationWizard
onSubmitted={() => setHasReachedBooking(true)}
onSuccess={onClose}
resetSignal={0}
/>
<PartnerApplicationWizard onSuccess={onClose} resetSignal={0} />
</WizardScope>
</Modal>
);
@@ -1,6 +1,11 @@
import { buildLogicFunctionPayload } from './build-logic-function-payload';
import { type PartnerApplicationRequest } from './partner-application-request-schema';
const validExperienceNotes =
'Built a custom Twenty app for a property-management client, modeled leases and ' +
'tenants as data models, automated renewal workflows, and shipped a front component ' +
'for the broker dashboard with role-based views.';
const minimalValid: PartnerApplicationRequest = {
name: 'Ada Lovelace',
email: 'ada@example.com',
@@ -9,6 +14,9 @@ const minimalValid: PartnerApplicationRequest = {
city: 'London',
hourlyRate: 150,
projectBudgetMin: 5000,
twentyExperience: ['WORKFLOWS'],
twentyExperienceNotes: validExperienceNotes,
twentyExperienceProofLink: 'https://www.loom.com/share/example',
};
const fullValid: PartnerApplicationRequest = {
@@ -19,7 +27,6 @@ const fullValid: PartnerApplicationRequest = {
typeOfTeam: 'SOLO',
partnerScope: ['ADVISORY', 'SOLUTIONING'],
skills: ['React', 'TypeScript'],
applicationNotes: 'refs: Acme, Globex',
calendarLink: 'https://cal.com/ada',
};
@@ -39,10 +46,14 @@ describe('buildLogicFunctionPayload', () => {
);
});
it('forwards applicationNotes through to the payload', () => {
expect(buildLogicFunctionPayload(fullValid).applicationNotes).toContain(
'Acme',
it('forwards twenty experience fields to the webhook payload', () => {
const payload = buildLogicFunctionPayload(fullValid);
expect(payload.twentyExperience).toEqual(['WORKFLOWS']);
expect(payload.twentyExperienceNotes).toBe(validExperienceNotes);
expect(payload.twentyExperienceProofLink).toBe(
'https://www.loom.com/share/example',
);
expect(payload).not.toHaveProperty('applicationNotes');
});
it('omits keys for undefined optional fields', () => {
@@ -53,7 +64,7 @@ describe('buildLogicFunctionPayload', () => {
expect(payload).not.toHaveProperty('partnerScope');
});
it('omits empty arrays', () => {
it('omits empty arrays for optional multi-selects', () => {
const payload = buildLogicFunctionPayload({
...minimalValid,
languages: [],
@@ -2,6 +2,7 @@ import { type PartnerCountryValue } from './data/partner-country-options';
import { type PartnerLanguageValue } from './data/partner-language-options';
import { type PartnerScopeValue } from './data/partner-scope-options';
import { type PartnerTeamTypeValue } from './data/partner-team-type-options';
import { type PartnerTwentyExperienceValue } from './data/partner-twenty-experience-options';
import { type PartnerApplicationRequest } from './partner-application-request-schema';
import { splitFullName } from './split-full-name';
@@ -21,7 +22,9 @@ export type PartnerApplicationLogicFunctionPayload = {
typeOfTeam?: PartnerTeamTypeValue;
partnerScope?: readonly PartnerScopeValue[];
skills?: readonly string[];
applicationNotes?: string;
twentyExperience: readonly PartnerTwentyExperienceValue[];
twentyExperienceNotes: string;
twentyExperienceProofLink: string;
hourlyRate?: number;
projectBudgetMin?: number;
calendarLink?: string;
@@ -37,6 +40,9 @@ export function buildLogicFunctionPayload(
lastName,
email: request.email,
companyName: request.company,
twentyExperience: request.twentyExperience,
twentyExperienceNotes: request.twentyExperienceNotes,
twentyExperienceProofLink: request.twentyExperienceProofLink,
};
if (request.website !== undefined) payload.domainName = request.website;
@@ -50,8 +56,6 @@ export function buildLogicFunctionPayload(
payload.partnerScope = request.partnerScope;
if (request.skills !== undefined && request.skills.length > 0)
payload.skills = request.skills;
if (request.applicationNotes !== undefined)
payload.applicationNotes = request.applicationNotes;
if (request.hourlyRate !== undefined) payload.hourlyRate = request.hourlyRate;
if (request.projectBudgetMin !== undefined)
payload.projectBudgetMin = request.projectBudgetMin;
@@ -4,6 +4,11 @@ import {
type PartnerApplicationState,
} from './partner-application-state';
const validExperienceNotes =
'Built a custom Twenty app for a property-management client, modeled leases and ' +
'tenants as data models, automated renewal workflows, and shipped a front component ' +
'for the broker dashboard with role-based views.';
const minimalState: PartnerApplicationState = {
...INITIAL_PARTNER_APPLICATION_STATE,
name: 'Ada Lovelace',
@@ -13,10 +18,13 @@ const minimalState: PartnerApplicationState = {
city: 'London',
hourlyRate: '150',
projectBudgetMin: '5000',
twentyExperience: ['WORKFLOWS'],
twentyExperienceNotes: validExperienceNotes,
twentyExperienceProofLink: 'https://www.loom.com/share/example',
};
describe('buildPartnerApplicationRequestBody', () => {
it('keeps only the required fields when optionals are empty', () => {
it('includes required fields and experience data', () => {
expect(buildPartnerApplicationRequestBody(minimalState)).toEqual({
name: 'Ada Lovelace',
email: 'ada@example.com',
@@ -25,6 +33,9 @@ describe('buildPartnerApplicationRequestBody', () => {
city: 'London',
hourlyRate: 150,
projectBudgetMin: 5000,
twentyExperience: ['WORKFLOWS'],
twentyExperienceNotes: validExperienceNotes,
twentyExperienceProofLink: 'https://www.loom.com/share/example',
});
});
@@ -34,22 +45,27 @@ describe('buildPartnerApplicationRequestBody', () => {
name: ' Ada Lovelace ',
email: ' ada@example.com ',
company: ' Analytical Engines Ltd ',
twentyExperienceNotes: ` ${validExperienceNotes} `,
twentyExperienceProofLink: ' https://www.loom.com/share/example ',
});
expect(body.name).toBe('Ada Lovelace');
expect(body.email).toBe('ada@example.com');
expect(body.company).toBe('Analytical Engines Ltd');
expect(body.twentyExperienceNotes).toBe(validExperienceNotes);
expect(body.twentyExperienceProofLink).toBe(
'https://www.loom.com/share/example',
);
});
it('omits optional string fields that are blank or whitespace-only', () => {
const body = buildPartnerApplicationRequestBody({
...minimalState,
linkedin: '',
applicationNotes: '',
calendarLink: ' ',
});
expect(body).not.toHaveProperty('linkedin');
expect(body).not.toHaveProperty('applicationNotes');
expect(body).not.toHaveProperty('calendarLink');
expect(body).not.toHaveProperty('applicationNotes');
});
it('trims optional string fields when present', () => {
@@ -58,13 +74,11 @@ describe('buildPartnerApplicationRequestBody', () => {
website: ' https://analyticalengines.example ',
linkedin: ' https://www.linkedin.com/in/ada ',
city: ' London ',
applicationNotes: ' refs: Acme ',
calendarLink: ' https://cal.com/ada ',
});
expect(body.website).toBe('https://analyticalengines.example');
expect(body.linkedin).toBe('https://www.linkedin.com/in/ada');
expect(body.city).toBe('London');
expect(body.applicationNotes).toBe('refs: Acme');
expect(body.calendarLink).toBe('https://cal.com/ada');
});
@@ -83,12 +97,17 @@ describe('buildPartnerApplicationRequestBody', () => {
languages: ['ENGLISH', 'FRENCH'],
partnerScope: ['ADVISORY', 'SOLUTIONING'],
skills: ['React', 'TypeScript'],
twentyExperience: ['CUSTOM_APPS', 'FRONT_COMPONENTS'],
});
expect(filled.country).toBe('UNITED_KINGDOM');
expect(filled.typeOfTeam).toBe('SOLO');
expect(filled.languages).toEqual(['ENGLISH', 'FRENCH']);
expect(filled.partnerScope).toEqual(['ADVISORY', 'SOLUTIONING']);
expect(filled.skills).toEqual(['React', 'TypeScript']);
expect(filled.twentyExperience).toEqual([
'CUSTOM_APPS',
'FRONT_COMPONENTS',
]);
});
it('parses hourlyRate and projectBudgetMin into non-negative numbers', () => {
@@ -12,6 +12,9 @@ export function buildPartnerApplicationRequestBody(
city: state.city.trim(),
hourlyRate: Number.parseFloat(state.hourlyRate),
projectBudgetMin: Number.parseFloat(state.projectBudgetMin),
twentyExperience: state.twentyExperience,
twentyExperienceNotes: state.twentyExperienceNotes.trim(),
twentyExperienceProofLink: state.twentyExperienceProofLink.trim(),
};
if (state.linkedin.trim()) body.linkedin = state.linkedin.trim();
@@ -20,8 +23,6 @@ export function buildPartnerApplicationRequestBody(
if (state.typeOfTeam !== '') body.typeOfTeam = state.typeOfTeam;
if (state.partnerScope.length > 0) body.partnerScope = state.partnerScope;
if (state.skills.length > 0) body.skills = state.skills;
if (state.applicationNotes.trim())
body.applicationNotes = state.applicationNotes.trim();
if (state.calendarLink.trim()) body.calendarLink = state.calendarLink.trim();
return body;
@@ -2,8 +2,9 @@ export type PartnerApplicationStepId =
| 'identity'
| 'profile'
| 'expertise'
| 'experience'
| 'commercials';
// Order is the wizard's step order: the reducer indexes into this by stepIndex.
export const PARTNER_APPLICATION_STEP_IDS: readonly PartnerApplicationStepId[] =
['identity', 'profile', 'expertise', 'commercials'];
['identity', 'profile', 'expertise', 'experience', 'commercials'];
@@ -0,0 +1,13 @@
import { msg } from '@lingui/core/macro';
import { defineFieldOptions } from './define-field-options';
export const PARTNER_TWENTY_EXPERIENCE_OPTIONS = defineFieldOptions([
{ value: 'CUSTOM_APPS', label: msg`Custom apps` },
{ value: 'DATA_MODELS', label: msg`Data models` },
{ value: 'WORKFLOWS', label: msg`Workflows` },
{ value: 'FRONT_COMPONENTS', label: msg`Front components` },
]);
export type PartnerTwentyExperienceValue =
(typeof PARTNER_TWENTY_EXPERIENCE_OPTIONS)[number]['value'];
@@ -0,0 +1 @@
export const TWENTY_EXPERIENCE_NOTES_MIN_LENGTH = 200;
@@ -1,8 +0,0 @@
// The Cal.com path (no host) and namespace the success screen books the partner
// intro call onto. The namespace scopes the embed's ui() config to this booking
// only. A fixed link — this is the brand site, so there is no self-hoster
// override (the old site's env knob does not apply here).
export const PARTNER_INTRO_CAL: { link: string; namespace: string } = {
link: 'rashad-twenty/partner-intro',
namespace: 'partner-intro',
};
@@ -1,5 +1,7 @@
import { msg } from '@lingui/core/macro';
import { TWENTY_EXPERIENCE_NOTES_MIN_LENGTH } from './data/twenty-experience-notes-min-length';
export const PARTNER_APPLICATION_COPY = {
title: msg`Apply to build\n*the future of CRM*`,
subtitle: msg`Join our ecosystem and help businesses take control of their customer data with open-source primitives.`,
@@ -7,9 +9,9 @@ export const PARTNER_APPLICATION_COPY = {
next: msg`Next →`,
submit: msg`Submit application`,
submitInFlight: msg`Submitting…`,
successTitle: msg`Thanks, you're in.\n*Now book your intro call.*`,
bookIntroSubtitle: msg`Grab 30 minutes so we can get to know your team.`,
bookLater: msg`I'll book later →`,
successTitle: msg`Thanks for applying.`,
successSubtitle: msg`Our team reviews every application and will reach out if there's a fit.`,
successDone: msg`Done`,
stepProgressLabel: (current: number, total: number) =>
msg`Step ${current} of ${total}`,
validation: {
@@ -17,6 +19,7 @@ export const PARTNER_APPLICATION_COPY = {
invalidEmail: msg`Enter a valid email address.`,
invalidUrl: msg`Enter a valid URL (starting with http:// or https://).`,
invalidAmount: msg`Enter a valid amount using numbers only.`,
notesTooShort: msg`Please write at least ${TWENTY_EXPERIENCE_NOTES_MIN_LENGTH} characters about the implementation.`,
submitFailed: msg`We could not submit your application. Please try again in a moment.`,
},
removeSkill: (skill: string) => msg`Remove ${skill}`,
@@ -39,8 +42,13 @@ export const PARTNER_APPLICATION_COPY = {
skills: msg`Technical skills`,
skillsHint: msg`Tools, technologies and industries you specialize in. Press Enter or comma to add.`,
skillsPlaceholder: msg`e.g. n8n, Shopify, Real estate…`,
applicationNotes: msg`Anything else we should know?`,
applicationNotesPlaceholder: msg`Workspace URL, customer references, relevant links…`,
twentyExperience: msg`What you've built in Twenty *`,
twentyExperienceHint: msg`Select every area you used in a real project — not what you offer, what you actually did.`,
twentyExperienceNotes: msg`Tell us about the implementation *`,
twentyExperienceNotesHint: msg`Describe a real Twenty project (customer or internal): who it was for, what you built, and how you used the areas you selected. Min. ${TWENTY_EXPERIENCE_NOTES_MIN_LENGTH} characters.`,
twentyExperienceNotesPlaceholder: msg`Who it was for, what you built, which of the four areas you used…`,
twentyExperienceProofLink: msg`Proof URL *`,
twentyExperienceProofLinkHint: msg`Link that shows the work (Loom, GitHub, Notion, Drive, public write-up…). No public workspace? Share a short Loom.`,
hourlyRate: msg`Hourly rate *`,
hourlyRatePlaceholder: msg`150`,
projectBudgetMin: msg`Minimum project budget *`,
@@ -50,7 +58,8 @@ export const PARTNER_APPLICATION_COPY = {
stepHeaders: {
identity: msg`Identity`,
profile: msg`Profile`,
expertise: msg`Expertise & experience`,
expertise: msg`Expertise`,
experience: msg`Experience`,
commercials: msg`Commercials`,
},
};
@@ -1,3 +1,4 @@
import { PARTNER_APPLICATION_STEP_IDS } from './data/partner-application-step-ids';
import { partnerApplicationReducer } from './partner-application-reducer';
import {
INITIAL_PARTNER_APPLICATION_STATE,
@@ -11,11 +12,27 @@ const baseValidIdentity: Partial<PartnerApplicationState> = {
website: 'https://analyticalengines.example',
};
const validExperienceNotes =
'Built a custom Twenty app for a property-management client, modeled leases and ' +
'tenants as data models, automated renewal workflows, and shipped a front component ' +
'for the broker dashboard with role-based views.';
describe('partnerApplicationReducer', () => {
it('orders steps identity → profile → expertise → experience → commercials', () => {
expect(PARTNER_APPLICATION_STEP_IDS).toEqual([
'identity',
'profile',
'expertise',
'experience',
'commercials',
]);
});
it('starts at stepIndex 0 with empty fields', () => {
expect(INITIAL_PARTNER_APPLICATION_STATE.stepIndex).toBe(0);
expect(INITIAL_PARTNER_APPLICATION_STATE.name).toBe('');
expect(INITIAL_PARTNER_APPLICATION_STATE.partnerScope).toEqual([]);
expect(INITIAL_PARTNER_APPLICATION_STATE.twentyExperience).toEqual([]);
});
it('SET_FIELD updates the field and clears any prior error for it', () => {
@@ -45,6 +62,19 @@ describe('partnerApplicationReducer', () => {
expect(removed.partnerScope).toEqual([]);
});
it('TOGGLE_EXPERIENCE adds and then removes a milestone', () => {
const added = partnerApplicationReducer(INITIAL_PARTNER_APPLICATION_STATE, {
type: 'TOGGLE_EXPERIENCE',
value: 'CUSTOM_APPS',
});
expect(added.twentyExperience).toEqual(['CUSTOM_APPS']);
const removed = partnerApplicationReducer(added, {
type: 'TOGGLE_EXPERIENCE',
value: 'CUSTOM_APPS',
});
expect(removed.twentyExperience).toEqual([]);
});
it('GO_NEXT on Identity with missing required fields fills errors and stays', () => {
const next = partnerApplicationReducer(INITIAL_PARTNER_APPLICATION_STATE, {
type: 'GO_NEXT',
@@ -115,10 +145,59 @@ describe('partnerApplicationReducer', () => {
expect(ok.fieldErrors).toEqual({});
});
it('GO_NEXT on Expertise advances to Experience when partnerScope is set', () => {
const onExpertise: PartnerApplicationState = {
...INITIAL_PARTNER_APPLICATION_STATE,
stepIndex: 2,
partnerScope: ['ADVISORY'],
};
const next = partnerApplicationReducer(onExpertise, { type: 'GO_NEXT' });
expect(next.stepIndex).toBe(3);
expect(next.fieldErrors).toEqual({});
});
it('GO_NEXT on Experience gates on milestones, narrative length, and proof URL', () => {
const onExperience: PartnerApplicationState = {
...INITIAL_PARTNER_APPLICATION_STATE,
stepIndex: 3,
};
const blocked = partnerApplicationReducer(onExperience, {
type: 'GO_NEXT',
});
expect(blocked.stepIndex).toBe(3);
expect(blocked.fieldErrors.twentyExperience).toBe('required');
expect(blocked.fieldErrors.twentyExperienceNotes).toBe('required');
expect(blocked.fieldErrors.twentyExperienceProofLink).toBe('required');
const shortNotes = partnerApplicationReducer(
{
...onExperience,
twentyExperience: ['WORKFLOWS'],
twentyExperienceNotes: 'Too short.',
twentyExperienceProofLink: 'https://www.loom.com/share/example',
},
{ type: 'GO_NEXT' },
);
expect(shortNotes.stepIndex).toBe(3);
expect(shortNotes.fieldErrors.twentyExperienceNotes).toBe('too_short');
const ok = partnerApplicationReducer(
{
...onExperience,
twentyExperience: ['WORKFLOWS'],
twentyExperienceNotes: validExperienceNotes,
twentyExperienceProofLink: 'https://www.loom.com/share/example',
},
{ type: 'GO_NEXT' },
);
expect(ok.stepIndex).toBe(4);
expect(ok.fieldErrors).toEqual({});
});
it('GO_NEXT on Commercials gates on hourly rate and minimum budget', () => {
const onCommercials: PartnerApplicationState = {
...INITIAL_PARTNER_APPLICATION_STATE,
stepIndex: 3,
stepIndex: 4,
};
const blocked = partnerApplicationReducer(onCommercials, {
type: 'GO_NEXT',
@@ -157,7 +236,7 @@ describe('partnerApplicationReducer', () => {
const dirty: PartnerApplicationState = {
...INITIAL_PARTNER_APPLICATION_STATE,
name: 'x',
stepIndex: 3,
stepIndex: 4,
isSubmitting: true,
isSubmitted: true,
};
@@ -42,6 +42,16 @@ export function partnerApplicationReducer(
: [...state.languages, action.value];
return { ...state, languages: next };
}
case 'TOGGLE_EXPERIENCE': {
const next = state.twentyExperience.includes(action.value)
? state.twentyExperience.filter((value) => value !== action.value)
: [...state.twentyExperience, action.value];
return {
...state,
twentyExperience: next,
fieldErrors: dropError(state.fieldErrors, 'twentyExperience'),
};
}
case 'SET_SKILLS':
return { ...state, skills: action.value };
case 'GO_NEXT': {
@@ -75,7 +85,10 @@ export function partnerApplicationReducer(
};
case 'RESET':
return INITIAL_PARTNER_APPLICATION_STATE;
default:
default: {
const _exhaustive: never = action;
void _exhaustive;
return state;
}
}
}
@@ -1,5 +1,10 @@
import { partnerApplicationRequestSchema } from './partner-application-request-schema';
const validExperienceNotes =
'Built a custom Twenty app for a property-management client, modeled leases and ' +
'tenants as data models, automated renewal workflows, and shipped a front component ' +
'for the broker dashboard with role-based views.';
const minimalValid = {
name: 'Ada Lovelace',
email: 'ada@example.com',
@@ -8,6 +13,9 @@ const minimalValid = {
city: 'London',
hourlyRate: 150,
projectBudgetMin: 5000,
twentyExperience: ['WORKFLOWS'],
twentyExperienceNotes: validExperienceNotes,
twentyExperienceProofLink: 'https://www.loom.com/share/example',
};
const fullValid = {
@@ -18,7 +26,6 @@ const fullValid = {
typeOfTeam: 'SOLO',
partnerScope: ['ADVISORY', 'SOLUTIONING'],
skills: ['React', 'TypeScript'],
applicationNotes: 'Workspace https://app.twenty.com/ws/ada · refs: Acme',
calendarLink: 'https://cal.com/ada',
};
@@ -62,6 +69,15 @@ describe('partnerApplicationRequestSchema', () => {
).toBe(false);
});
it('rejects legacy applicationNotes', () => {
expect(
partnerApplicationRequestSchema.safeParse({
...minimalValid,
applicationNotes: 'Workspace URL and refs',
}).success,
).toBe(false);
});
it('rejects an invalid email', () => {
expect(
partnerApplicationRequestSchema.safeParse({
@@ -79,7 +95,15 @@ describe('partnerApplicationRequestSchema', () => {
});
it('rejects when a newly-required field is missing', () => {
for (const field of ['website', 'city', 'hourlyRate', 'projectBudgetMin']) {
for (const field of [
'website',
'city',
'hourlyRate',
'projectBudgetMin',
'twentyExperience',
'twentyExperienceNotes',
'twentyExperienceProofLink',
]) {
const withoutField = Object.fromEntries(
Object.entries(minimalValid).filter(([key]) => key !== field),
);
@@ -89,6 +113,42 @@ describe('partnerApplicationRequestSchema', () => {
}
});
it('rejects empty twentyExperience', () => {
expect(
partnerApplicationRequestSchema.safeParse({
...minimalValid,
twentyExperience: [],
}).success,
).toBe(false);
});
it('rejects an unknown twentyExperience milestone', () => {
expect(
partnerApplicationRequestSchema.safeParse({
...minimalValid,
twentyExperience: ['INTEGRATIONS'],
}).success,
).toBe(false);
});
it('rejects twentyExperienceNotes shorter than 200 characters', () => {
expect(
partnerApplicationRequestSchema.safeParse({
...minimalValid,
twentyExperienceNotes: 'Too short for a real implementation narrative.',
}).success,
).toBe(false);
});
it('rejects an invalid twentyExperienceProofLink', () => {
expect(
partnerApplicationRequestSchema.safeParse({
...minimalValid,
twentyExperienceProofLink: 'not-a-url',
}).success,
).toBe(false);
});
it('rejects a negative hourly rate', () => {
expect(
partnerApplicationRequestSchema.safeParse({
@@ -4,6 +4,8 @@ import { PARTNER_COUNTRY_OPTIONS } from './data/partner-country-options';
import { PARTNER_LANGUAGE_OPTIONS } from './data/partner-language-options';
import { PARTNER_SCOPE_OPTIONS } from './data/partner-scope-options';
import { PARTNER_TEAM_TYPE_OPTIONS } from './data/partner-team-type-options';
import { PARTNER_TWENTY_EXPERIENCE_OPTIONS } from './data/partner-twenty-experience-options';
import { TWENTY_EXPERIENCE_NOTES_MIN_LENGTH } from './data/twenty-experience-notes-min-length';
import { emailFieldSchema } from './email-field-schema';
import { httpUrlFieldSchema } from './http-url-field-schema';
@@ -11,8 +13,10 @@ const countryValues = PARTNER_COUNTRY_OPTIONS.map((option) => option.value);
const languageValues = PARTNER_LANGUAGE_OPTIONS.map((option) => option.value);
const scopeValues = PARTNER_SCOPE_OPTIONS.map((option) => option.value);
const teamTypeValues = PARTNER_TEAM_TYPE_OPTIONS.map((option) => option.value);
const twentyExperienceValues = PARTNER_TWENTY_EXPERIENCE_OPTIONS.map(
(option) => option.value,
);
const optionalNonEmptyString = z.string().trim().min(1).optional();
const optionalUrl = httpUrlFieldSchema.optional();
export const partnerApplicationRequestSchema = z.strictObject({
@@ -27,7 +31,16 @@ export const partnerApplicationRequestSchema = z.strictObject({
typeOfTeam: z.enum(teamTypeValues).optional(),
partnerScope: z.array(z.enum(scopeValues)).optional(),
skills: z.array(z.string().trim().min(1)).optional(),
applicationNotes: optionalNonEmptyString,
twentyExperience: z
.array(z.enum(twentyExperienceValues))
.min(1, { error: 'Select at least one Twenty experience area.' }),
twentyExperienceNotes: z
.string()
.trim()
.min(TWENTY_EXPERIENCE_NOTES_MIN_LENGTH, {
error: `Describe the implementation in at least ${TWENTY_EXPERIENCE_NOTES_MIN_LENGTH} characters.`,
}),
twentyExperienceProofLink: httpUrlFieldSchema,
hourlyRate: z.number({ error: 'Hourly rate is required.' }).nonnegative(),
projectBudgetMin: z
.number({ error: 'Minimum project budget is required.' })
@@ -2,6 +2,7 @@ import { type PartnerCountryValue } from './data/partner-country-options';
import { type PartnerLanguageValue } from './data/partner-language-options';
import { type PartnerScopeValue } from './data/partner-scope-options';
import { type PartnerTeamTypeValue } from './data/partner-team-type-options';
import { type PartnerTwentyExperienceValue } from './data/partner-twenty-experience-options';
export type CountryFieldValue = PartnerCountryValue | '';
@@ -20,11 +21,15 @@ export type PartnerApplicationState = {
country: CountryFieldValue;
languages: PartnerLanguageValue[];
// Expertise & experience
// Expertise
typeOfTeam: PartnerTeamTypeValue | '';
partnerScope: PartnerScopeValue[];
skills: string[];
applicationNotes: string;
// Experience (what they've built in Twenty)
twentyExperience: PartnerTwentyExperienceValue[];
twentyExperienceNotes: string;
twentyExperienceProofLink: string;
// Commercials
hourlyRate: string;
@@ -39,7 +44,8 @@ export type PartnerApplicationState = {
};
// The scalar (string-valued) fields a single SET_FIELD action can target;
// multi-value fields (languages, partnerScope, skills) have their own actions.
// multi-value fields (languages, partnerScope, skills, twentyExperience) have
// their own actions.
export type ScalarFieldName =
| 'name'
| 'email'
@@ -49,7 +55,8 @@ export type ScalarFieldName =
| 'city'
| 'country'
| 'typeOfTeam'
| 'applicationNotes'
| 'twentyExperienceNotes'
| 'twentyExperienceProofLink'
| 'hourlyRate'
| 'projectBudgetMin'
| 'calendarLink';
@@ -58,6 +65,7 @@ export type PartnerApplicationAction =
| { type: 'SET_FIELD'; field: ScalarFieldName; value: string }
| { type: 'TOGGLE_SCOPE'; value: PartnerScopeValue }
| { type: 'TOGGLE_LANGUAGE'; value: PartnerLanguageValue }
| { type: 'TOGGLE_EXPERIENCE'; value: PartnerTwentyExperienceValue }
| { type: 'SET_SKILLS'; value: string[] }
| { type: 'GO_NEXT' }
| { type: 'GO_BACK' }
@@ -79,7 +87,9 @@ export const INITIAL_PARTNER_APPLICATION_STATE: PartnerApplicationState = {
typeOfTeam: '',
partnerScope: [],
skills: [],
applicationNotes: '',
twentyExperience: [],
twentyExperienceNotes: '',
twentyExperienceProofLink: '',
hourlyRate: '',
projectBudgetMin: '',
calendarLink: '',
@@ -1,27 +0,0 @@
import { buildPartnerIntroPrefill } from './partner-intro-prefill';
describe('buildPartnerIntroPrefill', () => {
it('maps name and email and folds company into notes', () => {
expect(
buildPartnerIntroPrefill({
name: 'Ada Lovelace',
email: 'ada@example.com',
company: 'Analytical Engines',
}),
).toEqual({
name: 'Ada Lovelace',
email: 'ada@example.com',
notes: 'Company: Analytical Engines',
});
});
it('trims inputs and omits blank ones', () => {
expect(
buildPartnerIntroPrefill({ name: ' Ada ', email: '', company: ' ' }),
).toEqual({ name: 'Ada' });
});
it('returns an empty prefill for no input', () => {
expect(buildPartnerIntroPrefill({})).toEqual({});
});
});
@@ -1,22 +0,0 @@
// Cal.com prefill keys for the intro call. `notes` carries the applicant's
// company so the booking arrives pre-qualified. Empty inputs are omitted.
export type PartnerIntroPrefill = {
name?: string;
email?: string;
notes?: string;
};
export function buildPartnerIntroPrefill(input: {
name?: string;
email?: string;
company?: string;
}): PartnerIntroPrefill {
const prefill: PartnerIntroPrefill = {};
const name = input.name?.trim();
const email = input.email?.trim();
const company = input.company?.trim();
if (name) prefill.name = name;
if (email) prefill.email = email;
if (company) prefill.notes = `Company: ${company}`;
return prefill;
}
@@ -4,6 +4,7 @@ import { useCallback, useReducer } from 'react';
import { type PartnerLanguageValue } from './data/partner-language-options';
import { type PartnerScopeValue } from './data/partner-scope-options';
import { type PartnerTwentyExperienceValue } from './data/partner-twenty-experience-options';
import { partnerApplicationReducer } from './partner-application-reducer';
import {
INITIAL_PARTNER_APPLICATION_STATE,
@@ -32,6 +33,11 @@ export function usePartnerApplicationState() {
dispatch({ type: 'TOGGLE_LANGUAGE', value }),
[],
);
const toggleExperience = useCallback(
(value: PartnerTwentyExperienceValue) =>
dispatch({ type: 'TOGGLE_EXPERIENCE', value }),
[],
);
const setSkills = useCallback(
(value: string[]) => dispatch({ type: 'SET_SKILLS', value }),
[],
@@ -57,6 +63,7 @@ export function usePartnerApplicationState() {
setField,
toggleScope,
toggleLanguage,
toggleExperience,
setSkills,
goNext,
goBack,
@@ -0,0 +1,52 @@
import { INITIAL_PARTNER_APPLICATION_STATE } from './partner-application-state';
import { validatePartnerApplicationStep } from './validate-partner-application-step';
const validExperienceNotes =
'Built a custom Twenty app for a property-management client, modeled leases and ' +
'tenants as data models, automated renewal workflows, and shipped a front component ' +
'for the broker dashboard with role-based views.';
describe('validatePartnerApplicationStep', () => {
it('requires experience milestones, narrative, and proof URL on Experience', () => {
const errors = validatePartnerApplicationStep({
...INITIAL_PARTNER_APPLICATION_STATE,
stepIndex: 3,
});
expect(errors.twentyExperience).toBe('required');
expect(errors.twentyExperienceNotes).toBe('required');
expect(errors.twentyExperienceProofLink).toBe('required');
});
it('rejects a narrative under 200 characters on Experience', () => {
const errors = validatePartnerApplicationStep({
...INITIAL_PARTNER_APPLICATION_STATE,
stepIndex: 3,
twentyExperience: ['WORKFLOWS'],
twentyExperienceNotes: 'Too short for a real implementation narrative.',
twentyExperienceProofLink: 'https://www.loom.com/share/example',
});
expect(errors.twentyExperienceNotes).toBe('too_short');
});
it('rejects an invalid proof URL on Experience', () => {
const errors = validatePartnerApplicationStep({
...INITIAL_PARTNER_APPLICATION_STATE,
stepIndex: 3,
twentyExperience: ['CUSTOM_APPS'],
twentyExperienceNotes: validExperienceNotes,
twentyExperienceProofLink: 'not-a-url',
});
expect(errors.twentyExperienceProofLink).toBe('invalid_url');
});
it('accepts a complete Experience step', () => {
const errors = validatePartnerApplicationStep({
...INITIAL_PARTNER_APPLICATION_STATE,
stepIndex: 3,
twentyExperience: ['CUSTOM_APPS', 'DATA_MODELS'],
twentyExperienceNotes: validExperienceNotes,
twentyExperienceProofLink: 'https://www.loom.com/share/example',
});
expect(errors).toEqual({});
});
});
@@ -2,6 +2,7 @@ import {
PARTNER_APPLICATION_STEP_IDS,
type PartnerApplicationStepId,
} from './data/partner-application-step-ids';
import { TWENTY_EXPERIENCE_NOTES_MIN_LENGTH } from './data/twenty-experience-notes-min-length';
import { emailFieldSchema } from './email-field-schema';
import { httpUrlFieldSchema } from './http-url-field-schema';
import { nonNegativeAmountFieldSchema } from './non-negative-amount-field-schema';
@@ -14,6 +15,11 @@ const STEP_REQUIRED_FIELDS: Record<
identity: ['name', 'email', 'company', 'website'],
profile: ['country', 'typeOfTeam', 'city'],
expertise: ['partnerScope'],
experience: [
'twentyExperience',
'twentyExperienceNotes',
'twentyExperienceProofLink',
],
commercials: ['hourlyRate', 'projectBudgetMin'],
};
@@ -29,6 +35,7 @@ type FieldFormatCheck = {
| 'website'
| 'linkedin'
| 'calendarLink'
| 'twentyExperienceProofLink'
| 'hourlyRate'
| 'projectBudgetMin';
schema:
@@ -48,6 +55,13 @@ const STEP_FORMAT_CHECKS: Partial<
profile: [
{ field: 'linkedin', schema: httpUrlFieldSchema, errorCode: 'invalid_url' },
],
experience: [
{
field: 'twentyExperienceProofLink',
schema: httpUrlFieldSchema,
errorCode: 'invalid_url',
},
],
commercials: [
{
field: 'hourlyRate',
@@ -85,6 +99,15 @@ export function validatePartnerApplicationStep(
}
}
if (
stepId === 'experience' &&
errors.twentyExperienceNotes === undefined &&
state.twentyExperienceNotes.trim().length <
TWENTY_EXPERIENCE_NOTES_MIN_LENGTH
) {
errors.twentyExperienceNotes = 'too_short';
}
for (const check of STEP_FORMAT_CHECKS[stepId] ?? []) {
const value = state[check.field];
if (value && !check.schema.safeParse(value).success) {
@@ -3,20 +3,10 @@
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import { CalEmbed } from '@/platform/cal/CalEmbed';
import {
fontFamily,
fontSize,
FONT_WEIGHT,
radius,
semanticColor,
spacing,
} from '@/tokens';
import { Body, Heading } from '@/ui';
import { spacing } from '@/tokens';
import { Body, Button, Heading } from '@/ui';
import { PARTNER_INTRO_CAL } from '../partner-application-config';
import { PARTNER_APPLICATION_COPY } from '../partner-application-copy';
import { buildPartnerIntroPrefill } from '../partner-intro-prefill';
const SuccessView = styled.div`
display: flex;
@@ -28,38 +18,16 @@ const SuccessView = styled.div`
}
`;
const EmbedFrame = styled.div`
border: 1px solid ${semanticColor.line};
border-radius: ${radius(2)};
overflow: hidden;
`;
const BookLater = styled.button`
const Actions = styled.div`
align-self: flex-end;
background: none;
border: none;
color: ${semanticColor.inkMuted};
cursor: pointer;
font-family: ${fontFamily('mono')};
font-size: ${fontSize(3)};
font-weight: ${FONT_WEIGHT.medium};
padding: 0;
text-transform: uppercase;
`;
export function PartnerApplicationSuccess({
company,
email,
name,
onDismiss,
}: {
company: string;
email: string;
name: string;
onDismiss: () => void;
}) {
const { i18n } = useLingui();
const prefill = buildPartnerIntroPrefill({ company, email, name });
return (
<>
@@ -68,19 +36,16 @@ export function PartnerApplicationSuccess({
</Heading>
<SuccessView>
<Body muted size="md">
{i18n._(PARTNER_APPLICATION_COPY.bookIntroSubtitle)}
{i18n._(PARTNER_APPLICATION_COPY.successSubtitle)}
</Body>
<EmbedFrame>
<CalEmbed
calLink={PARTNER_INTRO_CAL.link}
layout="month_view"
namespace={PARTNER_INTRO_CAL.namespace}
prefill={prefill}
<Actions>
<Button
label={i18n._(PARTNER_APPLICATION_COPY.successDone)}
onClick={onDismiss}
type="button"
variant="filled"
/>
</EmbedFrame>
<BookLater onClick={onDismiss} type="button">
{i18n._(PARTNER_APPLICATION_COPY.bookLater)}
</BookLater>
</Actions>
</SuccessView>
</>
);
@@ -25,6 +25,7 @@ import {
import { validatePartnerApplicationStep } from '../validate-partner-application-step';
import { PartnerApplicationSuccess } from './PartnerApplicationSuccess';
import { CommercialsStep } from './steps/CommercialsStep';
import { ExperienceStep } from './steps/ExperienceStep';
import { ExpertiseStep } from './steps/ExpertiseStep';
import { IdentityStep } from './steps/IdentityStep';
import { ProfileStep } from './steps/ProfileStep';
@@ -122,24 +123,47 @@ function StepRenderer({
}: {
controller: PartnerApplicationController;
}) {
switch (getCurrentStepId(controller.state)) {
const stepId = getCurrentStepId(controller.state);
switch (stepId) {
case 'identity':
return <IdentityStep controller={controller} />;
case 'profile':
return <ProfileStep controller={controller} />;
case 'expertise':
return <ExpertiseStep controller={controller} />;
case 'experience':
return <ExperienceStep controller={controller} />;
case 'commercials':
return <CommercialsStep controller={controller} />;
default: {
const _exhaustive: never = stepId;
return _exhaustive;
}
}
}
function resolveFieldErrorMessage(
errorValues: string[],
): (typeof COPY.validation)[keyof typeof COPY.validation] {
if (errorValues.includes('invalid_email')) {
return COPY.validation.invalidEmail;
}
if (errorValues.includes('invalid_url')) {
return COPY.validation.invalidUrl;
}
if (errorValues.includes('invalid_amount')) {
return COPY.validation.invalidAmount;
}
if (errorValues.includes('too_short')) {
return COPY.validation.notesTooShort;
}
return COPY.validation.incompleteForm;
}
export function PartnerApplicationWizard({
onSubmitted,
onSuccess,
resetSignal,
}: {
onSubmitted?: () => void;
onSuccess: () => void;
resetSignal: number;
}) {
@@ -162,15 +186,11 @@ export function PartnerApplicationWizard({
const stepId = getCurrentStepId(state);
const stepIndex = state.stepIndex;
const isLastStep = stepIndex === STEPS.length - 1;
const errorValues = Object.values(state.fieldErrors);
const errorValues = Object.values(state.fieldErrors).filter(
(value): value is string => value !== undefined,
);
const hasFieldErrors = errorValues.length > 0;
const fieldErrorMessage = errorValues.includes('invalid_email')
? COPY.validation.invalidEmail
: errorValues.includes('invalid_url')
? COPY.validation.invalidUrl
: errorValues.includes('invalid_amount')
? COPY.validation.invalidAmount
: COPY.validation.incompleteForm;
const fieldErrorMessage = resolveFieldErrorMessage(errorValues);
const handleSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
@@ -201,7 +221,6 @@ export function PartnerApplicationWizard({
return;
}
setSubmitted();
onSubmitted?.();
} catch {
setSubmitError(i18n._(COPY.validation.submitFailed));
} finally {
@@ -212,7 +231,6 @@ export function PartnerApplicationWizard({
goNext,
i18n,
isLastStep,
onSubmitted,
setSubmitError,
setSubmitted,
setSubmitting,
@@ -221,14 +239,7 @@ export function PartnerApplicationWizard({
);
if (state.isSubmitted) {
return (
<PartnerApplicationSuccess
company={state.company}
email={state.email}
name={state.name}
onDismiss={onSuccess}
/>
);
return <PartnerApplicationSuccess onDismiss={onSuccess} />;
}
const stepLabel = `${i18n._(
@@ -0,0 +1,72 @@
'use client';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { ChipMultiSelect, Field, TextareaField, TextField } from '@/ui';
import { PARTNER_TWENTY_EXPERIENCE_OPTIONS } from '../../data/partner-twenty-experience-options';
import { PARTNER_APPLICATION_COPY } from '../../partner-application-copy';
import { type PartnerApplicationController } from '../../use-partner-application-state';
const FIELDS = PARTNER_APPLICATION_COPY.fields;
export function ExperienceStep({
controller,
}: {
controller: PartnerApplicationController;
}) {
const { i18n } = useLingui();
const { setField, state, toggleExperience } = controller;
const experienceOptions = PARTNER_TWENTY_EXPERIENCE_OPTIONS.map((option) => ({
label: i18n._(option.label),
value: option.value,
}));
return (
<>
<Field
hint={i18n._(FIELDS.twentyExperienceHint)}
label={i18n._(FIELDS.twentyExperience)}
>
<ChipMultiSelect
ariaLabel={i18n._(FIELDS.twentyExperience)}
invalid={state.fieldErrors.twentyExperience !== undefined}
onToggle={toggleExperience}
options={experienceOptions}
values={state.twentyExperience}
/>
</Field>
<Field
hint={i18n._(FIELDS.twentyExperienceNotesHint)}
label={i18n._(FIELDS.twentyExperienceNotes)}
>
<TextareaField
ariaLabel={i18n._(FIELDS.twentyExperienceNotes)}
invalid={state.fieldErrors.twentyExperienceNotes !== undefined}
name="twentyExperienceNotes"
onValueChange={(value) => setField('twentyExperienceNotes', value)}
placeholder={i18n._(FIELDS.twentyExperienceNotesPlaceholder)}
value={state.twentyExperienceNotes}
/>
</Field>
<Field
hint={i18n._(FIELDS.twentyExperienceProofLinkHint)}
label={i18n._(FIELDS.twentyExperienceProofLink)}
>
<TextField
ariaLabel={i18n._(FIELDS.twentyExperienceProofLink)}
inputMode="url"
invalid={state.fieldErrors.twentyExperienceProofLink !== undefined}
name="twentyExperienceProofLink"
onValueChange={(value) =>
setField('twentyExperienceProofLink', value)
}
placeholder={i18n._(msg`https://`)}
value={state.twentyExperienceProofLink}
/>
</Field>
</>
);
}
@@ -2,7 +2,7 @@
import { useLingui } from '@lingui/react';
import { CategoryCardSelect, Field, TagInput, TextareaField } from '@/ui';
import { CategoryCardSelect, Field, TagInput } from '@/ui';
import { PARTNER_SCOPE_OPTIONS } from '../../data/partner-scope-options';
import { PARTNER_SKILL_POOL } from '../../data/partner-skill-pool';
@@ -18,7 +18,7 @@ export function ExpertiseStep({
controller: PartnerApplicationController;
}) {
const { i18n } = useLingui();
const { setField, setSkills, state, toggleScope } = controller;
const { setSkills, state, toggleScope } = controller;
const scopeOptions = PARTNER_SCOPE_OPTIONS.map((option) => ({
description: i18n._(option.description),
@@ -54,15 +54,6 @@ export function ExpertiseStep({
values={state.skills}
/>
</Field>
<Field label={i18n._(FIELDS.applicationNotes)}>
<TextareaField
ariaLabel={i18n._(FIELDS.applicationNotes)}
name="applicationNotes"
onValueChange={(value) => setField('applicationNotes', value)}
placeholder={i18n._(FIELDS.applicationNotesPlaceholder)}
value={state.applicationNotes}
/>
</Field>
</>
);
}
@@ -43,6 +43,10 @@ const Pill = styled.button`
color: ${semanticColor.surface};
}
&[data-invalid] {
border-color: ${color('error')};
}
&:focus-visible {
outline: 2px solid ${color('blue')};
outline-offset: 2px;
@@ -51,22 +55,29 @@ const Pill = styled.button`
export function ChipMultiSelect<TValue extends string>({
ariaLabel,
invalid = false,
onToggle,
options,
values,
}: {
ariaLabel: string;
invalid?: boolean;
onToggle: (value: TValue) => void;
options: readonly ChipOption<TValue>[];
values: readonly TValue[];
}) {
return (
<PillGroup aria-label={ariaLabel} role="group">
<PillGroup
aria-invalid={invalid ? true : undefined}
aria-label={ariaLabel}
role="group"
>
{options.map((option) => {
const selected = values.includes(option.value);
return (
<Pill
aria-pressed={selected}
data-invalid={invalid && !selected ? '' : undefined}
data-selected={selected ? '' : undefined}
key={option.value}
onClick={() => onToggle(option.value)}