Job Form Architecture
Overview
The Create Job and Update Job flows share a unified set of components in components/reusable/jobs/. Create Job uses a single-page vertical-scroll layout with sidebar scroll-spy navigation (May 2026 redesign). Update Job continues to use the original tabbed wizard since the upload step is skipped.
Shared Components
| Component | Path | Purpose |
|---|---|---|
BasicJobDetail | components/reusable/jobs/basicjobdetail.tsx | Section 2: Core job fields + AI generate |
Requirements | components/reusable/jobs/requirements.tsx | Section 3: Job type, skills, dates, compensation |
Summary | components/reusable/jobs/summary.tsx | Section 4: Read-only review of all fields |
UploadStep | components/reusable/jobs/UploadStep.tsx | Section 1: File upload + AI parsing |
JobRequirementsForm | components/jobs/JobRequirementsForm.tsx | Sub-form used by Requirements section |
ExtractionProgressBanner | components/reusable/jobs/ExtractionProgressBanner.tsx | Sticky banner showing AI extraction progress + classifier verdict |
Create Job: vertical-scroll layout (May 2026)
components/reusable/jobs/CreateJob.tsx renders all four sections (import, basic, requirements, summary) on a single scrollable page. A sticky sidebar lists section labels and tracks the active section via IntersectionObserver (scroll-spy). The footer carries Cancel | Previous | Next | Save Draft | Save; Previous/Next smooth-scroll between sections rather than swapping mounted views, so form state is preserved across navigation and the user can also free-scroll between any sections.
ExtractionProgressBanner
While useJobParsingSubscription is processing, the banner stays sticky at the top of the scroll container so the user sees per-step progress (✓ Basic info, ✓ Requirements, … Responsibilities & skills, … Compensation & benefits) and the classifier chip (✓ Detected: JOB (95%)) regardless of which section they've scrolled to. Three seconds after all four partials land green, the banner auto-dismisses. Data flows from UploadStep (which owns the subscription hook) up to CreateJob via the onExtractionStateChange callback prop with a ref-stable identity to avoid re-render storms.
Update Job (app/company/jobs/updatejob/[id]/page.tsx)
- Loads existing job via
GET_JOB_BY_IDand transforms toJobFormData - Skips Import (Section 1) since the job already exists
- No pipeline management (pipeline section auto-hides)
- Updates via
UPDATE_JOBmutation
BasicJobDetail Adaptive Behavior
The shared BasicJobDetail component adapts based on props:
- Pipeline UI: Shown only when
pipelineLocked,pipelines, orselectedPipelineIdprops are provided (create mode). When hidden, the Status dropdown moves into the Language/WorkSite row as a 3rd column. userprop: Optional. Only used in create mode to seed theresponsiblefield.- All fields: Language, WorkSite, Status, Reference ID, External Link, Job Title, Description, AI Generate, Publish Online, Priority, Responsible.
- Publish Online / Priority: Use
TWCheckbox(not TWToggle — toggles caused scroll-to-focus jumps via hiddensr-onlyinputs).
AI Job Parsing (Generate with AI)
Current System: Legacy WebSocket
The "Generate with AI" button uses the legacy WebSocket system (useWebSocket() context) with pipeline_request messages. This is the same approach as the main branch.
Known limitation: The useJobParsingSubscription hook (GraphQL subscriptions via graphql-ws) causes blank screen crashes when used in components that trigger re-renders (e.g., checkbox clicks). This needs investigation before migrating. The UploadStep (Step 1) uses useJobParsingSubscription successfully because it doesn't share state with interactive form elements.
Data Flow
- User clicks "Generate with AI" with text in the description field
handleAIGenerate()strips HTML tags and sends via WebSocket- WebSocket listener handles
pipeline_result/pipeline_errormessages - On success,
handleJobData()maps extracted data toJobFormData:- Merges requirements/responsibilities/benefits into description as HTML sections
- Converts dates via
convertDate(date, "toCalendar") - Maps skills through
mapSkillsToBackend()(creates missing skills viaCREATE_SKILLmutation) - Maps language codes to display names
RichTextEditor Fix
The RichTextEditor (components/reusable/RichTextEditor.tsx) uses a ref pattern for onChange:
onChangestored inonChangeRef— always has the latest callback- Quill init effect runs once (depends on
placeholderonly) - Value sync is a separate effect (depends on
valueonly) - This prevents unnecessary Quill innerHTML re-assignments on parent re-renders, which caused layout shifts (page jumping up)
TWCheckbox Scroll Fix
TWCheckbox (components/ux/TWCheckbox.tsx) uses e.preventDefault() on the label click to prevent the browser from focusing the hidden sr-only checkbox input, which caused the scroll container to jump to the top. The toggle state is managed via handleChange() called directly.
Due Date defaulting + cross-field validation
components/jobs/JobRequirementsForm.tsx owns two date-handling behaviours that drive the Section 3 form. Both reflect the SUP-00127 backend invariant (dueDate <= startDate) but kick in client-side so the user never sees a server reject.
Auto-default Due Date when empty
When startDate becomes set and dueDate is empty, a useEffect copies startDate into dueDate. This means the user only has to pick a Due Date when they actually want a deadline different from the Start Date — otherwise the form satisfies the invariant by default. The effect is idempotent: it never overwrites a user-edited Due Date.
computeDueDateError(startDate, dueDate) — single source of truth
Validation logic is exported from JobRequirementsForm.tsx and imported by CreateJob.tsx, so there is one invariant check shared across the form, the Next gate, and the Save handler. It returns either undefined (valid / not yet comparable) or "Due Date cannot be later than Start Date" (invariant violated).
const dayKey = (value?: string | null): string | null => {
if (!value) return null
const v = value.trim()
if (!v) return null
const datePart = v.includes("T") ? v.split("T")[0] : v.split(/\s+/)[0]
return /^\d{4}-\d{2}-\d{2}$/.test(datePart) ? datePart : null
}
export const computeDueDateError = (startDate, dueDate) => {
const sKey = dayKey(startDate)
const dKey = dayKey(dueDate)
if (!sKey || !dKey) return undefined
return dKey > sKey ? "Due Date cannot be later than Start Date" : undefined
}
The dayKey helper is the load-bearing piece — see TWDatepicker emit-format gotcha for why new Date() was wrong here.
Form Data Type
JobFormData is defined in lib/types/jobs.ts and serves as the canonical form state shape for both create and edit flows.
Key fields:
skillsandskillRequirements— bothSkillRequirementInput[], kept in syncresponsible(display name) andresponsibleUserId(ID) — both maintained- Date fields (
startDate,endDate,dueDate) — ISO strings or calendar format strings - Pipeline fields (
pipelineId,pipelineName) — only used in create flow
Summary Compensation Display
The Summary step conditionally renders compensation based on jobType:
- DIRECT_HIRE: Shows Salary with currency and frequency
- Other types: Shows Rate In and Rate Out with currencies and frequencies
Summary Infinite Loop Prevention
The Summary component checks companyOptions before calling selectHiringCompany/selectSupplierCompany to avoid an infinite re-render loop where selecting a company triggers a state update that re-triggers the effect.