[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:
@@ -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(() =>
|
||||
|
||||
+4
-5
@@ -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', () => {
|
||||
|
||||
+8
-22
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+4
-14
@@ -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',
|
||||
];
|
||||
+20
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+4
-15
@@ -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',
|
||||
];
|
||||
|
||||
+13
@@ -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`,
|
||||
},
|
||||
|
||||
+27
@@ -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(
|
||||
{
|
||||
|
||||
+3
-74
@@ -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 };
|
||||
}
|
||||
|
||||
+24
-4
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+6
-9
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+96
@@ -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;
|
||||
}
|
||||
+11
-6
@@ -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', {
|
||||
|
||||
+2
@@ -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';
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user