[Website] Port partner application form rework (required fields, skills, fail-fast) (#21802)

Ports twenty-website PR #21710 (Rashad) into `twenty-website-redone`.
The old site's partner application form was reworked last week —
required fields, a skills rethink, and fail-fast validation — after the
redone had already ported the form, so the redone was running the
pre-rework behavior. This brings it to parity.
  
Re-derived into the redone's own conventions rather than copied: it
reuses the redone's `STEP_REQUIRED_FIELDS`/`STEP_FORMAT_CHECKS`
validator, keeps one-export-per-file, and injects the new `searchPool`
as an opt-in prop.
This commit is contained in:
Abdullah.
2026-06-18 23:43:24 +05:00
committed by GitHub
parent 9de1b6330c
commit b14da2f9e8
25 changed files with 358 additions and 184 deletions
+19 -5
View File
@@ -1,9 +1,6 @@
const jestConfig = {
displayName: 'twenty-website-redone',
const baseProject = {
preset: '../../jest.preset.js',
testEnvironment: 'node',
// twenty-ui and twenty-shared ship ESM (.mjs) in their dist; transform them
// (everything else in node_modules stays ignored) so jest can load them.
setupFilesAfterEnv: ['<rootDir>/test/setup-jest-dom.ts'],
transformIgnorePatterns: [
'/node_modules/(?!(twenty-ui|twenty-shared)/.*)',
'../../node_modules/(?!(twenty-ui|twenty-shared)/.*)',
@@ -24,6 +21,23 @@ const jestConfig = {
'^@lingui/core/macro$': '<rootDir>/test/lingui-macro-mock.ts',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'mjs'],
};
const jestConfig = {
projects: [
{
...baseProject,
displayName: 'node',
testEnvironment: 'node',
testMatch: ['<rootDir>/src/**/*.test.ts'],
},
{
...baseProject,
displayName: 'jsdom',
testEnvironment: 'jsdom',
testMatch: ['<rootDir>/src/**/*.test.tsx'],
},
],
coverageDirectory: './coverage',
};
@@ -39,10 +39,14 @@
"@lingui/format-po": "5.1.2",
"@lingui/swc-plugin": "^5.11.0",
"@opennextjs/cloudflare": "^1.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.184.1",
"jest-environment-jsdom": "30.0.0-beta.3",
"wrangler": "^4.0.0"
}
}
@@ -322,17 +322,21 @@ function walk(directory) {
}
// .tsx files are PascalCase (named after their React component); .ts
// files are kebab-case. Next.js route files (page/layout/...) and the
// compiled locale catalogs are exempt.
// files are kebab-case. Test files mirror their subject's name, so the
// .test infix is stripped before the casing rule (TagInput.test.tsx,
// partner-fields.test.ts) — matching the .test-only exemptions elsewhere.
// Next.js route files (page/layout/...) and the compiled locale catalogs
// are exempt.
if (
!NEXT_CONTRACT_FILES.has(entry.name) &&
!relativePath.startsWith('locales' + path.sep)
) {
if (entry.name.endsWith('.tsx')) {
if (!/^[A-Z][A-Za-z0-9]*\.tsx$/.test(entry.name)) {
const nameForCasing = entry.name.replace(/\.test(?=\.[tj]sx?$)/, '');
if (nameForCasing.endsWith('.tsx')) {
if (!/^[A-Z][A-Za-z0-9]*\.tsx$/.test(nameForCasing)) {
failures.push(`src/${relativePath}: .tsx filenames are PascalCase.`);
}
} else if (/[A-Z]/.test(entry.name)) {
} else if (/[A-Z]/.test(nameForCasing)) {
failures.push(`src/${relativePath}: .ts filenames are kebab-case.`);
}
}
@@ -7,6 +7,9 @@ const VALID_PAYLOAD = {
name: 'Ada Lovelace',
company: 'Analytical Engines',
website: 'https://analytical.example/',
city: 'London',
hourlyRate: 150,
projectBudgetMin: 5000,
};
const VALID_BODY = JSON.stringify(VALID_PAYLOAD);
@@ -33,15 +36,11 @@ function buildRequest({
});
}
// resetModules so each test gets a fresh module-level rate limiter; distinct
// IPs keep buckets from bleeding between cases.
async function loadRoute() {
jest.resetModules();
return import('@/app/api/partner-application/route');
}
// Sequential by recursion (not a for-await loop): the rate limiter is stateful,
// so the calls must run in order to deplete the bucket deterministically.
async function runSequentially(
makeCall: () => Promise<Response>,
count: number,
@@ -56,9 +55,6 @@ describe('POST /api/partner-application', () => {
beforeEach(() => {
process.env.PARTNER_APPLICATION_WEBHOOK_URL = 'https://hooks.example/test';
process.env.PARTNER_APPLICATION_SECRET = 'test-key-abc123';
// The route logs to console.error on its upstream/logic failure paths,
// which several tests below exercise on purpose — mute the noise so the
// suite output stays clean.
jest.spyOn(console, 'error').mockImplementation(() => {});
});
@@ -169,6 +165,9 @@ describe('POST /api/partner-application', () => {
lastName: 'Lovelace',
companyName: 'Analytical Engines',
domainName: 'https://analytical.example/',
city: 'London',
hourlyRate: 150,
projectBudgetMin: 5000,
});
expect(init.signal).toBeInstanceOf(AbortSignal);
});
@@ -265,8 +264,6 @@ describe('POST /api/partner-application', () => {
});
it('rate-limits the same IP after the burst capacity is spent', async () => {
// mockImplementation (not mockResolvedValue): each call needs a fresh
// Response since the route consumes its body, and this test reads five.
global.fetch = jest
.fn()
.mockImplementation(() =>
@@ -5,21 +5,21 @@ const minimalValid: PartnerApplicationRequest = {
name: 'Ada Lovelace',
email: 'ada@example.com',
company: 'Analytical Engines Ltd',
website: 'https://analyticalengines.example',
city: 'London',
hourlyRate: 150,
projectBudgetMin: 5000,
};
const fullValid: PartnerApplicationRequest = {
...minimalValid,
website: 'https://analyticalengines.example',
linkedin: 'https://www.linkedin.com/in/ada',
city: 'London',
country: 'UNITED_KINGDOM',
languages: ['ENGLISH', 'FRENCH'],
typeOfTeam: 'SOLO',
partnerScope: ['ADVISORY', 'SOLUTIONING'],
skills: ['React', 'TypeScript'],
applicationNotes: 'refs: Acme, Globex',
hourlyRate: 150,
projectBudgetMin: 5000,
calendarLink: 'https://cal.com/ada',
};
@@ -51,7 +51,6 @@ describe('buildLogicFunctionPayload', () => {
expect(payload).not.toHaveProperty('country');
expect(payload).not.toHaveProperty('languages');
expect(payload).not.toHaveProperty('partnerScope');
expect(payload).not.toHaveProperty('domainName');
});
it('omits empty arrays', () => {
@@ -9,6 +9,10 @@ const minimalState: PartnerApplicationState = {
name: 'Ada Lovelace',
email: 'ada@example.com',
company: 'Analytical Engines Ltd',
website: 'https://analyticalengines.example',
city: 'London',
hourlyRate: '150',
projectBudgetMin: '5000',
};
describe('buildPartnerApplicationRequestBody', () => {
@@ -17,6 +21,10 @@ describe('buildPartnerApplicationRequestBody', () => {
name: 'Ada Lovelace',
email: 'ada@example.com',
company: 'Analytical Engines Ltd',
website: 'https://analyticalengines.example',
city: 'London',
hourlyRate: 150,
projectBudgetMin: 5000,
});
});
@@ -35,15 +43,11 @@ describe('buildPartnerApplicationRequestBody', () => {
it('omits optional string fields that are blank or whitespace-only', () => {
const body = buildPartnerApplicationRequestBody({
...minimalState,
website: ' ',
linkedin: '',
city: ' ',
applicationNotes: '',
calendarLink: ' ',
});
expect(body).not.toHaveProperty('website');
expect(body).not.toHaveProperty('linkedin');
expect(body).not.toHaveProperty('city');
expect(body).not.toHaveProperty('applicationNotes');
expect(body).not.toHaveProperty('calendarLink');
});
@@ -96,22 +100,4 @@ describe('buildPartnerApplicationRequestBody', () => {
expect(body.hourlyRate).toBe(150);
expect(body.projectBudgetMin).toBe(5000);
});
it('omits numeric fields that are blank, unparseable, or negative', () => {
const blank = buildPartnerApplicationRequestBody({
...minimalState,
hourlyRate: '',
projectBudgetMin: 'abc',
});
expect(blank).not.toHaveProperty('hourlyRate');
expect(blank).not.toHaveProperty('projectBudgetMin');
const negative = buildPartnerApplicationRequestBody({
...minimalState,
hourlyRate: '-5',
projectBudgetMin: '-1',
});
expect(negative).not.toHaveProperty('hourlyRate');
expect(negative).not.toHaveProperty('projectBudgetMin');
});
});
@@ -1,9 +1,6 @@
import { type PartnerApplicationRequest } from './partner-application-request-schema';
import { type PartnerApplicationState } from './partner-application-state';
// Maps the form state to the POST body: trims strings, omits empty optionals,
// and parses the numeric commercials. The shape must satisfy the request schema
// the route validates against.
export function buildPartnerApplicationRequestBody(
state: PartnerApplicationState,
): PartnerApplicationRequest {
@@ -11,11 +8,13 @@ export function buildPartnerApplicationRequestBody(
name: state.name.trim(),
email: state.email.trim(),
company: state.company.trim(),
website: state.website.trim(),
city: state.city.trim(),
hourlyRate: Number.parseFloat(state.hourlyRate),
projectBudgetMin: Number.parseFloat(state.projectBudgetMin),
};
if (state.website.trim()) body.website = state.website.trim();
if (state.linkedin.trim()) body.linkedin = state.linkedin.trim();
if (state.city.trim()) body.city = state.city.trim();
if (state.country !== '') body.country = state.country;
if (state.languages.length > 0) body.languages = state.languages;
if (state.typeOfTeam !== '') body.typeOfTeam = state.typeOfTeam;
@@ -23,15 +22,6 @@ export function buildPartnerApplicationRequestBody(
if (state.skills.length > 0) body.skills = state.skills;
if (state.applicationNotes.trim())
body.applicationNotes = state.applicationNotes.trim();
const hourlyRate = Number.parseFloat(state.hourlyRate);
if (Number.isFinite(hourlyRate) && hourlyRate >= 0)
body.hourlyRate = hourlyRate;
const projectBudgetMin = Number.parseFloat(state.projectBudgetMin);
if (Number.isFinite(projectBudgetMin) && projectBudgetMin >= 0)
body.projectBudgetMin = projectBudgetMin;
if (state.calendarLink.trim()) body.calendarLink = state.calendarLink.trim();
return body;
@@ -0,0 +1,24 @@
export const PARTNER_SKILL_POOL: readonly string[] = [
'React',
'TypeScript',
'Node.js',
'Python',
'PostgreSQL',
'GraphQL',
'Docker',
'Kubernetes',
'AWS',
'GCP',
'Azure',
'DevOps',
'SAP',
'WhatsApp',
'AI automations',
'Data enrichment',
'Cyber security',
'Fintech',
'B2B SaaS',
'Non-profit / NGO',
'Tax & accounting',
'Healthcare',
];
@@ -0,0 +1,20 @@
import { PARTNER_SKILL_POOL } from './partner-skill-pool';
import { PARTNER_SKILL_SUGGESTIONS } from './partner-skill-suggestions';
const COMPETITOR_CRMS = ['Salesforce', 'HubSpot', 'Attio', 'Pipedrive', 'Zoho'];
describe('partner skill suggestions', () => {
it('never surfaces a competitor CRM', () => {
const all = [...PARTNER_SKILL_SUGGESTIONS, ...PARTNER_SKILL_POOL];
for (const crm of COMPETITOR_CRMS) {
expect(all).not.toContain(crm);
}
});
it('keeps the shown chips and the searchable pool disjoint', () => {
const shown = new Set(PARTNER_SKILL_SUGGESTIONS);
for (const skill of PARTNER_SKILL_POOL) {
expect(shown.has(skill)).toBe(false);
}
});
});
@@ -1,22 +1,11 @@
// Common partner skills surfaced as quick-add chips beneath the skills field.
// Technology and product names — proper nouns, so not localized.
export const PARTNER_SKILL_SUGGESTIONS: readonly string[] = [
'React',
'TypeScript',
'Node.js',
'Python',
'PostgreSQL',
'GraphQL',
'CRM migration',
'n8n',
'Zapier',
'Make',
'Salesforce',
'HubSpot',
'SAP',
'AI agents',
'Shopify',
'Stripe',
'Docker',
'Kubernetes',
'AWS',
'GCP',
'Real estate',
'E-commerce',
];
@@ -0,0 +1,13 @@
import { z } from 'zod';
export const nonNegativeAmountFieldSchema = z
.string()
.trim()
.min(1)
.refine(
(value) => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0;
},
{ error: 'Enter a valid non-negative amount.' },
);
@@ -1,8 +1,5 @@
import { msg } from '@lingui/core/macro';
// All of the wizard's copy in one place: modal chrome, the success screen, the
// per-field labels/placeholders/hints, validation messages, and the step header
// labels. The wizard and steps read the slices they need.
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.`,
@@ -19,6 +16,7 @@ export const PARTNER_APPLICATION_COPY = {
incompleteForm: msg`Please complete all required fields before continuing.`,
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.`,
submitFailed: msg`We could not submit your application. Please try again in a moment.`,
},
removeSkill: (skill: string) => msg`Remove ${skill}`,
@@ -26,9 +24,9 @@ export const PARTNER_APPLICATION_COPY = {
name: msg`Your name *`,
email: msg`Work email *`,
company: msg`Company or brand *`,
website: msg`Website or GitHub`,
website: msg`Website or GitHub *`,
linkedin: msg`LinkedIn URL`,
city: msg`City`,
city: msg`City *`,
country: msg`Country *`,
countryPlaceholder: msg`Select your country`,
countrySearchPlaceholder: msg`Search a country…`,
@@ -39,13 +37,13 @@ export const PARTNER_APPLICATION_COPY = {
partnerScope: msg`What you cover *`,
partnerScopeHint: msg`Pick every category that applies.`,
skills: msg`Technical skills`,
skillsHint: msg`Press Enter or comma to add a skill.`,
skillsPlaceholder: msg`e.g. React, Postgres, n8n`,
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…`,
hourlyRate: msg`Hourly rate`,
hourlyRate: msg`Hourly rate *`,
hourlyRatePlaceholder: msg`150`,
projectBudgetMin: msg`Minimum project budget`,
projectBudgetMin: msg`Minimum project budget *`,
projectBudgetMinPlaceholder: msg`5,000`,
calendarLink: msg`Calendar / booking link`,
},
@@ -8,6 +8,7 @@ const baseValidIdentity: Partial<PartnerApplicationState> = {
name: 'Ada Lovelace',
email: 'ada@example.com',
company: 'Analytical Engines Ltd',
website: 'https://analyticalengines.example',
};
describe('partnerApplicationReducer', () => {
@@ -53,6 +54,7 @@ describe('partnerApplicationReducer', () => {
'company',
'email',
'name',
'website',
]);
});
@@ -99,6 +101,7 @@ describe('partnerApplicationReducer', () => {
...INITIAL_PARTNER_APPLICATION_STATE,
stepIndex: 1,
country: 'FRANCE',
city: 'Paris',
};
const blocked = partnerApplicationReducer(onProfile, { type: 'GO_NEXT' });
expect(blocked.stepIndex).toBe(1);
@@ -112,6 +115,30 @@ describe('partnerApplicationReducer', () => {
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,
};
const blocked = partnerApplicationReducer(onCommercials, {
type: 'GO_NEXT',
});
expect(blocked.fieldErrors.hourlyRate).toBe('required');
expect(blocked.fieldErrors.projectBudgetMin).toBe('required');
const badAmount = partnerApplicationReducer(
{ ...onCommercials, hourlyRate: '.', projectBudgetMin: '5000' },
{ type: 'GO_NEXT' },
);
expect(badAmount.fieldErrors.hourlyRate).toBe('invalid_amount');
const ok = partnerApplicationReducer(
{ ...onCommercials, hourlyRate: '150', projectBudgetMin: '5000' },
{ type: 'GO_NEXT' },
);
expect(ok.fieldErrors).toEqual({});
});
it('SET_SUBMITTED flips isSubmitted and clears submitError + isSubmitting', () => {
const next = partnerApplicationReducer(
{
@@ -1,81 +1,10 @@
import {
PARTNER_APPLICATION_STEP_IDS,
type PartnerApplicationStepId,
} from './data/partner-application-step-ids';
import { emailFieldSchema } from './email-field-schema';
import { httpUrlFieldSchema } from './http-url-field-schema';
import { PARTNER_APPLICATION_STEP_IDS } from './data/partner-application-step-ids';
import {
INITIAL_PARTNER_APPLICATION_STATE,
type PartnerApplicationAction,
type PartnerApplicationState,
} from './partner-application-state';
// Per-step required fields; GO_NEXT is gated on these being filled in.
const STEP_REQUIRED_FIELDS: Record<
PartnerApplicationStepId,
readonly (keyof PartnerApplicationState)[]
> = {
identity: ['name', 'email', 'company'],
profile: ['country', 'typeOfTeam'],
expertise: ['partnerScope'],
commercials: [],
};
function isEmpty(value: unknown): boolean {
if (value === '' || value === null || value === undefined) return true;
if (Array.isArray(value)) return value.length === 0;
return false;
}
// Per-step format checks reuse the shared field schemas so the client rejects
// exactly what the server route schema rejects. The required gate above owns
// "is it filled in"; these only run on non-empty values.
type FieldFormatCheck = {
field: 'email' | 'website' | 'linkedin' | 'calendarLink';
schema: typeof emailFieldSchema | typeof httpUrlFieldSchema;
errorCode: 'invalid_email' | 'invalid_url';
};
const STEP_FORMAT_CHECKS: Partial<
Record<PartnerApplicationStepId, readonly FieldFormatCheck[]>
> = {
identity: [
{ field: 'email', schema: emailFieldSchema, errorCode: 'invalid_email' },
{ field: 'website', schema: httpUrlFieldSchema, errorCode: 'invalid_url' },
],
profile: [
{ field: 'linkedin', schema: httpUrlFieldSchema, errorCode: 'invalid_url' },
],
commercials: [
{
field: 'calendarLink',
schema: httpUrlFieldSchema,
errorCode: 'invalid_url',
},
],
};
function validateStep(
state: PartnerApplicationState,
): Partial<Record<string, string>> {
const stepId = PARTNER_APPLICATION_STEP_IDS[state.stepIndex];
const errors: Partial<Record<string, string>> = {};
for (const field of STEP_REQUIRED_FIELDS[stepId]) {
if (isEmpty(state[field])) {
errors[field] = 'required';
}
}
for (const check of STEP_FORMAT_CHECKS[stepId] ?? []) {
const value = state[check.field];
if (value && !check.schema.safeParse(value).success) {
errors[check.field] = check.errorCode;
}
}
return errors;
}
import { validatePartnerApplicationStep } from './validate-partner-application-step';
function dropError(
errors: Partial<Record<string, string>>,
@@ -116,7 +45,7 @@ export function partnerApplicationReducer(
case 'SET_SKILLS':
return { ...state, skills: action.value };
case 'GO_NEXT': {
const errors = validateStep(state);
const errors = validatePartnerApplicationStep(state);
if (Object.keys(errors).length > 0) {
return { ...state, fieldErrors: errors };
}
@@ -4,21 +4,21 @@ const minimalValid = {
name: 'Ada Lovelace',
email: 'ada@example.com',
company: 'Analytical Engines Ltd',
website: 'https://analyticalengines.example',
city: 'London',
hourlyRate: 150,
projectBudgetMin: 5000,
};
const fullValid = {
...minimalValid,
website: 'https://analyticalengines.example',
linkedin: 'https://www.linkedin.com/in/ada',
city: 'London',
country: 'UNITED_KINGDOM',
languages: ['ENGLISH', 'FRENCH'],
typeOfTeam: 'SOLO',
partnerScope: ['ADVISORY', 'SOLUTIONING'],
skills: ['React', 'TypeScript'],
applicationNotes: 'Workspace https://app.twenty.com/ws/ada · refs: Acme',
hourlyRate: 150,
projectBudgetMin: 5000,
calendarLink: 'https://cal.com/ada',
};
@@ -77,4 +77,24 @@ describe('partnerApplicationRequestSchema', () => {
.success,
).toBe(false);
});
it('rejects when a newly-required field is missing', () => {
for (const field of ['website', 'city', 'hourlyRate', 'projectBudgetMin']) {
const withoutField = Object.fromEntries(
Object.entries(minimalValid).filter(([key]) => key !== field),
);
expect(
partnerApplicationRequestSchema.safeParse(withoutField).success,
).toBe(false);
}
});
it('rejects a negative hourly rate', () => {
expect(
partnerApplicationRequestSchema.safeParse({
...minimalValid,
hourlyRate: -5,
}).success,
).toBe(false);
});
});
@@ -14,27 +14,24 @@ const teamTypeValues = PARTNER_TEAM_TYPE_OPTIONS.map((option) => option.value);
const optionalNonEmptyString = z.string().trim().min(1).optional();
const optionalUrl = httpUrlFieldSchema.optional();
const optionalNonNegativeNumber = z.number().nonnegative().optional();
// The single source of truth for partner-application validation. The server
// route parses against it; the client reducer reuses the field schemas above
// so both accept and reject exactly the same input. strictObject so unknown
// keys are rejected rather than silently forwarded to the webhook.
export const partnerApplicationRequestSchema = z.strictObject({
name: z.string().trim().min(1, { error: 'Name is required.' }),
email: emailFieldSchema,
company: z.string().trim().min(1, { error: 'Company is required.' }),
website: optionalUrl,
website: httpUrlFieldSchema,
linkedin: optionalUrl,
city: optionalNonEmptyString,
city: z.string().trim().min(1, { error: 'City is required.' }),
country: z.enum(countryValues).optional(),
languages: z.array(z.enum(languageValues)).optional(),
typeOfTeam: z.enum(teamTypeValues).optional(),
partnerScope: z.array(z.enum(scopeValues)).optional(),
skills: z.array(z.string().trim().min(1)).optional(),
applicationNotes: optionalNonEmptyString,
hourlyRate: optionalNonNegativeNumber,
projectBudgetMin: optionalNonNegativeNumber,
hourlyRate: z.number({ error: 'Hourly rate is required.' }).nonnegative(),
projectBudgetMin: z
.number({ error: 'Minimum project budget is required.' })
.nonnegative(),
calendarLink: optionalUrl,
});
@@ -0,0 +1,96 @@
import {
PARTNER_APPLICATION_STEP_IDS,
type PartnerApplicationStepId,
} from './data/partner-application-step-ids';
import { emailFieldSchema } from './email-field-schema';
import { httpUrlFieldSchema } from './http-url-field-schema';
import { nonNegativeAmountFieldSchema } from './non-negative-amount-field-schema';
import { type PartnerApplicationState } from './partner-application-state';
const STEP_REQUIRED_FIELDS: Record<
PartnerApplicationStepId,
readonly (keyof PartnerApplicationState)[]
> = {
identity: ['name', 'email', 'company', 'website'],
profile: ['country', 'typeOfTeam', 'city'],
expertise: ['partnerScope'],
commercials: ['hourlyRate', 'projectBudgetMin'],
};
function isEmpty(value: unknown): boolean {
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) return value.length === 0;
return value === null || value === undefined;
}
type FieldFormatCheck = {
field:
| 'email'
| 'website'
| 'linkedin'
| 'calendarLink'
| 'hourlyRate'
| 'projectBudgetMin';
schema:
| typeof emailFieldSchema
| typeof httpUrlFieldSchema
| typeof nonNegativeAmountFieldSchema;
errorCode: 'invalid_email' | 'invalid_url' | 'invalid_amount';
};
const STEP_FORMAT_CHECKS: Partial<
Record<PartnerApplicationStepId, readonly FieldFormatCheck[]>
> = {
identity: [
{ field: 'email', schema: emailFieldSchema, errorCode: 'invalid_email' },
{ field: 'website', schema: httpUrlFieldSchema, errorCode: 'invalid_url' },
],
profile: [
{ field: 'linkedin', schema: httpUrlFieldSchema, errorCode: 'invalid_url' },
],
commercials: [
{
field: 'hourlyRate',
schema: nonNegativeAmountFieldSchema,
errorCode: 'invalid_amount',
},
{
field: 'projectBudgetMin',
schema: nonNegativeAmountFieldSchema,
errorCode: 'invalid_amount',
},
{
field: 'calendarLink',
schema: httpUrlFieldSchema,
errorCode: 'invalid_url',
},
],
};
export function validatePartnerApplicationStep(
state: PartnerApplicationState,
): Partial<Record<string, string>> {
if (
state.stepIndex < 0 ||
state.stepIndex >= PARTNER_APPLICATION_STEP_IDS.length
) {
return { step: 'invalid_step' };
}
const stepId = PARTNER_APPLICATION_STEP_IDS[state.stepIndex];
const errors: Partial<Record<string, string>> = {};
for (const field of STEP_REQUIRED_FIELDS[stepId]) {
if (isEmpty(state[field])) {
errors[field] = 'required';
}
}
for (const check of STEP_FORMAT_CHECKS[stepId] ?? []) {
const value = state[check.field];
if (value && !check.schema.safeParse(value).success) {
errors[check.field] = check.errorCode;
}
}
return errors;
}
@@ -22,6 +22,7 @@ import {
type PartnerApplicationController,
usePartnerApplicationState,
} from '../use-partner-application-state';
import { validatePartnerApplicationStep } from '../validate-partner-application-step';
import { PartnerApplicationSuccess } from './PartnerApplicationSuccess';
import { CommercialsStep } from './steps/CommercialsStep';
import { ExpertiseStep } from './steps/ExpertiseStep';
@@ -49,8 +50,6 @@ const TitleBlock = styled.div`
}
`;
// The title + subtitle are one tight group; the step header sits further below
// (matching the old site's larger intro→header separation).
const IntroGroup = styled.div`
display: flex;
flex-direction: column;
@@ -92,8 +91,6 @@ const Footer = styled.div`
}
`;
// The Back control: a plain bordered mono rectangle (the old secondary button),
// distinct from the primary's angled ButtonShape.
const SecondaryButton = styled.button`
background: none;
border: 1px solid ${semanticColor.lineStrong};
@@ -171,7 +168,9 @@ export function PartnerApplicationWizard({
? COPY.validation.invalidEmail
: errorValues.includes('invalid_url')
? COPY.validation.invalidUrl
: COPY.validation.incompleteForm;
: errorValues.includes('invalid_amount')
? COPY.validation.invalidAmount
: COPY.validation.incompleteForm;
const handleSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
@@ -182,8 +181,14 @@ export function PartnerApplicationWizard({
}
if (state.isSubmitting) return;
const payload = buildPartnerApplicationRequestBody(state);
setSubmitError(null);
if (Object.keys(validatePartnerApplicationStep(state)).length > 0) {
goNext();
return;
}
const payload = buildPartnerApplicationRequestBody(state);
setSubmitting(true);
try {
const response = await fetch('/api/partner-application', {
@@ -22,6 +22,7 @@ export function CommercialsStep({
<Field label={i18n._(FIELDS.hourlyRate)}>
<NumberField
ariaLabel={i18n._(FIELDS.hourlyRate)}
invalid={state.fieldErrors.hourlyRate !== undefined}
name="hourlyRate"
onValueChange={(value) => setField('hourlyRate', value)}
placeholder={i18n._(FIELDS.hourlyRatePlaceholder)}
@@ -32,6 +33,7 @@ export function CommercialsStep({
<Field label={i18n._(FIELDS.projectBudgetMin)}>
<NumberField
ariaLabel={i18n._(FIELDS.projectBudgetMin)}
invalid={state.fieldErrors.projectBudgetMin !== undefined}
name="projectBudgetMin"
onValueChange={(value) => setField('projectBudgetMin', value)}
placeholder={i18n._(FIELDS.projectBudgetMinPlaceholder)}
@@ -5,6 +5,7 @@ import { useLingui } from '@lingui/react';
import { CategoryCardSelect, Field, TagInput, TextareaField } from '@/ui';
import { PARTNER_SCOPE_OPTIONS } from '../../data/partner-scope-options';
import { PARTNER_SKILL_POOL } from '../../data/partner-skill-pool';
import { PARTNER_SKILL_SUGGESTIONS } from '../../data/partner-skill-suggestions';
import { PARTNER_APPLICATION_COPY } from '../../partner-application-copy';
import { type PartnerApplicationController } from '../../use-partner-application-state';
@@ -48,6 +49,7 @@ export function ExpertiseStep({
removeLabel={(tag) =>
i18n._(PARTNER_APPLICATION_COPY.removeSkill(tag))
}
searchPool={PARTNER_SKILL_POOL}
suggestions={PARTNER_SKILL_SUGGESTIONS}
values={state.skills}
/>
@@ -59,6 +59,7 @@ export function ProfileStep({
<Field>
<TextField
ariaLabel={i18n._(FIELDS.city)}
invalid={state.fieldErrors.city !== undefined}
name="city"
onValueChange={(value) => setField('city', value)}
placeholder={i18n._(FIELDS.city)}
@@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { TagInput } from './TagInput';
function TagInputHarness() {
const [values, setValues] = useState<string[]>([]);
return (
<TagInput
ariaLabel="Skills"
onValuesChange={setValues}
placeholder="Add a skill…"
removeLabel={(tag) => `Remove ${tag}`}
searchPool={['Kubernetes']}
suggestions={['Workflows']}
values={values}
/>
);
}
describe('TagInput', () => {
it('offers the suggestions as quick-add chips but never the search pool', () => {
render(<TagInputHarness />);
expect(
screen.getByRole('button', { name: '+ Workflows' }),
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: '+ Kubernetes' }),
).not.toBeInTheDocument();
});
it('autocompletes the menu against both the suggestions and the search pool', async () => {
const user = userEvent.setup();
render(<TagInputHarness />);
await user.type(screen.getByRole('combobox'), 'kuber');
expect(
screen.getByRole('option', { name: 'Kubernetes' }),
).toBeInTheDocument();
});
it('adds a search-pool entry from the menu as a removable chip', async () => {
const user = userEvent.setup();
render(<TagInputHarness />);
await user.type(screen.getByRole('combobox'), 'kuber');
await user.click(screen.getByRole('option', { name: 'Kubernetes' }));
expect(
screen.getByRole('button', { name: 'Remove Kubernetes' }),
).toBeInTheDocument();
expect(screen.queryByRole('option')).not.toBeInTheDocument();
});
});
@@ -138,8 +138,6 @@ const Ghost = styled.button`
}
`;
// Suggestions matching the typed draft (case-insensitive substring), excluding
// the ones already added. Empty draft → no menu.
function matchingSuggestions(
pool: readonly string[],
selected: readonly string[],
@@ -154,15 +152,12 @@ function matchingSuggestions(
);
}
// A free-text tag entry with suggestions: type to filter the dropdown menu,
// Enter/comma adds the draft (or the highlighted match), Backspace on an empty
// draft removes the last tag, and the remaining suggestions show as quick-add
// ghost chips. Stays i18n-free — the consumer supplies a localized removeLabel.
export function TagInput({
ariaLabel,
onValuesChange,
placeholder,
removeLabel,
searchPool,
suggestions,
values,
}: {
@@ -170,6 +165,7 @@ export function TagInput({
onValuesChange: (values: string[]) => void;
placeholder?: string;
removeLabel: (tag: string) => string;
searchPool?: readonly string[];
suggestions: readonly string[];
values: readonly string[];
}) {
@@ -177,7 +173,11 @@ export function TagInput({
const [activeIndex, setActiveIndex] = useState(-1);
const listId = useId();
const menuMatches = matchingSuggestions(suggestions, values, draft);
const menuMatches = matchingSuggestions(
searchPool === undefined ? suggestions : [...suggestions, ...searchPool],
values,
draft,
);
const menuOpen = menuMatches.length > 0;
const commit = (raw: string) => {
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+4
View File
@@ -55057,12 +55057,16 @@ __metadata:
"@lottiefiles/dotlottie-react": "npm:^0.18.10"
"@opennextjs/cloudflare": "npm:^1.0.0"
"@tabler/icons-react": "npm:^3.41.1"
"@testing-library/jest-dom": "npm:^6.6.3"
"@testing-library/react": "npm:^16.3.0"
"@testing-library/user-event": "npm:^14.6.1"
"@types/node": "npm:^20"
"@types/react": "npm:^19"
"@types/react-dom": "npm:^19"
"@types/three": "npm:^0.184.1"
"@wyw-in-js/babel-preset": "npm:^0.8.1"
"@wyw-in-js/transform": "npm:^0.8.1"
jest-environment-jsdom: "npm:30.0.0-beta.3"
next: "npm:^16.2.6"
next-with-linaria: "npm:^1.3.0"
react: "npm:19.2.3"