Skip to content

Commit 99c1de1

Browse files
committed
feat: enhance InstructionsWizard to manage free text drafts and improve form submission handling
1 parent 5ce614c commit 99c1de1

File tree

4 files changed

+125
-25
lines changed

4 files changed

+125
-25
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,4 @@ pnpm-debug.log*
117117

118118
# vercel
119119
.vercel/
120+
test-results/

components/instructions-wizard.tsx

Lines changed: 116 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { WizardAnswerGrid } from "./wizard-answer-grid"
3030
import { WizardConfirmationDialog } from "./wizard-confirmation-dialog"
3131

3232
const suffixSteps = getSuffixSteps()
33+
const buildStepSignature = (step: WizardStep) => `${step.id}::${step.questions.map((question) => question.id).join("|")}`
3334

3435
export function InstructionsWizard({
3536
initialStackId,
@@ -46,6 +47,7 @@ export function InstructionsWizard({
4647
initialStackId ? { [STACK_QUESTION_ID]: initialStackId } : {}
4748
)
4849
const [freeTextResponses, setFreeTextResponses] = useState<FreeTextResponses>({})
50+
const [freeTextDrafts, setFreeTextDrafts] = useState<Record<string, string>>({})
4951
const [dynamicSteps, setDynamicSteps] = useState<WizardStep[]>(() =>
5052
initialStackStep ? [initialStackStep] : []
5153
)
@@ -65,6 +67,9 @@ export function InstructionsWizard({
6567
const hasAppliedInitialStack = useRef<string | null>(
6668
initialStackStep && initialStackId ? initialStackId : null
6769
)
70+
const lastAppliedStackStepSignature = useRef<string | null>(
71+
initialStackStep ? buildStepSignature(initialStackStep) : null
72+
)
6873
const [activeStackLabel, setActiveStackLabel] = useState<string | null>(initialStackLabel)
6974

7075
const wizardSteps = useMemo(() => [stacksStep, ...dynamicSteps, ...suffixSteps], [dynamicSteps])
@@ -87,20 +92,32 @@ export function InstructionsWizard({
8792
const filterInputId = currentQuestion ? `answer-filter-${currentQuestion.id}` : "answer-filter"
8893

8994
const currentAnswerValue = currentQuestion ? responses[currentQuestion.id] : undefined
90-
const currentFreeTextValue = useMemo(() => {
95+
const savedFreeTextValue = useMemo(() => {
9196
if (!currentQuestion) {
9297
return ""
9398
}
9499

95100
const value = freeTextResponses[currentQuestion.id]
96101
return typeof value === "string" ? value : ""
97102
}, [currentQuestion, freeTextResponses])
103+
104+
const draftFreeTextValue = useMemo(() => {
105+
if (!currentQuestion) {
106+
return ""
107+
}
108+
109+
const value = freeTextDrafts[currentQuestion.id]
110+
return typeof value === "string" ? value : ""
111+
}, [currentQuestion, freeTextDrafts])
112+
113+
const currentFreeTextValue =
114+
draftFreeTextValue.length > 0 ? draftFreeTextValue : savedFreeTextValue
98115
const freeTextConfig = currentQuestion?.freeText ?? null
99116
const freeTextInputId = currentQuestion ? `free-text-${currentQuestion.id}` : "free-text"
100117
const canSubmitFreeText = Boolean(freeTextConfig?.enabled && currentFreeTextValue.trim().length > 0)
101-
const hasSavedCustomFreeText = Boolean(freeTextConfig?.enabled && currentFreeTextValue.trim().length > 0)
118+
const hasSavedCustomFreeText = Boolean(freeTextConfig?.enabled && savedFreeTextValue.trim().length > 0)
102119

103-
const savedCustomFreeTextValue = currentFreeTextValue.trim()
120+
const savedCustomFreeTextValue = savedFreeTextValue.trim()
104121

105122
const defaultAnswer = useMemo(
106123
() => currentQuestion?.answers.find((answer) => answer.isDefault) ?? null,
@@ -190,25 +207,47 @@ export function InstructionsWizard({
190207
const applyStackStep = useCallback(
191208
(step: WizardStep, label: string | null, options?: { skipFastTrackPrompt?: boolean; stackId?: string }) => {
192209
const skipFastTrackPrompt = options?.skipFastTrackPrompt ?? false
193-
const nextStackId = options?.stackId
210+
const nextStackId = options?.stackId ?? null
211+
const stepSignature = buildStepSignature(step)
212+
const previousSignature = lastAppliedStackStepSignature.current
213+
const isSameStep = previousSignature === stepSignature
194214

195215
setActiveStackLabel(label)
196-
setDynamicSteps([step])
197216
setIsStackFastTrackPromptVisible(!skipFastTrackPrompt && step.questions.length > 0)
198217

218+
if (isSameStep) {
219+
if (nextStackId) {
220+
setResponses((prev) => {
221+
if (prev[STACK_QUESTION_ID] === nextStackId) {
222+
return prev
223+
}
224+
225+
return {
226+
...prev,
227+
[STACK_QUESTION_ID]: nextStackId,
228+
}
229+
})
230+
}
231+
232+
lastAppliedStackStepSignature.current = stepSignature
233+
return
234+
}
235+
236+
const questionIds = new Set(step.questions.map((question) => question.id))
237+
238+
setDynamicSteps([step])
239+
199240
setResponses((prev) => {
200241
const next: Responses = { ...prev }
201242

202243
if (nextStackId) {
203244
next[STACK_QUESTION_ID] = nextStackId
204245
}
205246

206-
step.questions.forEach((question) => {
207-
if (question.id === STACK_QUESTION_ID) {
208-
return
247+
questionIds.forEach((questionId) => {
248+
if (questionId !== STACK_QUESTION_ID && questionId in next) {
249+
delete next[questionId]
209250
}
210-
211-
delete next[question.id]
212251
})
213252

214253
return next
@@ -222,13 +261,27 @@ export function InstructionsWizard({
222261
let didMutate = false
223262
const next = { ...prev }
224263

225-
step.questions.forEach((question) => {
226-
if (question.id === STACK_QUESTION_ID) {
227-
return
264+
questionIds.forEach((questionId) => {
265+
if (questionId !== STACK_QUESTION_ID && questionId in next) {
266+
delete next[questionId]
267+
didMutate = true
228268
}
269+
})
270+
271+
return didMutate ? next : prev
272+
})
273+
274+
setFreeTextDrafts((prev) => {
275+
if (Object.keys(prev).length === 0) {
276+
return prev
277+
}
278+
279+
let didMutate = false
280+
const next = { ...prev }
229281

230-
if (next[question.id] !== undefined) {
231-
delete next[question.id]
282+
questionIds.forEach((questionId) => {
283+
if (questionId !== STACK_QUESTION_ID && questionId in next) {
284+
delete next[questionId]
232285
didMutate = true
233286
}
234287
})
@@ -239,6 +292,8 @@ export function InstructionsWizard({
239292
setCurrentStepIndex(1)
240293
setCurrentQuestionIndex(0)
241294
setAutoFilledQuestionMap({})
295+
296+
lastAppliedStackStepSignature.current = stepSignature
242297
},
243298
[]
244299
)
@@ -415,6 +470,7 @@ export function InstructionsWizard({
415470
})
416471

417472
setFreeTextResponses((prev) => (Object.keys(prev).length > 0 ? {} : prev))
473+
setFreeTextDrafts((prev) => (Object.keys(prev).length > 0 ? {} : prev))
418474

419475
markQuestionsAutoFilled(autoFilledIds)
420476
setIsStackFastTrackPromptVisible(false)
@@ -517,7 +573,7 @@ export function InstructionsWizard({
517573
const { value } = event.target
518574
let didChange = false
519575

520-
setFreeTextResponses((prev) => {
576+
setFreeTextDrafts((prev) => {
521577
const existing = prev[question.id]
522578

523579
if (value.length === 0) {
@@ -569,7 +625,9 @@ export function InstructionsWizard({
569625
) => {
570626
const allowAutoAdvance = options?.allowAutoAdvance ?? true
571627
const trimmedValue = rawValue.trim()
572-
const existingValue = typeof freeTextResponses[question.id] === "string" ? freeTextResponses[question.id] : ""
628+
const draftValue = typeof freeTextDrafts[question.id] === "string" ? freeTextDrafts[question.id] : ""
629+
const savedValue = typeof freeTextResponses[question.id] === "string" ? freeTextResponses[question.id] : ""
630+
const existingValue = savedValue
573631

574632
if (trimmedValue === existingValue) {
575633
if (allowAutoAdvance && trimmedValue.length > 0 && !hasSelectionForQuestion(question)) {
@@ -578,6 +636,18 @@ export function InstructionsWizard({
578636
}, 0)
579637
}
580638

639+
if (draftValue.length > 0) {
640+
setFreeTextDrafts((prev) => {
641+
if (!(question.id in prev)) {
642+
return prev
643+
}
644+
645+
const next = { ...prev }
646+
delete next[question.id]
647+
return next
648+
})
649+
}
650+
581651
return
582652
}
583653

@@ -598,6 +668,26 @@ export function InstructionsWizard({
598668
}
599669
})
600670

671+
setFreeTextDrafts((prev) => {
672+
if (trimmedValue.length === 0) {
673+
if (!(question.id in prev)) {
674+
return prev
675+
}
676+
677+
const next = { ...prev }
678+
delete next[question.id]
679+
return next
680+
}
681+
682+
if (!(question.id in prev)) {
683+
return prev
684+
}
685+
686+
const next = { ...prev }
687+
delete next[question.id]
688+
return next
689+
})
690+
601691
clearAutoFilledFlag(question.id)
602692

603693
if (allowAutoAdvance && trimmedValue.length > 0 && !hasSelectionForQuestion(question)) {
@@ -660,6 +750,7 @@ export function InstructionsWizard({
660750
const stackIdToClear = selectedStackId
661751
setResponses({})
662752
setFreeTextResponses({})
753+
setFreeTextDrafts({})
663754
setDynamicSteps([])
664755
setCurrentStepIndex(0)
665756
setCurrentQuestionIndex(0)
@@ -856,6 +947,7 @@ export function InstructionsWizard({
856947
<form
857948
className="flex flex-col gap-2 sm:flex-row sm:items-center"
858949
onSubmit={handleFreeTextSubmit}
950+
data-testid="wizard-free-text-form"
859951
>
860952
<Input
861953
id={freeTextInputId}
@@ -866,7 +958,13 @@ export function InstructionsWizard({
866958
autoComplete="off"
867959
/>
868960
<div className="flex gap-2">
869-
<Button type="submit" size="sm" disabled={!canSubmitFreeText}>
961+
<Button
962+
type="submit"
963+
size="sm"
964+
disabled={!canSubmitFreeText}
965+
data-can-submit={canSubmitFreeText ? "true" : "false"}
966+
data-free-text-length={currentFreeTextValue.length}
967+
>
870968
Save custom answer
871969
</Button>
872970
{currentFreeTextValue.length > 0 ? (

playwright/tests/wizard-free-text.spec.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,14 @@ test("wizard accepts custom free text answers and shows them in the summary", as
2929

3030
await expect(customInput).toBeVisible()
3131
await customInput.click()
32-
await customInput.fill("")
33-
await customInput.type(customAnswer)
32+
await customInput.fill(customAnswer)
3433
await expect(customInput).toHaveValue(customAnswer)
3534

3635
const saveButton = page.getByRole("button", { name: "Save custom answer" })
3736
await expect(saveButton).toBeEnabled()
38-
await customInput.press("Enter")
39-
40-
await expect(questionHeading).toHaveText("What language do you use?")
37+
await page.getByTestId("wizard-free-text-form").evaluate((form) => {
38+
(form as HTMLFormElement).requestSubmit()
39+
})
4140

4241
await expect.poll(
4342
() =>
@@ -58,6 +57,8 @@ test("wizard accepts custom free text answers and shows them in the summary", as
5857
{ timeout: 15000 }
5958
).toBe(customAnswer)
6059

60+
await expect(questionHeading).toHaveText("What language do you use?")
61+
6162
const storedState = await page.evaluate(() => {
6263
const raw = window.localStorage.getItem("devcontext:wizard:react")
6364
return raw ? JSON.parse(raw) : null

playwright/tests/wizard.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ test('wizard supports filtering, defaults, and reset', async ({ page }) => {
2121

2222
const defaultButton = page.getByRole('button', { name: 'Use default (Vite)' })
2323
await expect(defaultButton).toBeEnabled()
24-
await defaultButton.click()
25-
await expect(defaultButton).toBeDisabled()
24+
await defaultButton.click({ noWaitAfter: true })
25+
await expect(questionHeading).toHaveText('What language do you use?')
2626

2727
await expect.poll(
2828
() =>

0 commit comments

Comments
 (0)