diff --git a/apps/site/astro.config.mjs b/apps/site/astro.config.mjs index d84363e..ec85420 100644 --- a/apps/site/astro.config.mjs +++ b/apps/site/astro.config.mjs @@ -23,6 +23,30 @@ const robotsTxtConfig = { ], }; +function externalLinks() { + return (tree) => { + tree.children?.forEach(function walk(node) { + if ( + node.tagName === 'a' && + typeof node.properties?.href === 'string' && + (node.properties.href.startsWith('http://') || + node.properties.href.startsWith('https://')) + ) { + node.properties.target = '_blank'; + node.properties.rel = 'noopener noreferrer'; + node.properties.className = [ + ...(Array.isArray(node.properties.className) + ? node.properties.className + : []), + 'usa-link', + 'usa-link--external', + ]; + } + node.children?.forEach(walk); + }); + }; +} + export default defineConfig({ site: siteUrl, integrations: [ @@ -34,6 +58,7 @@ export default defineConfig({ ], markdown: { remarkPlugins: [['remark-excerpt', { remove: true }]], + rehypePlugins: [externalLinks], }, vite: { optimizeDeps: { diff --git a/apps/site/package.json b/apps/site/package.json index 7212f73..a6195bc 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -28,6 +28,7 @@ "js-yaml": "^4.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.79.0", "remark-excerpt": "^1.0.0-beta.1", "sass-embedded": "^1.83.0" }, diff --git a/apps/site/src/components/Button.astro b/apps/site/src/components/Button.astro index a8e319b..5d02112 100644 --- a/apps/site/src/components/Button.astro +++ b/apps/site/src/components/Button.astro @@ -15,6 +15,7 @@ interface Props extends astroHTML.JSX.ButtonHTMLAttributes { icon?: string; href?: string; to?: string; + centered?: boolean; } const { @@ -22,10 +23,10 @@ const { size = 'default', icon, class: className, - className: classNameProp, type = 'button', href, to, + centered, ...rest } = Astro.props; @@ -37,18 +38,29 @@ const classes = [ 'radius-pill', variant !== 'default' && `usa-button--${variant}`, size === 'big' && 'usa-button--big', + centered && 'usa-button--centered', className, - classNameProp, ] .filter(Boolean) .join(' '); + +const tagProps = { + ...(targetUrl ? { href: targetUrl } : { type }), + class: classes, + ...rest, +}; --- - - {icon && } + + {icon && } + + diff --git a/apps/site/src/components/EepMember.astro b/apps/site/src/components/EepMember.astro deleted file mode 100644 index 047441b..0000000 --- a/apps/site/src/components/EepMember.astro +++ /dev/null @@ -1,130 +0,0 @@ ---- -/** - * EepMember.astro - * - * Displays a single EEP member with a circular headshot and bio content. - * Stacks vertically on mobile, side by side on tablet and above. - * Falls back to a placeholder image if the headshot is not found. - * - * Props: - * imageName — filename of headshot without extension (e.g. "last-first"). - * Looks for the file in src/assets/images/eep-headshots/. - * Supported formats: jpg, jpeg, png, webp. - * - * Usage in MDX: - * - * ### Name, Ph.D. - * **Title** - * **Affiliated Institution** - * Bio text here. - * - * - * Notes: - * - Image lookup uses import.meta.glob at build time — no runtime cost. - * - Real images go in src/content/eep-headshots/. - * - Falls back to placehold.co if image is not found. - * - Swap placeholder with a local fallback image later if needed. - */ - -import { Image } from 'astro:assets'; - -interface Props { - imageName?: string; -} - -const { imageName } = Astro.props; - -// Glob all headshot images at build time -// Supports jpg, jpeg, png, and webp formats -const images = import.meta.glob<{ default: ImageMetadata }>( - '/src/content/eep-headshots/*.{jpg,jpeg,png,webp}', -); - -// Attempt to find the image by trying each supported extension -const extensions = ['jpg', 'jpeg', 'png', 'webp']; -let resolvedImage = null; - -if (imageName) { - for (const ext of extensions) { - const key = `/src/content/eep-headshots/${imageName}.${ext}`; - if (images[key]) { - resolvedImage = (await images[key]()).default; - break; - } - } -} ---- - -
-
- {resolvedImage ? ( - - ) : ( - - )} -
-
- -
-
- - \ No newline at end of file diff --git a/apps/site/src/components/Tooltip.astro b/apps/site/src/components/Tooltip.astro deleted file mode 100644 index c539aad..0000000 --- a/apps/site/src/components/Tooltip.astro +++ /dev/null @@ -1,77 +0,0 @@ ---- -/** - * Tooltip.astro - * - * Inline tooltip for use in prose. Wraps a word or phrase with a dotted - * underline indicating a tooltip is available. Shows tooltip text on - * hover or focus (keyboard accessible). - * - * Accessibility: - * - aria-describedby links the trigger to the tooltip bubble via a - * generated unique id, so assistive technology announces the tooltip - * content when the trigger receives focus. - * - * Props: - * text — The tooltip content shown on hover/focus. - * - * Usage in MDX: - * Researchers can apply for cloud credits through this program. - */ - -interface Props { - text: string; -} - -const { text } = Astro.props; -const tooltipId = `tooltip-${crypto.randomUUID()}`; ---- - -{text} - - \ No newline at end of file diff --git a/apps/site/src/components/Tooltip.tsx b/apps/site/src/components/Tooltip.tsx new file mode 100644 index 0000000..91d77d8 --- /dev/null +++ b/apps/site/src/components/Tooltip.tsx @@ -0,0 +1,19 @@ +import { Icon, Tooltip as TrussTooltip } from '@trussworks/react-uswds'; + +interface Props { + text: string; + position?: 'top' | 'bottom' | 'left' | 'right'; +} + +export default function Tooltip({ text, position = 'top' }: Props) { + return ( + + + + ); +} diff --git a/apps/site/src/components/events/EventsList.astro b/apps/site/src/components/events/EventsList.astro index c538178..0fb6730 100644 --- a/apps/site/src/components/events/EventsList.astro +++ b/apps/site/src/components/events/EventsList.astro @@ -35,7 +35,7 @@ const dateOptions = {

- + {event.data.title}

diff --git a/apps/site/src/components/forms/ConsentField.tsx b/apps/site/src/components/forms/ConsentField.tsx new file mode 100644 index 0000000..f04909b --- /dev/null +++ b/apps/site/src/components/forms/ConsentField.tsx @@ -0,0 +1,64 @@ +/** + * ConsentField + * + * A hardcoded consent checkbox rendered at the bottom of every form, + * above the submit button. Not derived from Freshdesk field config — + * this is a site-level requirement added by DynamicForm regardless + * of what fields Freshdesk returns. + * + * Why hardcoded? + * Consent language is a legal/policy concern, not a support ticket + * field. It doesn't belong in Freshdesk's field config and shouldn't + * be editable there. It lives here so content changes go through + * the normal code review and approval process. + * + * Behavior: + * - Required — the form cannot be submitted without checking this box + * - Checked state is registered with React Hook Form like any other field + * - The consent value is NOT included in the Freshdesk ticket payload — + * it's filtered out in buildPayload since it has no cf_ prefix and + * no matching default field type + * + * Copy note: + * The label text below is a placeholder. Final consent language should + * be confirmed with the content lead and legal/policy reviewer before launch. + */ + +import type { FieldError, UseFormRegister } from 'react-hook-form'; + +// The field name used to register this input with React Hook Form. +// Must be consistent with any filtering logic in buildPayload. +export const CONSENT_FIELD_NAME = 'consent'; + +interface ConsentFieldProps { + register: ReturnType>>; + error?: FieldError; +} + +export default function ConsentField({ register, error }: ConsentFieldProps) { + return ( +
+ {error && ( + + {error.message} + + )} + +
+ + +
+
+ ); +} diff --git a/apps/site/src/components/forms/DynamicCustomObjectForm.tsx b/apps/site/src/components/forms/DynamicCustomObjectForm.tsx new file mode 100644 index 0000000..a14695d --- /dev/null +++ b/apps/site/src/components/forms/DynamicCustomObjectForm.tsx @@ -0,0 +1,306 @@ +/** + * DynamicCustomObjectForm + * + * A React island that renders a Freshdesk custom object form dynamically + * from field configuration fetched at build time by the Astro page. + * + * Parallel to DynamicForm.tsx but for custom object schemas rather than + * ticket forms. Key differences: + * + * - Accepts CustomObjectField[] instead of FreshdeskField[] + * - Uses renderCustomObjectField instead of renderField + * - Uses buildCustomObjectPayload instead of buildPayload + * - No formType — custom object records don't have a ticket type string + * - No dynamic sections — custom objects don't support them natively + * - schemaId identifies which custom object schema to post records to + * - idPrefix customizes the generated submission ID per form type + * + * FormProvider: + * This component uses FormProvider to expose the RHF form context to all + * child field components. Field components that need unregister (e.g. for + * "Other" free text fields) access it via useFormContext rather than having + * it passed as a prop. + * + * Data flow: + * 1. Astro page fetches schema fields via getCustomObjectSchema at build time + * 2. The schema response already includes choices for DROPDOWN and MULTI_SELECT fields + * 3. Filtered fields array is passed as props to this component + * 4. renderCustomObjectField maps field types to USWDS components + * 5. On submit, buildCustomObjectPayload builds the record payload + * 6. Payload (including reCAPTCHA token) is POSTed to the Lambda proxy, + * which forwards to POST /api/v2/custom_objects/schemas/{id}/records/ + * + * Error fallback: + * If getCustomObjectSchema throws at build time, the Astro page catches it + * and passes error={true}. This component renders a fallback message instead + * of the form. + */ + +import { useRef, useState } from 'react'; +import type { FieldError } from 'react-hook-form'; +import { FormProvider, useForm } from 'react-hook-form'; +import { buildCustomObjectPayload } from '../../util/freshdesk/buildCustomObjectPayload'; +import type { CustomObjectField } from '../../util/freshdesk/typesCustomObjects'; +import { getRecaptchaToken } from '../../util/recaptcha'; +import ConsentField, { CONSENT_FIELD_NAME } from './ConsentField'; +import HoneypotField from './HoneypotField'; +import { renderCustomObjectField } from './helpers/renderCustomObjectField'; +import { fieldErrors, formErrors, formStatus } from './util/errorMessages'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type FormStatus = 'idle' | 'submitting' | 'success' | 'error'; + +interface DynamicCustomObjectFormProps { + // Filtered, enriched field config from getCustomObjectSchema. + // PRIMARY, RELATIONSHIP, and hidden fields are excluded. + // DROPDOWN and MULTI_SELECT fields include choices from the schema response. + fields: CustomObjectField[]; + // The numeric ID of the custom object schema. + // Passed to the Lambda proxy so it can construct the correct endpoint: + // POST /api/v2/custom_objects/schemas/{schemaId}/records/ + schemaId: number; + // Prefix for the generated submission ID (e.g. 'PUB', 'CC', 'JOIN'). + // Defaults to 'SUB' if not provided. + idPrefix?: string; + // The URL of the Lambda proxy endpoint. + // Set via FRESHDESK_PROXY_URL in apps/site/.env. + submitUrl: string; + // The reCAPTCHA v3 site key for the current environment. + // Passed from the Astro page via import.meta.env.PUBLIC_RECAPTCHA_SITE_KEY. + recaptchaSiteKey: string; + // True if getCustomObjectSchema threw at build time — renders fallback UI. + error?: boolean; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function DynamicCustomObjectForm({ + fields, + schemaId, + idPrefix = 'SUB', + submitUrl, + recaptchaSiteKey, + error = false, +}: DynamicCustomObjectFormProps) { + const [status, setStatus] = useState('idle'); + const [submitError, setSubmitError] = useState(null); + const errorSummaryRef = useRef(null); + const confirmationRef = useRef(null); + + // methods is the full RHF form instance. Spread into FormProvider so all + // child field components can access register, unregister, control, etc. + // via useFormContext without needing them passed as props. + const methods = useForm>({ + mode: 'onSubmit', // Validate on submit, not while typing + reValidateMode: 'onChange', // Clear errors in real time as user fixes them + }); + + const { + register, + control, + handleSubmit, + reset, + formState: { errors }, + } = methods; + + // --------------------------------------------------------------------------- + // Fallback — shown when getCustomObjectSchema failed at build time + // --------------------------------------------------------------------------- + + if (error) { + return ( +
+
+

+ {formStatus.unavailableHeading} +

+

{formStatus.unavailable}

+
+
+ ); + } + + // --------------------------------------------------------------------------- + // Success state — replaces the form after a successful submission + // --------------------------------------------------------------------------- + + if (status === 'success') { + return ( + // tabIndex={-1} allows focus to be programmatically moved here + // after submission, per the UX spec accessibility requirement. +
+ +
+

{formStatus.successHeading}

+

+ {/* TODO: Per-form follow-up copy — confirm with content team */} + Check your inbox for a confirmation email with a copy of your + submission. +

+
+
+ +
+ {/* TODO: Per-form button labels and destinations — confirm with content team */} + + + Return to home + +
+
+ ); + } + + // --------------------------------------------------------------------------- + // Submit handler + // --------------------------------------------------------------------------- + + const onSubmit = async (values: Record) => { + setStatus('submitting'); + setSubmitError(null); + + try { + // Get reCAPTCHA token before building payload. + // The Lambda proxy verifies this token with Google before forwarding + // the record to Freshdesk. See services/freshdesk/handler.py. + const recaptchaToken = await getRecaptchaToken(recaptchaSiteKey); + + const payload = { + ...buildCustomObjectPayload(values, fields, 'name', idPrefix), + recaptcha_token: recaptchaToken, + // Pass schemaId so the Lambda knows which endpoint to hit. + // The Lambda constructs: POST /api/v2/custom_objects/schemas/{schemaId}/records/ + schema_id: schemaId, + }; + + const response = await fetch(submitUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Submit failed: ${response.status}`); + } + + setStatus('success'); + + // Move focus to confirmation message per UX spec accessibility requirement. + // setTimeout defers until after React re-renders the success state. + setTimeout(() => confirmationRef.current?.focus(), 0); + } catch { + setStatus('error'); + setSubmitError(formErrors.submission.general); + } + }; + + const onError = () => { + // Validation failed — move focus to error summary per UX spec. + setTimeout(() => errorSummaryRef.current?.focus(), 0); + }; + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + + const errorFields = Object.keys(errors); + + return ( + +
+ {/* Submission error banner — shown when the Lambda POST fails */} + {status === 'error' && submitError && ( +
+
+

{submitError}

+
+
+ )} + + {/* Validation error summary — shown after a failed submit attempt. + Focus moves here programmatically via onError so screen reader + users hear the summary immediately. */} + {errorFields.length > 0 && ( +
+
+

+ {formErrors.summary.heading} +

+ +
+
+ )} + + {/* Required fields note — per UX spec, appears at top of every form */} +

+ Required fields are marked with an asterisk (*). +

+ + {/* Form intro text */} +

+ Enter the required information below to complete your submission. +

+ + {/* Dynamic fields — rendered from custom object schema field config */} + {fields.map((field) => + renderCustomObjectField(field, register, control, errors), + )} + + {/* Hardcoded fields — not derived from Freshdesk config */} + + + + + {/* Submit button — disabled while submitting to prevent double submission. + Label changes to "Submitting…" so users know something is happening. */} + + +
+ ); +} diff --git a/apps/site/src/components/forms/DynamicForm.tsx b/apps/site/src/components/forms/DynamicForm.tsx new file mode 100644 index 0000000..6f6aa47 --- /dev/null +++ b/apps/site/src/components/forms/DynamicForm.tsx @@ -0,0 +1,329 @@ +/** + * DynamicForm + * + * A React island that renders a Freshdesk-backed form dynamically from + * field configuration fetched at build time by the Astro page. + * + * Why a React island? + * Forms require client-side interactivity — validation state, submission + * handling, field error display, success/loading/error UI transitions. + * The field *config* is static (fetched at build time and passed as props), + * but the form *behavior* must run in the browser. + * + * Data flow: + * 1. Astro page fetches field config from Freshdesk API at build time + * 2. Filtered fields array is passed as props to this component + * 3. renderField maps each field type to the appropriate USWDS component + * 4. On submit, buildPayload transforms RHF values into a Freshdesk ticket shape + * 5. Payload (including reCAPTCHA token) is POSTed to the Lambda proxy, + * which verifies reCAPTCHA and forwards the ticket to Freshdesk + * + * Dynamic sections: + * Dropdown fields with dynamic sections track their selected value in + * sectionSelections state. renderField uses this to show/hide section + * fields inline below the dropdown. Hidden section fields are unregistered + * from RHF so their values are excluded from the payload. + * + * FormProvider: + * This component uses FormProvider to expose the RHF form context to all + * child field components. Field components that need unregister (e.g. for + * "Other" free text fields) access it via useFormContext rather than having + * it passed as a prop. + * + * Error fallback: + * If getFormFields throws at build time, the Astro page catches it and passes + * error={true}. This component renders a fallback message instead of the form. + */ + +import { useCallback, useRef, useState } from 'react'; +import type { FieldError } from 'react-hook-form'; +import { FormProvider, useForm } from 'react-hook-form'; +import { buildPayload } from '../../util/freshdesk/buildPayload'; +import { getSectionFieldIds } from '../../util/freshdesk/getFormFields'; +import type { FreshdeskField } from '../../util/freshdesk/types'; +import { getRecaptchaToken } from '../../util/recaptcha'; +import ConsentField, { CONSENT_FIELD_NAME } from './ConsentField'; +import HoneypotField from './HoneypotField'; +import { renderField } from './helpers/renderField'; +import { fieldErrors, formErrors, formStatus } from './util/errorMessages'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type FormStatus = 'idle' | 'submitting' | 'success' | 'error'; + +interface DynamicFormProps { + // Filtered, enriched field config from getFormFields — only fields where + // displayed_to_customers is true and archived is false. Dropdown fields + // include choices and sections populated at build time. + fields: FreshdeskField[]; + // The Freshdesk ticket type string for this form + // (e.g. "Published Research Submission"). + // Used as both ticket `type` and ticket `subject` in buildPayload. + formType: string; + // The URL of the Lambda proxy endpoint. + // Provided by the Astro page so it can vary per environment. + // Set via FRESHDESK_PROXY_URL in apps/site/.env. + submitUrl: string; + // The reCAPTCHA v3 site key for the current environment. + // Passed from the Astro page via import.meta.env.PUBLIC_RECAPTCHA_SITE_KEY. + // Used to obtain a token before submission — the Lambda verifies it. + recaptchaSiteKey: string; + // True if getFormFields threw at build time — renders fallback UI. + error?: boolean; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function DynamicForm({ + fields, + formType, + submitUrl, + recaptchaSiteKey, + error = false, +}: DynamicFormProps) { + const [status, setStatus] = useState('idle'); + const [submitError, setSubmitError] = useState(null); + const errorSummaryRef = useRef(null); + const confirmationRef = useRef(null); + + // Tracks the currently selected value for each dropdown that has sections. + // Keyed by field name, value is the selected choice value string. + // Updated by onSectionChange when the user selects a dropdown option. + const [sectionSelections, setSectionSelections] = useState< + Record + >({}); + + // methods is the full RHF form instance. Spread into FormProvider so all + // child field components can access register, unregister, control, etc. + // via useFormContext without needing them passed as props. + const methods = useForm>({ + mode: 'onSubmit', // Validate on submit, not while typing + reValidateMode: 'onChange', // Clear errors in real time as user fixes them + }); + + const { + register, + control, + handleSubmit, + reset, + formState: { errors }, + } = methods; + + // Called by SelectField (via renderField) when a section-controlling + // dropdown changes. Updates sectionSelections which triggers a re-render, + // showing or hiding the appropriate section fields. + // useCallback prevents unnecessary re-renders of child components. + const onSectionChange = useCallback((fieldName: string, value: string) => { + setSectionSelections((prev) => ({ ...prev, [fieldName]: value })); + }, []); + + // IDs of fields that belong to dynamic sections. + // These are skipped during top-level rendering — they only appear + // inline below their parent dropdown when their section is triggered. + const sectionFieldIds = getSectionFieldIds(fields); + + // --------------------------------------------------------------------------- + // Fallback — shown when getFormFields failed at build time + // --------------------------------------------------------------------------- + + if (error) { + return ( +
+
+

+ {formStatus.unavailableHeading} +

+

{formStatus.unavailable}

+
+
+ ); + } + + // --------------------------------------------------------------------------- + // Success state — replaces the form after a successful submission + // --------------------------------------------------------------------------- + + if (status === 'success') { + return ( + // tabIndex={-1} allows focus to be programmatically moved here + // after submission, per the UX spec accessibility requirement. +
+ +
+

