Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/(main)/(auth)/positions/[id]/apply/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
APPLICATION_STATUS_LABELS,
UNRESOLVED_APPLICATION_STATUSES,
} from '@/lib/constants';
import { formatDate, isError, toStringArray } from '@/lib/utils';
import { formatDate, isAnswered, isError, toStringArray } from '@/lib/utils';

import { ApplicationStepper } from '@/components/features/application-stepper';
import { ApplicationStatusBadge } from '@/components/features/status-badge';
Expand Down Expand Up @@ -52,12 +52,12 @@ export default async function ApplyPage({ params }: ApplyPageProps) {
d.answer ? [d.answer] : [],
);

// Gate must match submitApplication: requires non-empty value, not just a record existing.
// Gate must match submitApplication: value must still fit the question's shape.
const profileComplete =
profileData.length === 0 ||
profileData
.filter((d) => d.question.required)
.every((d) => toStringArray(d.answer?.value).length > 0);
.every((d) => isAnswered(d.question, toStringArray(d.answer?.value)));

// An existing draft bypasses this gate — it only protects creating one.
const applicationResult =
Expand Down
54 changes: 54 additions & 0 deletions components/features/answer-mismatch-notice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { TriangleAlert } from 'lucide-react';

import type { QuestionType } from '@/prisma/client';

interface AnswerMismatchNoticeProps {
id: string;
values: string[];
questionType: QuestionType;
}

// Callers wire `id` to the control's `aria-describedby`.
export function AnswerMismatchNotice({
id,
values,
questionType,
}: AnswerMismatchNoticeProps) {
if (values.length === 0) return null;

return (
<div
id={id}
className="border-warning/40 bg-warning/10 text-warning-foreground mb-2 flex gap-2 rounded-lg border p-3 text-sm"
>
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<div className="flex flex-col gap-1">
<p className="font-medium">This question has changed</p>
<p>
Your previous answer no longer matches the available choices.
It&apos;s saved below until you answer again.
</p>
{questionType === 'multiple_choice' ? (
<div className="mt-1 flex flex-wrap gap-1.5">
{values.map((v, i) => (
<span
key={i}
className="bg-warning/20 rounded-md px-2 py-0.5 text-xs font-medium"
>
{v}
</span>
))}
</div>
) : (
<ul className="mt-1 flex flex-col gap-0.5">
{values.map((v, i) => (
<li key={i} className="font-medium">
{v}
</li>
))}
</ul>
)}
</div>
</div>
);
}
90 changes: 52 additions & 38 deletions components/features/application-question.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,23 @@ import { useRef, useState } from 'react';

import { toast } from 'sonner';

import type { QuestionType, ShortAnswerFormat } from '@/prisma/client';

import { OTHER_OPTION_LABEL, matchesShortAnswerFormat } from '@/lib/constants';
import type { QuestionFileTarget } from '@/lib/types';
import { cn } from '@/lib/utils';
import {
FORMAT_INPUT_TYPES,
OTHER_OPTION_LABEL,
matchesShortAnswerFormat,
} from '@/lib/constants';
import type { AnswerQuestion, QuestionFileTarget } from '@/lib/types';
import { cn, partitionAnswerValue } from '@/lib/utils';

import { AnswerMismatchNotice } from '@/components/features/answer-mismatch-notice';
import { QuestionFileField } from '@/components/features/question-file-field';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';

type QuestionShape = {
id: string;
label: string;
type: QuestionType;
required: boolean;
options: string[];
allowOther: boolean;
format: ShortAnswerFormat | null;
};

// For the mobile keyboard only; the format regex remains the validation.
const FORMAT_INPUT_TYPES: Record<ShortAnswerFormat, string> = {
email: 'email',
phone_number: 'tel',
url: 'url',
zip_code: 'text',
};

