Skip to main content

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

ComponentPathPurpose
BasicJobDetailcomponents/reusable/jobs/basicjobdetail.tsxSection 2: Core job fields + AI generate
Requirementscomponents/reusable/jobs/requirements.tsxSection 3: Job type, skills, dates, compensation
Summarycomponents/reusable/jobs/summary.tsxSection 4: Read-only review of all fields
UploadStepcomponents/reusable/jobs/UploadStep.tsxSection 1: File upload + AI parsing
JobRequirementsFormcomponents/jobs/JobRequirementsForm.tsxSub-form used by Requirements section
ExtractionProgressBannercomponents/reusable/jobs/ExtractionProgressBanner.tsxSticky 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_ID and transforms to JobFormData
  • Skips Import (Section 1) since the job already exists
  • No pipeline management (pipeline section auto-hides)
  • Updates via UPDATE_JOB mutation

BasicJobDetail Adaptive Behavior

The shared BasicJobDetail component adapts based on props:

  • Pipeline UI: Shown only when pipelineLocked, pipelines, or selectedPipelineId props are provided (create mode). When hidden, the Status dropdown moves into the Language/WorkSite row as a 3rd column.
  • user prop: Optional. Only used in create mode to seed the responsible field.
  • 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 hidden sr-only inputs).

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

  1. User clicks "Generate with AI" with text in the description field
  2. handleAIGenerate() strips HTML tags and sends via WebSocket
  3. WebSocket listener handles pipeline_result / pipeline_error messages
  4. On success, handleJobData() maps extracted data to JobFormData:
    • Merges requirements/responsibilities/benefits into description as HTML sections
    • Converts dates via convertDate(date, "toCalendar")
    • Maps skills through mapSkillsToBackend() (creates missing skills via CREATE_SKILL mutation)
    • Maps language codes to display names

RichTextEditor Fix

The RichTextEditor (components/reusable/RichTextEditor.tsx) uses a ref pattern for onChange:

  • onChange stored in onChangeRef — always has the latest callback
  • Quill init effect runs once (depends on placeholder only)
  • Value sync is a separate effect (depends on value only)
  • 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:

  • skills and skillRequirements — both SkillRequirementInput[], kept in sync
  • responsible (display name) and responsibleUserId (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.