{formStatus.successHeading}

+

{formStatus.successText}

+
+
+ +
+ {/* TODO: Per-form button labels and destinations — confirm with content team */} + + + Return to home + +
+
+ ); + } + + // --------------------------------------------------------------------------- + // Submit handler + // --------------------------------------------------------------------------- + + const onSubmit = async (values: Record) => { + setStatus('submitting'); + setSubmitError(null); + + try { + // Get reCAPTCHA token before building payload. + // The Lambda proxy verifies this token with Google before forwarding + // the ticket to Freshdesk. See services/freshdesk/handler.py. + const recaptchaToken = await getRecaptchaToken(recaptchaSiteKey); + + const payload = { + ...buildPayload(values, fields, formType), + recaptcha_token: recaptchaToken, + }; + + const response = await fetch(submitUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Submit failed: ${response.status}`); + } + + setStatus('success'); + + // Move focus to confirmation message per UX spec accessibility requirement. + // setTimeout defers until after React re-renders the success state. + setTimeout(() => confirmationRef.current?.focus(), 0); + } catch { + setStatus('error'); + setSubmitError(formErrors.submission.general); + } + }; + + const onError = () => { + // Validation failed — move focus to error summary per UX spec. + setTimeout(() => errorSummaryRef.current?.focus(), 0); + }; + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + + const errorFields = Object.keys(errors); + + return ( + +
+ {/* Submission error banner — shown when the Lambda POST fails */} + {status === 'error' && submitError && ( +
+
+

{submitError}

+
+
+ )} + + {/* Validation error summary — shown after a failed submit attempt. + Focus moves here programmatically via onError so screen reader + users hear the summary immediately. */} + {errorFields.length > 0 && ( +
+
+

+ {formErrors.summary.heading} +

+ +
+
+ )} + + {/* Required fields note — per UX spec, appears at top of every form */} +