interface ApplicationQuestionProps {
question: QuestionShape;
question: AnswerQuestion;
field: {
value: string[];
onChange: (value: string[]) => void;
Expand All @@ -57,13 +42,19 @@ export function ApplicationQuestion({
}: ApplicationQuestionProps) {
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState(false);
// Raw value, never re-seeded — an untouched blur must write nothing.
const savedValueRef = useRef(JSON.stringify(field.value));
const options = Array.isArray(question.options)
? question.options.filter((o): o is string => typeof o === 'string')
: [];
const { fitted, orphaned } = partitionAnswerValue(question, field.value);
const noticeId = `${question.id}-mismatch`;
const labelId = `${question.id}-label`;

// options is a closed set, so any value outside it is the applicant's "Other" text.
const initialOtherValue = field.value.find((v) => !options.includes(v));
// Gated on allowOther — a turned-off option can't masquerade as "Other".
const initialOtherValue = question.allowOther
? fitted.find((v) => !options.includes(v))
: undefined;
const [otherSelected, setOtherSelected] = useState(
initialOtherValue !== undefined,
);
Expand Down Expand Up @@ -111,37 +102,55 @@ export function ApplicationQuestion({
error && 'border-destructive',
)}
>
<p className="text-muted-foreground mb-2 text-xs font-semibold tracking-wide uppercase">
<p
id={labelId}
className="text-muted-foreground mb-2 text-xs font-semibold tracking-wide uppercase"
>
{question.label}
{question.required && <span className="text-destructive ml-1">*</span>}
</p>

{orphaned.length > 0 && (
<AnswerMismatchNotice
id={noticeId}
values={orphaned}
questionType={question.type}
/>
)}

{question.type === 'short_answer' && (
<Input
type={question.format ? FORMAT_INPUT_TYPES[question.format] : 'text'}
Comment thread
b-at-neu marked this conversation as resolved.
value={field.value[0] ?? ''}
value={fitted[0] ?? ''}
onChange={(e) =>
field.onChange(e.target.value ? [e.target.value] : [])
}
onBlur={handleBlur}
placeholder="Your answer"
aria-describedby={orphaned.length > 0 ? noticeId : undefined}
/>
)}

{question.type === 'long_answer' && (
<Textarea
value={field.value[0] ?? ''}
value={fitted[0] ?? ''}
onChange={(e) =>
field.onChange(e.target.value ? [e.target.value] : [])
}
onBlur={handleBlur}
placeholder="Your answer"
className="min-h-[120px]"
aria-describedby={orphaned.length > 0 ? noticeId : undefined}
/>
)}

{question.type === 'single_choice' && (
<div className="flex flex-col gap-2">
<div
role="group"
aria-labelledby={labelId}
aria-describedby={orphaned.length > 0 ? noticeId : undefined}
className="flex flex-col gap-2"
>
{options.map((option) => (
<Label
key={option}
Expand All @@ -151,7 +160,7 @@ export function ApplicationQuestion({
type="radio"
name={question.id}
value={option}
checked={!otherSelected && field.value[0] === option}
checked={!otherSelected && fitted[0] === option}
onChange={() => {
// Clearing the typed text stops it being silently resubmitted.
setOtherSelected(false);
Expand Down Expand Up @@ -213,18 +222,23 @@ export function ApplicationQuestion({
)}

{question.type === 'multiple_choice' && (
<div className="flex flex-col gap-2">
<div
role="group"
aria-labelledby={labelId}
aria-describedby={orphaned.length > 0 ? noticeId : undefined}
className="flex flex-col gap-2"
>
{options.map((option) => (
<Label
key={option}
className="flex cursor-pointer items-center gap-2 font-normal"
>
<Checkbox
checked={field.value.includes(option)}
checked={fitted.includes(option)}
onCheckedChange={(checked) => {
const next = checked
? [...field.value, option]
: field.value.filter((v) => v !== option);
? [...fitted, option]
: fitted.filter((v) => v !== option);
field.onChange(next);
save(next);
}}
Expand All @@ -240,7 +254,7 @@ export function ApplicationQuestion({
checked={otherSelected}
onCheckedChange={(checked) => {
setOtherSelected(!!checked);
const checkedOptions = field.value.filter((v) =>
const checkedOptions = fitted.filter((v) =>
options.includes(v),
);
if (checked) {
Expand Down Expand Up @@ -276,7 +290,7 @@ export function ApplicationQuestion({
value={otherText}
onChange={(e) => {
setOtherText(e.target.value);
const checkedOptions = field.value.filter((v) =>
const checkedOptions = fitted.filter((v) =>
options.includes(v),
);
field.onChange(
Expand All @@ -298,7 +312,7 @@ export function ApplicationQuestion({
{question.type === 'file_upload' && (
<QuestionFileField
target={fileTarget}
value={field.value}
value={fitted}
onChange={field.onChange}
/>
)}
Expand Down
58 changes: 37 additions & 21 deletions components/features/application-stepper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,31 +24,30 @@ import {
matchesShortAnswerFormat,
} from '@/lib/constants';
import {
type AnswerQuestion,
type DraftApplication,
type PositionWithQuestions,
type QuestionFileTarget,
} from '@/lib/types';
import { type ErrorType, cn, isError, toStringArray } from '@/lib/utils';
import {
type ErrorType,
cn,
isAnswered,
isError,
partitionAnswerValue,
toStringArray,
} from '@/lib/utils';

import { AnswerFileLink } from '@/components/features/answer-file-link';
import { AnswerMismatchNotice } from '@/components/features/answer-mismatch-notice';
import { ApplicationQuestion } from '@/components/features/application-question';
import { Button } from '@/components/ui/button';

type StepperFormValues = Record<string, string[]>;

type NarrowQuestion = {
id: string;
label: string;
type: GlobalQuestion['type'];
required: boolean;
options: string[];
allowOther: boolean;
format: GlobalQuestion['format'];
};

interface QuestionListProps {
applicationId: string;
questions: NarrowQuestion[];
questions: AnswerQuestion[];
control: Control<StepperFormValues>;
isGlobal: boolean;
readOnly?: boolean;
Expand All @@ -64,10 +63,13 @@ function ReadOnlyQuestionCard({
displayValue,
isMissing,
}: {
question: NarrowQuestion;
question: AnswerQuestion;
displayValue: string[];
isMissing?: boolean;
}) {
// Read-only — full stored value renders as-is; the notice just flags the mismatch.
const { orphaned } = partitionAnswerValue(question, displayValue);

return (
<div
className={cn(
Expand All @@ -79,6 +81,13 @@ function ReadOnlyQuestionCard({
{question.label}
{question.required && <span className="text-destructive ml-1">*</span>}
</p>
{orphaned.length > 0 && (
<AnswerMismatchNotice
id={`${question.id}-mismatch`}
values={orphaned}
questionType={question.type}
/>
)}
{displayValue.length === 0 ? (
<p className="text-muted-foreground text-sm italic">No answer yet</p>
) : question.type === 'file_upload' ? (
Expand Down Expand Up @@ -161,16 +170,22 @@ function QuestionList({
? undefined
: {
validate: (value) => {
if (
question.required &&
!(Array.isArray(value) && value.length > 0)
)
return 'This field is required';
const arr = toStringArray(value);
if (question.required && !isAnswered(question, arr)) {
// Distinguish never-answered from answered-but-no-longer-fits.
const { orphaned } = partitionAnswerValue(
question,
arr,
);
return orphaned.length > 0
? 'Please answer this question again'
: 'This field is required';
}
if (
question.type === 'short_answer' &&
question.format &&
value[0] &&
!matchesShortAnswerFormat(value[0], question.format)
arr[0] &&
!matchesShortAnswerFormat(arr[0], question.format)
)
return SHORT_ANSWER_FORMAT_ERROR_MESSAGES[
question.format
Expand Down Expand Up @@ -329,7 +344,8 @@ export function ApplicationStepper({
const missing = new Set(
globalQuestions
.filter(
(q) => q.required && toStringArray(values[`g_${q.id}`]).length === 0,
(q) =>
q.required && !isAnswered(q, toStringArray(values[`g_${q.id}`])),
)
.map((q) => q.id),
);
Expand Down
Loading
Loading