mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
co-authored by
xiaoche-hub
parent
30501a2f12
commit
7c8c7b79f3
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user