+ Required fields are marked with an asterisk (*). +

+ + {/* Form intro text */} +

+ Enter the required information below to complete your submission. +

+ + {/* Dynamic fields — rendered from Freshdesk field config. + Fields that belong to dynamic sections are skipped here — + they are rendered inline below their parent dropdown by renderField. */} + {fields + .filter((field) => !sectionFieldIds.has(field.id)) + .map((field) => + renderField( + field, + register, + control, + errors, + sectionSelections, + onSectionChange, + ), + )} + + {/* Hardcoded fields — not derived from Freshdesk config */} + + + + + {/* Submit button — disabled while submitting to prevent double submission. + Label changes to "Submitting…" so users know something is happening. */} + + +
+ ); +} diff --git a/apps/site/src/components/forms/HoneypotField.tsx b/apps/site/src/components/forms/HoneypotField.tsx new file mode 100644 index 0000000..ad658f0 --- /dev/null +++ b/apps/site/src/components/forms/HoneypotField.tsx @@ -0,0 +1,53 @@ +/** + * HoneypotField + * + * An invisible field included in every form as a bot trap. + * + * How it works: + * - Hidden from sighted users via CSS (not display:none or hidden attribute — + * those are detectable by bots and may cause them to skip the field) + * - Bots that auto-fill all form fields will fill this in + * - If the field has any value on submit, the Lambda proxy silently discards + * the submission and returns a fake success response + * - Real users never see it, never fill it, and never know it's there + * + * Implementation note: + * - aria-hidden removes it from the accessibility tree so screen readers skip it + * - tabIndex={-1} prevents keyboard users from accidentally landing on it + * - autocomplete="off" discourages browser autofill from populating it + * - The field name "website" is intentionally generic — a common honeypot convention + * + * Per the UX spec: "The bot sees a success message and doesn't know it was caught." + * That behavior is handled in the Lambda proxy, not here. + */ + +import type { UseFormRegister } from 'react-hook-form'; + +interface HoneypotFieldProps { + register: ReturnType>>; +} + +// Visually hidden but not display:none — keeps it in the DOM flow +// so it looks like a real field to bots scanning the page. +const hiddenStyle: React.CSSProperties = { + position: 'absolute', + left: '-9999px', + width: '1px', + height: '1px', + overflow: 'hidden', +}; + +export default function HoneypotField({ register }: HoneypotFieldProps) { + return ( + + ); +} diff --git a/apps/site/src/components/forms/fields/CheckboxField.tsx b/apps/site/src/components/forms/fields/CheckboxField.tsx new file mode 100644 index 0000000..4582aa6 --- /dev/null +++ b/apps/site/src/components/forms/fields/CheckboxField.tsx @@ -0,0 +1,97 @@ +/** + * CheckboxField + * + * Renders a USWDS single boolean checkbox for Freshdesk field type: + * - custom_checkbox + * + * Freshdesk's custom_checkbox is a single boolean field — checked submits + * true, unchecked submits false. There is no multiselect checkbox type in + * Freshdesk ticket forms. If multiple options are needed, the Freshdesk + * workaround is multiple separate checkbox fields or a dropdown. + * + * This component is distinct from ConsentField, which is a hardcoded + * site-level checkbox not derived from Freshdesk field config. This component + * is for Freshdesk-configured boolean fields rendered dynamically by DynamicForm. + * + * Payload behavior: + * The value submitted to Freshdesk is a boolean (true/false), not a string. + * React Hook Form registers checkboxes as booleans by default, which is + * correct for Freshdesk's custom_checkbox type. + * + * Uses raw USWDS CSS classes, consistent with the project pattern of using + * Trussworks only when a component requires complex JS behavior. + */ + +import type { FieldError, UseFormRegister } from 'react-hook-form'; + +interface CheckboxFieldProps { + // The Freshdesk field name (e.g. "cf_agree_to_terms"). + // Used as the HTML input name and React Hook Form registration key. + name: string; + // The customer-facing label from label_for_customers. + // Rendered as the checkbox label, not as a separate field label above it — + // USWDS checkbox pattern places the label inline with the input. + label: string; + // Optional hint text from hint_for_customers. + // Rendered above the checkbox group wrapper per USWDS checkbox pattern. + hint?: string; + // Whether the field is required — derived from required_for_customers. + // A required checkbox means the user must check it to submit. + required?: boolean; + // React Hook Form's register function, bound to this field by the parent. + register: ReturnType>>; + // The React Hook Form error for this field, if any. + error?: FieldError; +} + +export default function CheckboxField({ + name, + label, + hint, + required = false, + register, + error, +}: CheckboxFieldProps) { + const hintId = hint ? `${name}-hint` : undefined; + const errorId = error ? `${name}-error` : undefined; + + return ( +
+ {/* USWDS checkbox pattern: hint and error appear above the checkbox + wrapper, not between a label and input as with text fields. */} + {hint && ( + + {hint} + + )} + + {error && ( + + {error.message} + + )} + +
+ + +
+
+ ); +} diff --git a/apps/site/src/components/forms/fields/DateField.tsx b/apps/site/src/components/forms/fields/DateField.tsx new file mode 100644 index 0000000..0409569 --- /dev/null +++ b/apps/site/src/components/forms/fields/DateField.tsx @@ -0,0 +1,95 @@ +/** + * DateField + * + * Renders a date input for Freshdesk field type: custom_date + * + * Why Trussworks here instead of raw USWDS classes? + * The USWDS date picker is a JS-enhanced component that initializes by + * scanning the DOM for [data-module="usa-date-picker"]. In a React island, + * React controls the DOM and renders client-side — the USWDS JS bundle + * may run before React has mounted, missing the element entirely. + * + * Trussworks DatePicker handles its own initialization as a proper React + * component, making it safe to use inside DynamicForm without needing to + * manually call datePicker.init() in a useEffect or depend on load order. + * + * All other field types use raw USWDS classes — this is the one exception. + * + * React Hook Form integration note: + * Trussworks DatePicker doesn't accept a ref directly (it wraps a USWDS + * component internally), so we use Controller from React Hook Form instead + * of register. The parent DynamicForm passes control rather than register + * for this field type. + */ + +import { DatePicker } from '@trussworks/react-uswds'; +import type { Control, FieldError } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; +import { fieldErrors } from '../util/errorMessages'; + +interface DateFieldProps { + name: string; + label: string; + hint?: string; + required?: boolean; + // DateField uses Controller instead of register because Trussworks DatePicker + // doesn't expose a ref for React Hook Form to attach to directly. + control: Control>; + error?: FieldError; +} + +export default function DateField({ + name, + label, + hint, + required = false, + control, + error, +}: DateFieldProps) { + const hintId = hint ? `${name}-hint` : undefined; + const errorId = error ? `${name}-error` : undefined; + + return ( +
+ + + {hint && ( + + {hint} + + )} + + {error && ( + + {error.message} + + )} + + ( + field.onChange(val)} + aria-describedby={ + [hintId, errorId].filter(Boolean).join(' ') || undefined + } + /> + )} + /> +
+ ); +} diff --git a/apps/site/src/components/forms/fields/MultiSelectCheckbox.tsx b/apps/site/src/components/forms/fields/MultiSelectCheckbox.tsx new file mode 100644 index 0000000..933e75a --- /dev/null +++ b/apps/site/src/components/forms/fields/MultiSelectCheckbox.tsx @@ -0,0 +1,177 @@ +/** + * MultiSelectCheckbox + * + * Renders a USWDS checkbox group for multi-select fields. + * + * Used for fields where the user can select one or more options from a + * predefined list — for example, Research Community and Research Area + * on the Publications Submission form. + * + * Options source: + * Options are passed as a string array prop. They come from either: + * - Inline choices defined on the Freshdesk custom object schema + * (returned by getCustomObjectSchema and mapped to string values) + * - A string array populated at build time by the Astro page from + * a separate data source and merged into the field config + * Options are never fetched client-side. + * + * Payload shape: + * React Hook Form returns checked checkbox values as an array of strings. + * This maps directly to the MULTI_SELECT field type in Freshdesk custom + * objects, which expects an array of string values. + * buildCustomObjectPayload handles this automatically. + * + * "Other" free text: + * If "Other" appears in the options array (case-insensitive), checking it + * reveals a text input below the checkbox group. The text input is registered + * as `other_{fieldName}` and is required when "Other" is checked. + * Unchecking "Other" hides and clears the text input via useFormContext's + * unregister — so stale values are never included in the payload. + * + * Uses raw USWDS CSS classes, consistent with the project pattern of using + * Trussworks only when a component requires complex JS behavior. + */ + +import { useState } from 'react'; +import type { FieldError, UseFormRegister } from 'react-hook-form'; +import { useFormContext } from 'react-hook-form'; +import { fieldErrors } from '../util/errorMessages'; +import TextField from './TextField'; + +interface MultiSelectCheckboxProps { + // The field name — used as the HTML input name and RHF registration key. + // Also used to generate unique input IDs for each option and the + // "Other" free text field name: `other_{name}`. + name: string; + // The customer-facing label for the checkbox group. + label: string; + // Optional hint text shown below the label, above the checkboxes. + hint?: string; + // Whether at least one option must be checked before submitting. + required?: boolean; + // The available options for this checkbox group. + // Provided by the parent — either from inline schema choices or a + // separate data source merged into the field config at build time. + // Each string is both the display label and the submitted value. + // If "Other" is present (case-insensitive), a free text input is shown + // when it is checked. + options: string[]; + // React Hook Form's register function, bound to this field by the parent. + // Note: RHF automatically collects checked checkbox values as an array + // when multiple checkboxes share the same name. + register: ReturnType>>; + // The React Hook Form error for this field, if any. + error?: FieldError; +} + +export default function MultiSelectCheckbox({ + name, + label, + hint, + required = false, + options, + register, + error, +}: MultiSelectCheckboxProps) { + const hintId = hint ? `${name}-hint` : undefined; + const errorId = error ? `${name}-error` : undefined; + const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined; + + // Track whether "Other" is currently checked so we can show/hide + // the free text input. Local state — doesn't need to live in RHF. + const [otherChecked, setOtherChecked] = useState(false); + + // Access unregister from form context — available because DynamicForm + // and DynamicCustomObjectForm wrap all fields in FormProvider. + const { + register: contextRegister, + unregister, + formState: { errors: contextErrors }, + } = useFormContext>(); + + const otherFieldName = `other_${name}`; + const otherError = contextErrors[otherFieldName] as FieldError | undefined; + + // Detect if "Other" is one of the options (case-insensitive). + const hasOtherOption = options.some((o) => o.toLowerCase() === 'other'); + + return ( +
+
+ + {label} + {required && ( + + {' '} + * + + )} + + + {hint && ( + + {hint} + + )} + + {error && ( + + {error.message} + + )} + + {options.map((option) => { + const isOtherOption = option.toLowerCase() === 'other'; + const optionId = `${name}-${option.toLowerCase().replace(/\s+/g, '-')}`; + + return ( +
+ { + // For the "Other" option, track checked state so we can + // show/hide the free text input. When unchecked, unregister + // the text field to clear its value from the payload. + if (isOtherOption) { + setOtherChecked(e.target.checked); + if (!e.target.checked) { + unregister(otherFieldName, { keepValue: false }); + } + } + // Call the original register onChange to keep RHF in sync + register.onChange(e); + }} + onBlur={register.onBlur} + ref={register.ref} + name={register.name} + /> + +
+ ); + })} +
+ + {/* "Other" free text input — only rendered when "Other" is checked. + Uses the dynamic-section-fields class for the fade-in animation. + Registered as `other_{fieldName}` and required when visible. */} + {hasOtherOption && otherChecked && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/site/src/components/forms/fields/RadioField.tsx b/apps/site/src/components/forms/fields/RadioField.tsx new file mode 100644 index 0000000..c7a05ed --- /dev/null +++ b/apps/site/src/components/forms/fields/RadioField.tsx @@ -0,0 +1,171 @@ +/** + * RadioField + * + * Renders a USWDS radio button group for single-select fields. + * + * Used for fields where the user must pick exactly one option from a + * predefined list — for example, Publication Status (Published, PrePrint, Other). + * + * Options source: + * Options are passed as a string array prop. They come from either: + * - Inline choices defined on the Freshdesk custom object schema + * (returned by getCustomObjectSchema and mapped to string values) + * - A string array populated at build time by the Astro page from + * a separate data source and merged into the field config + * Options are never fetched client-side. + * + * "Other" free text: + * If "Other" appears in the options array (case-insensitive), selecting it + * reveals a text input below the radio group. The text input is registered + * as `other_{fieldName}` and is required when "Other" is selected. + * Selecting any other option hides and clears the text input via + * useFormContext's unregister — so stale values are never included in + * the payload. + * + * Uses raw USWDS CSS classes, consistent with the project pattern of using + * Trussworks only when a component requires complex JS behavior. + */ + +import { useState } from 'react'; +import type { FieldError, UseFormRegister } from 'react-hook-form'; +import { useFormContext } from 'react-hook-form'; +import { fieldErrors } from '../util/errorMessages'; +import TextField from './TextField'; + +interface RadioFieldProps { + // The field name — used as the HTML input name and RHF registration key. + // Also used to generate unique input IDs for each option and the + // "Other" free text field name: `other_{name}`. + name: string; + // The customer-facing label for the radio group. + label: string; + // Optional hint text shown below the label, above the radio options. + hint?: string; + // Whether at least one option must be selected before submitting. + required?: boolean; + // The available options for this radio group. + // Provided by the parent — either from inline schema choices or a + // separate data source merged into the field config at build time. + // Each string is both the display label and the submitted value. + // If "Other" is present (case-insensitive), a free text input is shown + // when it is selected. + options: string[]; + // React Hook Form's register function, bound to this field by the parent. + register: ReturnType>>; + // The React Hook Form error for this field, if any. + error?: FieldError; +} + +export default function RadioField({ + name, + label, + hint, + required = false, + options, + register, + error, +}: RadioFieldProps) { + const hintId = hint ? `${name}-hint` : undefined; + const errorId = error ? `${name}-error` : undefined; + const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined; + + // Track whether "Other" is currently selected so we can show/hide + // the free text input. Local state — doesn't need to live in RHF. + const [otherSelected, setOtherSelected] = useState(false); + + // Access unregister from form context — available because DynamicForm + // and DynamicCustomObjectForm wrap all fields in FormProvider. + const { + register: contextRegister, + unregister, + formState: { errors: contextErrors }, + } = useFormContext>(); + + const otherFieldName = `other_${name}`; + const otherError = contextErrors[otherFieldName] as FieldError | undefined; + + // Detect if "Other" is one of the options (case-insensitive). + const hasOtherOption = options.some((o) => o.toLowerCase() === 'other'); + + return ( +
+
+ + {label} + {required && ( + + {' '} + * + + )} + + + {hint && ( + + {hint} + + )} + + {error && ( + + {error.message} + + )} + + {options.map((option) => { + const isOtherOption = option.toLowerCase() === 'other'; + const optionId = `${name}-${option.toLowerCase().replace(/\s+/g, '-')}`; + + return ( +
+ { + // For the "Other" option, track selected state so we can + // show/hide the free text input. When another option is + // selected, unregister the text field to clear its value. + if (isOtherOption) { + setOtherSelected(true); + } else { + if (otherSelected) { + setOtherSelected(false); + unregister(otherFieldName, { keepValue: false }); + } + } + // Call the original register onChange to keep RHF in sync + register.onChange(e); + }} + onBlur={register.onBlur} + ref={register.ref} + name={register.name} + /> + +
+ ); + })} +
+ + {/* "Other" free text input — only rendered when "Other" is selected. + Uses the dynamic-section-fields class for the fade-in animation. + Registered as `other_{fieldName}` and required when visible. */} + {hasOtherOption && otherSelected && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/site/src/components/forms/fields/SelectField.tsx b/apps/site/src/components/forms/fields/SelectField.tsx new file mode 100644 index 0000000..ffc3bca --- /dev/null +++ b/apps/site/src/components/forms/fields/SelectField.tsx @@ -0,0 +1,192 @@ +/** + * SelectField + * + * Renders a USWDS single-select dropdown for Freshdesk field types: + * - custom_dropdown + * - default_ticket_type + * + * Choices are provided via the field config. For ticket forms, they are fetched + * at build time via the Freshdesk per-field endpoint + * (GET /api/v2/ticket-forms/{id}/fields/{field_id}). For custom object forms, + * they are included directly in the schema response. + * + * Choice rendering: + * - Uses `value` as the option value submitted to Freshdesk + * - Uses `label` as display text, falling back to `value` if label is empty + * (Freshdesk test/draft forms sometimes return empty label strings) + * - A blank default option is always prepended so the field starts unselected, + * consistent with USWDS select behavior and required field validation + * + * "Other" free text: + * If a choice with value "Other" exists (case-insensitive), selecting it + * reveals a text input below the dropdown. The text input is registered + * as `other_{fieldName}` and is required when "Other" is selected. + * Selecting any other option hides and clears the text input via + * useFormContext's unregister — so stale values are never included in + * the payload. + * + * Dynamic sections: + * When a dropdown field has sections (has_section: true), selecting an option + * may reveal additional fields. The optional onSectionChange callback is called + * whenever the selected value changes, allowing DynamicForm to track which + * sections should be visible. SelectField itself has no knowledge of sections — + * it just reports its value upward via the callback. + * + * Uses raw USWDS CSS classes, consistent with the project pattern of using + * Trussworks only when a component requires complex JS behavior. + */ + +import { useState } from 'react'; +import type { FieldError, UseFormRegister } from 'react-hook-form'; +import { useFormContext } from 'react-hook-form'; +import type { FreshdeskChoice } from '../../../util/freshdesk/types'; +import { fieldErrors } from '../util/errorMessages'; +import TextField from './TextField'; + +interface SelectFieldProps { + // The Freshdesk field name (e.g. "ticket_type", "cf_assistance_type"). + // Used as the HTML select name and React Hook Form registration key. + // Also used to generate the "Other" free text field name: `other_{name}`. + name: string; + // The customer-facing label from label_for_customers. + label: string; + // Optional hint text from hint_for_customers. + // Rendered between the label and the select per USWDS and UX spec. + hint?: string; + // Whether the field is required — derived from required_for_customers. + required?: boolean; + // The available choices for this dropdown. + // Never undefined for dropdown fields in practice — the caller + // logs a warning if choices are missing. + choices?: FreshdeskChoice[]; + // React Hook Form's register function, bound to this field by the parent. + register: ReturnType>>; + // The React Hook Form error for this field, if any. + error?: FieldError; + // Optional callback fired when the selected value changes. + // Used by renderField to track section visibility when this dropdown + // has dynamic sections attached. Not needed for dropdowns without sections. + onSectionChange?: (value: string) => void; +} + +export default function SelectField({ + name, + label, + hint, + required = false, + choices = [], + register, + error, + onSectionChange, +}: SelectFieldProps) { + const hintId = hint ? `${name}-hint` : undefined; + const errorId = error ? `${name}-error` : undefined; + const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined; + + // Track whether "Other" is currently selected so we can show/hide + // the free text input. Local state — doesn't need to live in RHF. + const [otherSelected, setOtherSelected] = useState(false); + + // Access unregister from form context — available because DynamicForm + // and DynamicCustomObjectForm wrap all fields in FormProvider. + const { + register: contextRegister, + unregister, + formState: { errors: contextErrors }, + } = useFormContext>(); + + const otherFieldName = `other_${name}`; + const otherError = contextErrors[otherFieldName] as FieldError | undefined; + + // Detect if "Other" is one of the choices (case-insensitive). + const hasOtherChoice = choices.some((c) => c.value.toLowerCase() === 'other'); + + // Destructure onChange from register so we can call both RHF's handler + // and our section change callback without losing RHF's change tracking. + // restRegister contains the remaining register properties (onBlur, ref, name) + // which are spread onto the { + const value = e.target.value; + const isOther = value.toLowerCase() === 'other'; + + // Track "Other" selection for free text visibility. + // When switching away from "Other", unregister the text field + // to clear its value from the payload. + if (isOther) { + setOtherSelected(true); + } else if (otherSelected) { + setOtherSelected(false); + unregister(otherFieldName, { keepValue: false }); + } + + // Call RHF's onChange first to keep form state in sync, + // then notify the parent about the new value for section tracking. + onChange(e); + onSectionChange?.(value); + }} + {...restRegister} + > + {/* Blank default option — ensures the field starts unselected. + Required fields will fail validation if this remains selected, + since its value is an empty string. */} + + + {choices.map((choice) => ( + + ))} + + + {/* "Other" free text input — only rendered when "Other" is selected. + Uses the dynamic-section-fields class for the fade-in animation. + Registered as `other_{fieldName}` and required when visible. */} + {hasOtherChoice && otherSelected && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/site/src/components/forms/fields/TextField.tsx b/apps/site/src/components/forms/fields/TextField.tsx new file mode 100644 index 0000000..083d3ae --- /dev/null +++ b/apps/site/src/components/forms/fields/TextField.tsx @@ -0,0 +1,117 @@ +/** + * TextField + * + * Renders a USWDS single-line text input for Freshdesk field types: + * - custom_text — plain text input + * - default_requester — email input (pass inputType="email") + * - default_company — plain text input + * - custom_number — integer input (pass inputType="number") + * - custom_decimal — decimal input (pass inputType="decimal") + * - custom_url — URL input (pass inputType="url") + * + * All numeric and URL variants reuse this component via the inputType prop + * rather than having separate components — the HTML element and USWDS styling + * are identical. The only differences are input type, step attribute (for + * decimals), and validation rules, which are handled by DynamicForm's + * renderField when registering with React Hook Form. + * + * Styling differences between variants (e.g. unit labels for numbers, + * prefix icons for URLs) can be added later without changing this component's + * core structure. + * + * Uses raw USWDS CSS classes rather than Trussworks, consistent with the + * project pattern of using Trussworks only when a component requires + * complex JS behavior (see DateField for the one exception). + * + * Registered directly with React Hook Form via the `register` prop, + * so the parent DynamicForm controls all form state. + */ + +import type { FieldError, UseFormRegister } from 'react-hook-form'; + +// The full set of input types this component supports. +// 'decimal' is a semantic alias for type="number" with step="any" — +// it's not a native HTML input type but used internally to distinguish +// custom_decimal from custom_number in the step attribute logic below. +type TextFieldInputType = 'text' | 'email' | 'number' | 'decimal' | 'url'; + +interface TextFieldProps { + // The Freshdesk field name (e.g. "requester", "cf_journal_name"). + // Used as the HTML input name and React Hook Form registration key. + name: string; + // The customer-facing label from label_for_customers. + label: string; + // Optional hint text from hint_for_customers. + // Rendered between the label and the input per USWDS and UX spec. + hint?: string; + // Whether the field is required — derived from required_for_customers. + required?: boolean; + // Input type — defaults to "text". + // Pass "email" for default_requester, "number" for custom_number, + // "decimal" for custom_decimal, "url" for custom_url. + inputType?: TextFieldInputType; + // React Hook Form's register function, bound to this field by the parent. + register: ReturnType>>; + // The React Hook Form error for this field, if any. + error?: FieldError; +} + +export default function TextField({ + name, + label, + hint, + required = false, + inputType = 'text', + register, + error, +}: TextFieldProps) { + const hintId = hint ? `${name}-hint` : undefined; + const errorId = error ? `${name}-error` : undefined; + + // aria-describedby wires the input to both hint and error text for screen readers. + // Both may be present simultaneously — hint is always shown, error only on failure. + const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined; + + // 'decimal' is an internal alias — the HTML input type is always 'number'. + // We use it to set step="any", which allows decimal values. + // custom_number uses step="1" (integers only) by default. + const htmlType = inputType === 'decimal' ? 'number' : inputType; + const step = + inputType === 'decimal' ? 'any' : inputType === 'number' ? '1' : undefined; + + return ( +
+ + + {hint && ( + + {hint} + + )} + + {error && ( + + {error.message} + + )} + + +
+ ); +} diff --git a/apps/site/src/components/forms/fields/TextareaField.tsx b/apps/site/src/components/forms/fields/TextareaField.tsx new file mode 100644 index 0000000..2bf0917 --- /dev/null +++ b/apps/site/src/components/forms/fields/TextareaField.tsx @@ -0,0 +1,106 @@ +/** + * TextareaField + * + * Renders a USWDS multi-line textarea for Freshdesk field types: + * - custom_paragraph + * - default_description + * + * Follows the same pattern as TextField — raw USWDS classes, + * registered with React Hook Form via the parent's register function. + * + * The UX spec notes that long text fields may have a character limit + * with a visible counter. The optional maxLength prop enables this — + * when provided, USWDS's character count behavior is activated via + * the data-character-count attribute pattern. This requires the USWDS + * JS bundle to be loaded globally (already handled in apps/site layout). + */ + +import type { FieldError, UseFormRegister } from 'react-hook-form'; + +interface TextareaFieldProps { + name: string; + label: string; + hint?: string; + required?: boolean; + // Optional character limit. When provided, renders a character count + // indicator below the textarea using the USWDS character-count pattern. + maxLength?: number; + register: ReturnType>>; + error?: FieldError; +} + +export default function TextareaField({ + name, + label, + hint, + required = false, + maxLength, + register, + error, +}: TextareaFieldProps) { + const hintId = hint ? `${name}-hint` : undefined; + const errorId = error ? `${name}-error` : undefined; + const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined; + + // When maxLength is set, USWDS expects the textarea to be wrapped in a + // div with data-character-count pointing to the input's id, and a + // separate status element with id="${name}-info" for the counter. + const textarea = ( +