Skip to content

Commit 7afc991

Browse files
authored
Partner application form: required fields, skills rework, fail-fast validation (#21710)
## Summary (twenty-website — partner application form) - **Required fields**: website URL, city, hourly rate, minimum project are now required (client step-gate + server zod) with `*` markers. The final step validates before POSTing, so empty required fields fail fast client-side instead of round-tripping. - **Technical skills reworked to *complement* "What you cover"** (the service categories) rather than duplicate them — now a small shown set + a larger searchable-only pool of tools / technologies / industries. Field hint clarifies the intent. - **No competitor CRMs** in suggestions (Salesforce/HubSpot/Attio removed); a guard test fails if one ever reappears. Migrations surface as a generic "CRM migration". - `Form.TagInput` gains an optional `searchPool` prop (autocomplete-only entries, not rendered as chips). Companion to the app-side PR #21709. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21710?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. -->
1 parent 0f4cb2c commit 7afc991

17 files changed

Lines changed: 331 additions & 72 deletions

packages/twenty-website/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@
4848
"@opennextjs/cloudflare": "^1.0.0",
4949
"@swc/core": "^1.15.11",
5050
"@swc/jest": "^0.2.39",
51+
"@testing-library/jest-dom": "^6.6.3",
52+
"@testing-library/react": "^16.3.0",
53+
"@testing-library/user-event": "^14.6.1",
5154
"@types/jest": "^30.0.0",
5255
"@types/node": "^20",
5356
"@types/react": "^19.2.0",
@@ -56,6 +59,7 @@
5659
"@types/three": "^0.183.1",
5760
"babel-plugin-react-compiler": "1.0.0",
5861
"jest": "29.7.0",
62+
"jest-environment-jsdom": "30.0.0-beta.3",
5963
"jest-environment-node": "^29.4.1",
6064
"ts-jest": "^29.1.1",
6165
"wrangler": "^4.0.0"

packages/twenty-website/src/app/api/partner-application/__tests__/partner-application-schema.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ const minimalValid = {
77
name: 'Ada Lovelace',
88
email: 'ada@example.com',
99
company: 'Analytical Engines Ltd',
10+
website: 'https://analyticalengines.example',
11+
city: 'London',
12+
hourlyRate: 150,
13+
projectBudgetMin: 5000,
1014
};
1115

1216
const fullValid = {
@@ -108,7 +112,6 @@ describe('buildLogicFunctionPayload', () => {
108112
expect('country' in payload).toBe(false);
109113
expect('languages' in payload).toBe(false);
110114
expect('partnerScope' in payload).toBe(false);
111-
expect('domainName' in payload).toBe(false);
112115
});
113116

114117
it('forwards website as domainName when provided', () => {

packages/twenty-website/src/app/api/partner-application/__tests__/route.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ const VALID_PAYLOAD = {
77
name: 'Ada Lovelace',
88
company: 'Analytical Engines',
99
website: 'https://analytical.example/',
10+
city: 'London',
11+
hourlyRate: 150,
12+
projectBudgetMin: 5000,
1013
};
1114

1215
const VALID_BODY = JSON.stringify(VALID_PAYLOAD);
@@ -168,6 +171,9 @@ describe('POST /api/partner-application', () => {
168171
lastName: 'Lovelace',
169172
companyName: 'Analytical Engines',
170173
domainName: 'https://analytical.example/',
174+
city: 'London',
175+
hourlyRate: 150,
176+
projectBudgetMin: 5000,
171177
});
172178
expect(init.signal).toBeInstanceOf(AbortSignal);
173179
});

packages/twenty-website/src/design-system/components/Form/TagInput.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ type FormTagInputProps = {
136136
placeholder?: string;
137137
ariaLabel?: string;
138138
suggestions?: ReadonlyArray<string>;
139+
searchPool?: ReadonlyArray<string>;
139140
};
140141

141142
export function FormTagInput({
@@ -144,14 +145,20 @@ export function FormTagInput({
144145
placeholder,
145146
ariaLabel,
146147
suggestions,
148+
searchPool,
147149
}: FormTagInputProps) {
148150
const [draft, setDraft] = useState('');
149151
const [activeIndex, setActiveIndex] = useState(-1);
150152
const listId = useId();
151153

152-
const menuMatches =
154+
const searchableSuggestions =
153155
suggestions !== undefined
154-
? filterSkillSuggestions(suggestions, values, draft)
156+
? [...suggestions, ...(searchPool ?? [])]
157+
: undefined;
158+
159+
const menuMatches =
160+
searchableSuggestions !== undefined
161+
? filterSkillSuggestions(searchableSuggestions, values, draft)
155162
: [];
156163
const menuOpen = menuMatches.length > 0;
157164

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
import '@testing-library/jest-dom';
5+
import { render, screen } from '@testing-library/react';
6+
import userEvent from '@testing-library/user-event';
7+
import { useState } from 'react';
8+
9+
import { FormTagInput } from '@/design-system/components/Form/TagInput';
10+
11+
function Harness() {
12+
const [values, setValues] = useState<string[]>([]);
13+
return (
14+
<FormTagInput
15+
values={values}
16+
onValuesChange={setValues}
17+
ariaLabel="skills"
18+
suggestions={['Workflows']}
19+
searchPool={['Kubernetes']}
20+
/>
21+
);
22+
}
23+
24+
describe('FormTagInput searchPool', () => {
25+
it('shows only `suggestions` as ghost chips, not the pool', () => {
26+
render(<Harness />);
27+
expect(
28+
screen.getByRole('button', { name: '+ Workflows' }),
29+
).toBeInTheDocument();
30+
expect(screen.queryByRole('button', { name: '+ Kubernetes' })).toBeNull();
31+
});
32+
33+
it('autocompletes against the pool', async () => {
34+
render(<Harness />);
35+
await userEvent.type(screen.getByRole('combobox'), 'kuber');
36+
expect(
37+
screen.getByRole('option', { name: 'Kubernetes' }),
38+
).toBeInTheDocument();
39+
});
40+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import {
2+
nonNegativeAmountStringSchema,
3+
partnerApplicationRequestSchema,
4+
} from '@/sections/PartnerApplication/partner-application-field-schemas';
5+
6+
const validBody: Record<string, unknown> = {
7+
name: 'Acme',
8+
email: 'a@acme.com',
9+
company: 'Acme Inc',
10+
website: 'https://acme.com',
11+
city: 'Paris',
12+
country: 'FRANCE',
13+
partnerScope: ['DEVELOPMENT'],
14+
hourlyRate: 120,
15+
projectBudgetMin: 5000,
16+
};
17+
18+
describe('partnerApplicationRequestSchema required fields', () => {
19+
it('accepts a complete body', () => {
20+
expect(partnerApplicationRequestSchema.safeParse(validBody).success).toBe(
21+
true,
22+
);
23+
});
24+
25+
it.each(['website', 'city', 'hourlyRate', 'projectBudgetMin'])(
26+
'rejects a body missing %s',
27+
(field) => {
28+
const { [field]: _omitted, ...rest } = validBody;
29+
expect(partnerApplicationRequestSchema.safeParse(rest).success).toBe(
30+
false,
31+
);
32+
},
33+
);
34+
});
35+
36+
describe('nonNegativeAmountStringSchema', () => {
37+
it.each(['0', '120', '12.5', '.5', '12.', ' 90 '])(
38+
'accepts the valid amount %p',
39+
(value) => {
40+
expect(nonNegativeAmountStringSchema.safeParse(value).success).toBe(true);
41+
},
42+
);
43+
44+
it.each(['', '.', 'abc', '12abc', '-5', 'NaN', 'Infinity'])(
45+
'rejects the invalid amount %p',
46+
(value) => {
47+
expect(nonNegativeAmountStringSchema.safeParse(value).success).toBe(
48+
false,
49+
);
50+
},
51+
);
52+
});

packages/twenty-website/src/sections/PartnerApplication/partner-application-field-schemas.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,36 @@ export const httpUrlFieldSchema = z
2525
.min(1)
2626
.pipe(z.httpUrl({ error: 'Invalid URL.' }));
2727

28+
// Currency fields live in wizard state as strings, and the Form.Currency input
29+
// allows digits plus a single decimal separator — so a lone "." (or "") can
30+
// reach the reducer. Reject any non-empty value that doesn't represent a
31+
// finite, non-negative number, so the wizard fails fast on the commercials step
32+
// instead of submitting parseFloat(".") === NaN to the server.
33+
export const nonNegativeAmountStringSchema = z
34+
.string()
35+
.trim()
36+
.min(1)
37+
.refine(
38+
(value) => {
39+
const parsed = Number(value);
40+
return Number.isFinite(parsed) && parsed >= 0;
41+
},
42+
{ error: 'Enter a valid non-negative amount.' },
43+
);
44+
2845
const optionalNonEmptyString = z.string().trim().min(1).optional();
2946
const optionalUrl = httpUrlFieldSchema.optional();
30-
const optionalNonNegativeNumber = z.number().nonnegative().optional();
3147

3248
export const partnerApplicationRequestSchema = z.strictObject({
3349
// Identity
3450
name: z.string().trim().min(1, { error: 'Name is required.' }),
3551
email: emailFieldSchema,
3652
company: z.string().trim().min(1, { error: 'Company is required.' }),
37-
website: optionalUrl,
53+
website: httpUrlFieldSchema,
3854

3955
// Profile
4056
linkedin: optionalUrl,
41-
city: optionalNonEmptyString,
57+
city: z.string().trim().min(1, { error: 'City is required.' }),
4258
country: z.enum(PARTNER_COUNTRY_VALUES).optional(),
4359
languages: z.array(z.enum(PARTNER_LANGUAGE_VALUES)).optional(),
4460

@@ -49,8 +65,10 @@ export const partnerApplicationRequestSchema = z.strictObject({
4965
applicationNotes: optionalNonEmptyString,
5066

5167
// Commercials
52-
hourlyRate: optionalNonNegativeNumber,
53-
projectBudgetMin: optionalNonNegativeNumber,
68+
hourlyRate: z.number({ error: 'Hourly rate is required.' }).nonnegative(),
69+
projectBudgetMin: z
70+
.number({ error: 'Minimum project budget is required.' })
71+
.nonnegative(),
5472
calendarLink: optionalUrl,
5573
});
5674

packages/twenty-website/src/sections/PartnerApplication/partner-application-modal-data.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const PARTNER_APPLICATION_MODAL_COPY = {
2020
incompleteForm: msg`Please complete all required fields before continuing.`,
2121
invalidEmail: msg`Enter a valid email address.`,
2222
invalidUrl: msg`Enter a valid URL (starting with http:// or https://).`,
23+
invalidAmount: msg`Enter a valid amount using numbers only.`,
2324
submitFailed: msg`We could not submit your application. Please try again in a moment.`,
2425
},
2526
} as const;
@@ -29,10 +30,10 @@ export const PARTNER_APPLICATION_FIELD_COPY = {
2930
name: msg`Your name *`,
3031
email: msg`Work email *`,
3132
company: msg`Company or brand *`,
32-
website: msg`Website or GitHub`,
33+
website: msg`Website or GitHub *`,
3334
// Profile
3435
linkedin: msg`LinkedIn URL`,
35-
city: msg`City`,
36+
city: msg`City *`,
3637
country: msg`Country *`,
3738
countryPlaceholder: msg`Select your country`,
3839
countrySearchPlaceholder: msg`Search a country…`,
@@ -44,14 +45,14 @@ export const PARTNER_APPLICATION_FIELD_COPY = {
4445
partnerScope: msg`What you cover *`,
4546
partnerScopeHint: msg`Pick every category that applies.`,
4647
skills: msg`Technical skills`,
47-
skillsHint: msg`Press Enter or comma to add a skill.`,
48-
skillsPlaceholder: msg`e.g. React, Postgres, n8n…`,
48+
skillsHint: msg`Tools, technologies and industries you specialize in. Press Enter or comma to add.`,
49+
skillsPlaceholder: msg`e.g. n8n, Shopify, Real estate…`,
4950
applicationNotes: msg`Anything else we should know?`,
5051
applicationNotesPlaceholder: msg`Workspace URL, customer references, relevant links…`,
5152
// Commercials
52-
hourlyRate: msg`Hourly rate`,
53+
hourlyRate: msg`Hourly rate *`,
5354
hourlyRatePlaceholder: msg`150`,
54-
projectBudgetMin: msg`Minimum project budget`,
55+
projectBudgetMin: msg`Minimum project budget *`,
5556
projectBudgetMinPlaceholder: msg`5,000`,
5657
calendarLink: msg`Calendar / booking link`,
5758
} as const;

packages/twenty-website/src/sections/PartnerApplication/wizard/PartnerApplicationWizard.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ export function PartnerApplicationWizard({
177177
setSubmitError,
178178
setSubmitted,
179179
reset,
180+
validateCurrentStep,
180181
} = controller;
181182

182183
useEffect(() => {
@@ -192,7 +193,9 @@ export function PartnerApplicationWizard({
192193
? PARTNER_APPLICATION_MODAL_COPY.validation.invalidEmail
193194
: errorValues.includes('invalid_url')
194195
? PARTNER_APPLICATION_MODAL_COPY.validation.invalidUrl
195-
: PARTNER_APPLICATION_MODAL_COPY.validation.incompleteForm;
196+
: errorValues.includes('invalid_amount')
197+
? PARTNER_APPLICATION_MODAL_COPY.validation.invalidAmount
198+
: PARTNER_APPLICATION_MODAL_COPY.validation.incompleteForm;
196199

197200
const stepLabelNode = (
198201
<>
@@ -214,6 +217,9 @@ export function PartnerApplicationWizard({
214217
return;
215218
}
216219
if (state.isSubmitting) return;
220+
// Fail fast: the final step bypasses GO_NEXT's gate, so validate its
221+
// required fields here before hitting the server.
222+
if (!validateCurrentStep()) return;
217223

218224
const payload = buildPartnerApplicationRequestBody(state);
219225

@@ -244,6 +250,7 @@ export function PartnerApplicationWizard({
244250
[
245251
isLastStep,
246252
goNext,
253+
validateCurrentStep,
247254
state,
248255
setSubmitError,
249256
setSubmitting,

0 commit comments

Comments
 (0)