From b14da2f9e8da3ab52923f2d88252a3ad39cb90b4 Mon Sep 17 00:00:00 2001 From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:43:24 +0500 Subject: [PATCH] [Website] Port partner application form rework (required fields, skills, fail-fast) (#21802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../twenty-website-redone/jest.config.mjs | 24 ++++- packages/twenty-website-redone/package.json | 4 + .../scripts/check-conventions.mjs | 14 ++- .../app/api/partner-application/route.test.ts | 15 ++- .../build-logic-function-payload.test.ts | 9 +- ...d-partner-application-request-body.test.ts | 30 ++---- .../build-partner-application-request-body.ts | 18 +--- .../data/partner-skill-pool.ts | 24 +++++ .../data/partner-skill-suggestions.test.ts | 20 ++++ .../data/partner-skill-suggestions.ts | 19 +--- .../non-negative-amount-field-schema.ts | 13 +++ .../partner-application-copy.ts | 16 ++-- .../partner-application-reducer.test.ts | 27 ++++++ .../partner-application-reducer.ts | 77 +-------------- ...partner-application-request-schema.test.ts | 28 +++++- .../partner-application-request-schema.ts | 15 ++- .../validate-partner-application-step.ts | 96 +++++++++++++++++++ .../wizard/PartnerApplicationWizard.tsx | 17 ++-- .../wizard/steps/CommercialsStep.tsx | 2 + .../wizard/steps/ExpertiseStep.tsx | 2 + .../wizard/steps/ProfileStep.tsx | 1 + .../src/ui/TagInput.test.tsx | 52 ++++++++++ .../twenty-website-redone/src/ui/TagInput.tsx | 14 +-- .../test/setup-jest-dom.ts | 1 + yarn.lock | 4 + 25 files changed, 358 insertions(+), 184 deletions(-) create mode 100644 packages/twenty-website-redone/src/partner-application/data/partner-skill-pool.ts create mode 100644 packages/twenty-website-redone/src/partner-application/data/partner-skill-suggestions.test.ts create mode 100644 packages/twenty-website-redone/src/partner-application/non-negative-amount-field-schema.ts create mode 100644 packages/twenty-website-redone/src/partner-application/validate-partner-application-step.ts create mode 100644 packages/twenty-website-redone/src/ui/TagInput.test.tsx create mode 100644 packages/twenty-website-redone/test/setup-jest-dom.ts diff --git a/packages/twenty-website-redone/jest.config.mjs b/packages/twenty-website-redone/jest.config.mjs index 467f651115..861c0b385e 100644 --- a/packages/twenty-website-redone/jest.config.mjs +++ b/packages/twenty-website-redone/jest.config.mjs @@ -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: ['/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$': '/test/lingui-macro-mock.ts', }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'mjs'], +}; + +const jestConfig = { + projects: [ + { + ...baseProject, + displayName: 'node', + testEnvironment: 'node', + testMatch: ['/src/**/*.test.ts'], + }, + { + ...baseProject, + displayName: 'jsdom', + testEnvironment: 'jsdom', + testMatch: ['/src/**/*.test.tsx'], + }, + ], coverageDirectory: './coverage', }; diff --git a/packages/twenty-website-redone/package.json b/packages/twenty-website-redone/package.json index 9b53be79ce..801500da51 100644 --- a/packages/twenty-website-redone/package.json +++ b/packages/twenty-website-redone/package.json @@ -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" } } diff --git a/packages/twenty-website-redone/scripts/check-conventions.mjs b/packages/twenty-website-redone/scripts/check-conventions.mjs index e55a8335d7..4d35fb5400 100644 --- a/packages/twenty-website-redone/scripts/check-conventions.mjs +++ b/packages/twenty-website-redone/scripts/check-conventions.mjs @@ -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.`); } } diff --git a/packages/twenty-website-redone/src/app/api/partner-application/route.test.ts b/packages/twenty-website-redone/src/app/api/partner-application/route.test.ts index ca655ec172..444ebacf8a 100644 --- a/packages/twenty-website-redone/src/app/api/partner-application/route.test.ts +++ b/packages/twenty-website-redone/src/app/api/partner-application/route.test.ts @@ -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, 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(() => diff --git a/packages/twenty-website-redone/src/partner-application/build-logic-function-payload.test.ts b/packages/twenty-website-redone/src/partner-application/build-logic-function-payload.test.ts index a0bd80f336..f39605045c 100644 --- a/packages/twenty-website-redone/src/partner-application/build-logic-function-payload.test.ts +++ b/packages/twenty-website-redone/src/partner-application/build-logic-function-payload.test.ts @@ -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', () => { diff --git a/packages/twenty-website-redone/src/partner-application/build-partner-application-request-body.test.ts b/packages/twenty-website-redone/src/partner-application/build-partner-application-request-body.test.ts index 3b766c90cd..dc2662968f 100644 --- a/packages/twenty-website-redone/src/partner-application/build-partner-application-request-body.test.ts +++ b/packages/twenty-website-redone/src/partner-application/build-partner-application-request-body.test.ts @@ -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'); - }); }); diff --git a/packages/twenty-website-redone/src/partner-application/build-partner-application-request-body.ts b/packages/twenty-website-redone/src/partner-application/build-partner-application-request-body.ts index 7127bf3c27..be7b6729a8 100644 --- a/packages/twenty-website-redone/src/partner-application/build-partner-application-request-body.ts +++ b/packages/twenty-website-redone/src/partner-application/build-partner-application-request-body.ts @@ -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; diff --git a/packages/twenty-website-redone/src/partner-application/data/partner-skill-pool.ts b/packages/twenty-website-redone/src/partner-application/data/partner-skill-pool.ts new file mode 100644 index 0000000000..88051d72c8 --- /dev/null +++ b/packages/twenty-website-redone/src/partner-application/data/partner-skill-pool.ts @@ -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', +]; diff --git a/packages/twenty-website-redone/src/partner-application/data/partner-skill-suggestions.test.ts b/packages/twenty-website-redone/src/partner-application/data/partner-skill-suggestions.test.ts new file mode 100644 index 0000000000..0d623ae48b --- /dev/null +++ b/packages/twenty-website-redone/src/partner-application/data/partner-skill-suggestions.test.ts @@ -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); + } + }); +}); diff --git a/packages/twenty-website-redone/src/partner-application/data/partner-skill-suggestions.ts b/packages/twenty-website-redone/src/partner-application/data/partner-skill-suggestions.ts index 90c529f27b..95241da6de 100644 --- a/packages/twenty-website-redone/src/partner-application/data/partner-skill-suggestions.ts +++ b/packages/twenty-website-redone/src/partner-application/data/partner-skill-suggestions.ts @@ -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', ]; diff --git a/packages/twenty-website-redone/src/partner-application/non-negative-amount-field-schema.ts b/packages/twenty-website-redone/src/partner-application/non-negative-amount-field-schema.ts new file mode 100644 index 0000000000..e21d3ae4ec --- /dev/null +++ b/packages/twenty-website-redone/src/partner-application/non-negative-amount-field-schema.ts @@ -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.' }, + ); diff --git a/packages/twenty-website-redone/src/partner-application/partner-application-copy.ts b/packages/twenty-website-redone/src/partner-application/partner-application-copy.ts index c6b15eeb2a..bfb184eae7 100644 --- a/packages/twenty-website-redone/src/partner-application/partner-application-copy.ts +++ b/packages/twenty-website-redone/src/partner-application/partner-application-copy.ts @@ -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`, }, diff --git a/packages/twenty-website-redone/src/partner-application/partner-application-reducer.test.ts b/packages/twenty-website-redone/src/partner-application/partner-application-reducer.test.ts index 41c97b81dd..3707b518b5 100644 --- a/packages/twenty-website-redone/src/partner-application/partner-application-reducer.test.ts +++ b/packages/twenty-website-redone/src/partner-application/partner-application-reducer.test.ts @@ -8,6 +8,7 @@ const baseValidIdentity: Partial = { 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( { diff --git a/packages/twenty-website-redone/src/partner-application/partner-application-reducer.ts b/packages/twenty-website-redone/src/partner-application/partner-application-reducer.ts index e4b84bc61c..2e7e8bb5b7 100644 --- a/packages/twenty-website-redone/src/partner-application/partner-application-reducer.ts +++ b/packages/twenty-website-redone/src/partner-application/partner-application-reducer.ts @@ -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 -> = { - 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> { - const stepId = PARTNER_APPLICATION_STEP_IDS[state.stepIndex]; - const errors: Partial> = {}; - - 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>, @@ -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 }; } diff --git a/packages/twenty-website-redone/src/partner-application/partner-application-request-schema.test.ts b/packages/twenty-website-redone/src/partner-application/partner-application-request-schema.test.ts index d8944b37e9..5e8cd9088b 100644 --- a/packages/twenty-website-redone/src/partner-application/partner-application-request-schema.test.ts +++ b/packages/twenty-website-redone/src/partner-application/partner-application-request-schema.test.ts @@ -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); + }); }); diff --git a/packages/twenty-website-redone/src/partner-application/partner-application-request-schema.ts b/packages/twenty-website-redone/src/partner-application/partner-application-request-schema.ts index 8d685ea330..f6548da9d6 100644 --- a/packages/twenty-website-redone/src/partner-application/partner-application-request-schema.ts +++ b/packages/twenty-website-redone/src/partner-application/partner-application-request-schema.ts @@ -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, }); diff --git a/packages/twenty-website-redone/src/partner-application/validate-partner-application-step.ts b/packages/twenty-website-redone/src/partner-application/validate-partner-application-step.ts new file mode 100644 index 0000000000..165462d232 --- /dev/null +++ b/packages/twenty-website-redone/src/partner-application/validate-partner-application-step.ts @@ -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 +> = { + 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> { + 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> = {}; + + 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; +} diff --git a/packages/twenty-website-redone/src/partner-application/wizard/PartnerApplicationWizard.tsx b/packages/twenty-website-redone/src/partner-application/wizard/PartnerApplicationWizard.tsx index 8aff55f99e..7bd1a13e9a 100644 --- a/packages/twenty-website-redone/src/partner-application/wizard/PartnerApplicationWizard.tsx +++ b/packages/twenty-website-redone/src/partner-application/wizard/PartnerApplicationWizard.tsx @@ -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) => { @@ -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', { diff --git a/packages/twenty-website-redone/src/partner-application/wizard/steps/CommercialsStep.tsx b/packages/twenty-website-redone/src/partner-application/wizard/steps/CommercialsStep.tsx index 57184fb422..2787b1c076 100644 --- a/packages/twenty-website-redone/src/partner-application/wizard/steps/CommercialsStep.tsx +++ b/packages/twenty-website-redone/src/partner-application/wizard/steps/CommercialsStep.tsx @@ -22,6 +22,7 @@ export function CommercialsStep({ setField('hourlyRate', value)} placeholder={i18n._(FIELDS.hourlyRatePlaceholder)} @@ -32,6 +33,7 @@ export function CommercialsStep({ setField('projectBudgetMin', value)} placeholder={i18n._(FIELDS.projectBudgetMinPlaceholder)} diff --git a/packages/twenty-website-redone/src/partner-application/wizard/steps/ExpertiseStep.tsx b/packages/twenty-website-redone/src/partner-application/wizard/steps/ExpertiseStep.tsx index fa0f5d19de..046c74cce3 100644 --- a/packages/twenty-website-redone/src/partner-application/wizard/steps/ExpertiseStep.tsx +++ b/packages/twenty-website-redone/src/partner-application/wizard/steps/ExpertiseStep.tsx @@ -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} /> diff --git a/packages/twenty-website-redone/src/partner-application/wizard/steps/ProfileStep.tsx b/packages/twenty-website-redone/src/partner-application/wizard/steps/ProfileStep.tsx index b5ac180011..1b53365e4d 100644 --- a/packages/twenty-website-redone/src/partner-application/wizard/steps/ProfileStep.tsx +++ b/packages/twenty-website-redone/src/partner-application/wizard/steps/ProfileStep.tsx @@ -59,6 +59,7 @@ export function ProfileStep({ setField('city', value)} placeholder={i18n._(FIELDS.city)} diff --git a/packages/twenty-website-redone/src/ui/TagInput.test.tsx b/packages/twenty-website-redone/src/ui/TagInput.test.tsx new file mode 100644 index 0000000000..317f18df6a --- /dev/null +++ b/packages/twenty-website-redone/src/ui/TagInput.test.tsx @@ -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([]); + return ( + `Remove ${tag}`} + searchPool={['Kubernetes']} + suggestions={['Workflows']} + values={values} + /> + ); +} + +describe('TagInput', () => { + it('offers the suggestions as quick-add chips but never the search pool', () => { + render(); + 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(); + 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(); + 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(); + }); +}); diff --git a/packages/twenty-website-redone/src/ui/TagInput.tsx b/packages/twenty-website-redone/src/ui/TagInput.tsx index 0e8ed2e154..32d6f4dcf3 100644 --- a/packages/twenty-website-redone/src/ui/TagInput.tsx +++ b/packages/twenty-website-redone/src/ui/TagInput.tsx @@ -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) => { diff --git a/packages/twenty-website-redone/test/setup-jest-dom.ts b/packages/twenty-website-redone/test/setup-jest-dom.ts new file mode 100644 index 0000000000..7b0828bfa8 --- /dev/null +++ b/packages/twenty-website-redone/test/setup-jest-dom.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/yarn.lock b/yarn.lock index 24fc3c8576..e5cff7dc30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"