fix(web): allow skipping required chat questions (#6177)

* fix(web): allow skipping required chat questions

* fix(web): auto-skip unanswered chat questions

---------

Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
This commit is contained in:
open-design-crew[bot]
2026-07-29 08:14:06 +00:00
committed by GitHub
co-authored by xiaoche-hub
parent 30501a2f12
commit 7c8c7b79f3
2 changed files with 57 additions and 55 deletions
+27 -28
View File
@@ -85,8 +85,8 @@ interface Props {
) => void;
submitDisabled?: boolean;
visualStyleContext?: VisualStyleContext;
// Optional paths can move on after the timeout. A required answer never
// becomes a skipped answer merely because the form was left unattended.
// When enabled, the form moves on after the timeout. Any unanswered field,
// including a required one, is submitted as "(skipped)".
autoContinueAfterTimeout?: boolean;
}
@@ -99,8 +99,9 @@ export interface QuestionFormFileSubmission {
// Lets an embedding host trigger submission.
export interface QuestionFormHandle {
submit: () => void;
// Submit with no answers — backs the "skip all" affordance. Every question
// is optional, so this just records each as "(skipped)" and moves on.
// Submit with no answers — backs the "skip all" affordance. This is an
// explicit user decision, so it records every question as "(skipped)" and
// moves on even when the normal form path marks a question required.
skipAll: () => void;
}
@@ -350,13 +351,13 @@ export const QuestionFormView = forwardRef<QuestionFormHandle, Props>(function Q
}
function handleSkipAll() {
if (locked || !onSubmit || !canSkipAll) return;
if (locked || !onSubmit) return;
const empty: Record<string, string | string[]> = {};
onSubmit(formatFormAnswers(formWithVisualStyleOptions(form, visualStyleContext), empty), empty, 'skip');
}
function handleSkipCurrent() {
if (locked || !onSubmit || !activeQuestion || activeQuestion.required === true) return;
if (locked || !onSubmit || !activeQuestion) return;
onInteraction?.({
element: 'step_skip',
questionId: activeQuestion.id,
@@ -401,29 +402,29 @@ export const QuestionFormView = forwardRef<QuestionFormHandle, Props>(function Q
const v = currentAnswers[q.id];
return !Array.isArray(v) || v.length <= q.maxSelections;
});
// Required questions must carry a non-empty answer. Without this, main-path forms (the discovery router's
// required taskType/output, the ElevenLabs voice picker) would accept an
// empty submit and serialize "(skipped)" for fields the rest of the system
// treats as mandatory.
// Required questions must carry a non-empty answer on the normal path. An
// explicit per-question Skip is a deliberate alternative: it serializes the
// value as "(skipped)" and lets the agent proceed with sensible defaults.
const requiredAnswered = form.questions.every((q) => {
if (q.required !== true) return true;
if (skippedQuestionIds.has(q.id)) return true;
const v = currentAnswers[q.id];
return questionAnswerIsPresent(v);
});
const ready = withinSelectionLimits && requiredAnswered;
const canSkipAll = form.questions.every((q) => q.required !== true);
// Required answers remain required after a timeout. A flat form may only
// auto-continue once every required answer is present; a stepped form can
// auto-continue on an optional active step after its earlier requirements
// have been met. Fully optional forms retain the countdown throughout.
// A manual Skip all is always available, including for required questions.
const canSkipAll = true;
const hasRequiredQuestions = form.questions.some((q) => q.required === true);
// Timeout continuation shares the explicit Skip semantics: unanswered
// questions, including required ones, are serialized as "(skipped)".
const autoContinueEnabled =
autoContinueAfterTimeout &&
!locked &&
!submitDisabled &&
(canSkipAll || (ready && (!stepped || activeQuestion?.required !== true)));
!submitDisabled;
const currentQuestionReady =
!activeQuestion ||
activeQuestion.required !== true ||
skippedQuestionIds.has(activeQuestion.id) ||
questionAnswerIsPresent(currentAnswers[activeQuestion.id]);
const autoContinueCountdown = `${Math.floor(autoContinueRemaining / 60)}:${String(
autoContinueRemaining % 60,
@@ -809,16 +810,14 @@ export const QuestionFormView = forwardRef<QuestionFormHandle, Props>(function Q
{autoContinueCountdown}
</span>
) : null}
{activeQuestion?.required === true ? null : (
<Button
type="button"
variant="ghost"
onClick={handleSkipCurrent}
disabled={submitDisabled}
>
{t('questionForm.skip')}
</Button>
)}
<Button
type="button"
variant="ghost"
onClick={handleSkipCurrent}
disabled={submitDisabled}
>
{t('questionForm.skip')}
</Button>
<span className="qf-submit-actions">
<Button
type="button"
@@ -853,7 +852,7 @@ export const QuestionFormView = forwardRef<QuestionFormHandle, Props>(function Q
</Button>
</span>
</>
) : autoContinueEnabled || canSkipAll ? (
) : autoContinueEnabled || !hasRequiredQuestions ? (
<span
className={autoContinueEnabled ? 'qf-auto-continue' : 'qf-hint'}
title={autoContinueEnabled ? t('questions.autoSkipHint') : undefined}
+30 -27
View File
@@ -614,16 +614,20 @@ describe('QuestionFormView', () => {
);
});
it('does not offer Skip all when a form contains required questions', () => {
it('offers Skip all when a single-question form contains required questions', () => {
const onSubmit = vi.fn();
render(<QuestionFormView form={richForm} interactive onSubmit={onSubmit} />);
expect((screen.getByRole('button', { name: 'Send answers' }) as HTMLButtonElement).disabled).toBe(
true,
);
expect(screen.queryByRole('button', { name: 'Skip all' })).toBeNull();
expect(screen.queryByText('Pick what fits. Skip optional fields you don\'t care about — the agent will use sensible defaults.')).toBeNull();
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Skip all' }));
expect(onSubmit).toHaveBeenCalledWith(
expect.stringContaining('- Primary surface: (skipped)'),
{},
'skip',
);
});
it('keeps Skip all for a form containing only optional questions', () => {
@@ -690,7 +694,7 @@ describe('QuestionFormView', () => {
);
});
it('only auto-continues when no required answer is missing', () => {
it('auto-continues unanswered required questions as skipped', () => {
vi.useFakeTimers();
try {
const optionalSubmit = vi.fn();
@@ -726,9 +730,13 @@ describe('QuestionFormView', () => {
onSubmit={requiredSubmit}
/>,
);
expect(screen.queryByLabelText(/Auto-continues when the timer ends 10:00/)).toBeNull();
expect(screen.getByLabelText(/Auto-continues when the timer ends 10:00/)).toBeTruthy();
act(() => vi.advanceTimersByTime(10 * 60 * 1000));
expect(requiredSubmit).not.toHaveBeenCalled();
expect(requiredSubmit).toHaveBeenCalledWith(
expect.stringContaining('- Primary surface: (skipped)'),
{ platform: '' },
'auto',
);
} finally {
vi.useRealTimers();
}
@@ -748,7 +756,7 @@ describe('QuestionFormView', () => {
);
expect(screen.getByText('1 / 3').closest('.question-form-head')).toBeTruthy();
expect(screen.queryByLabelText(/Auto-continues when the timer ends 10:00/)).toBeNull();
expect(screen.getByLabelText(/Auto-continues when the timer ends 10:00/)).toBeTruthy();
expect(screen.getByText('Who will see this deck?')).toBeTruthy();
expect(screen.queryByText('How detailed should it be?')).toBeNull();
const nextStep = screen.getByRole('button', { name: 'Next step' }) as HTMLButtonElement;
@@ -796,7 +804,7 @@ describe('QuestionFormView', () => {
);
});
it('offers Skip only on optional steps', () => {
it('offers Skip on every step and completes after skipping a required answer', () => {
const onSubmit = vi.fn();
const onInteraction = vi.fn();
render(
@@ -808,39 +816,34 @@ describe('QuestionFormView', () => {
/>,
);
expect(screen.queryByRole('button', { name: 'Skip' })).toBeNull();
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'Leadership and product team' },
});
fireEvent.click(screen.getByRole('button', { name: 'Next step' }));
fireEvent.click(screen.getByRole('button', { name: 'Skip' }));
expect(onInteraction).toHaveBeenCalledWith({
element: 'step_next',
element: 'step_skip',
questionId: 'audience',
stepIndex: 1,
stepCount: 3,
});
expect(screen.getByText('2 / 3')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Skip' })).toBeNull();
expect(screen.getByRole('button', { name: 'Skip' })).toBeTruthy();
fireEvent.click(screen.getByLabelText('Concise · 8 slides'));
fireEvent.click(screen.getByRole('button', { name: 'Next step' }));
fireEvent.click(screen.getByRole('button', { name: 'Skip' }));
expect(onInteraction).toHaveBeenCalledWith({
element: 'step_skip',
questionId: 'constraints',
stepIndex: 3,
stepCount: 3,
});
expect(screen.getByText('3 / 3')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Skip' })).toBeTruthy();
expect((screen.getByRole('button', { name: 'Send answers' }) as HTMLButtonElement).disabled).toBe(
false,
);
fireEvent.click(screen.getByRole('button', { name: 'Send answers' }));
expect(onSubmit).toHaveBeenCalledWith(
expect.stringContaining('- Anything else to preserve?: (skipped)'),
expect.stringContaining('- Who will see this deck?: (skipped)'),
{
audience: 'Leadership and product team',
audience: '',
length: '8',
constraints: '',
},
'skip',
'submit',
);
});