Skip to content

Commit 9adedf2

Browse files
committed
improvement(questions): make something else a placeholder
1 parent 8a1e3f9 commit 9adedf2

4 files changed

Lines changed: 509 additions & 1344 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ describe('QuestionDisplay', () => {
114114
container.remove()
115115
})
116116

117-
it('focuses the free-text input when Something else is clicked', () => {
117+
it('renders Something else as a placeholder instead of an option', () => {
118118
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
119119
const container = document.createElement('div')
120120
document.body.appendChild(container)
@@ -129,17 +129,53 @@ describe('QuestionDisplay', () => {
129129
)
130130
})
131131

132-
const somethingElseButton = Array.from(container.querySelectorAll('button')).find((button) =>
133-
button.textContent?.includes('Something else')
132+
const input = container.querySelector('input')
133+
expect(input).not.toBeNull()
134+
expect(input?.placeholder).toBe('Something else')
135+
expect(input?.className).toContain('placeholder:text-[var(--text-muted)]')
136+
expect(container.textContent).not.toContain('Something else')
137+
138+
const optionButton = Array.from(container.querySelectorAll('button')).find(
139+
(button) => button.textContent === 'Keep the newest entry'
134140
)
135-
expect(somethingElseButton).toBeDefined()
141+
expect(optionButton).toBeDefined()
136142

137-
act(() => somethingElseButton?.click())
143+
act(() => root.unmount())
144+
container.remove()
145+
})
146+
147+
it('focuses the Something else input when its multi-select checkbox is selected', () => {
148+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
149+
const container = document.createElement('div')
150+
document.body.appendChild(container)
151+
const root = createRoot(container)
152+
153+
act(() => {
154+
root.render(
155+
createElement(QuestionDisplay, {
156+
data: [QUESTIONS[2]],
157+
onSelect: () => undefined,
158+
})
159+
)
160+
})
138161

139162
const input = container.querySelector('input')
163+
const checkbox = container.querySelector<HTMLButtonElement>(
164+
'button[aria-label="Include \\"Something else\\" in the answer"]'
165+
)
140166
expect(input).not.toBeNull()
167+
expect(checkbox).not.toBeNull()
168+
169+
act(() => checkbox?.click())
170+
171+
expect(checkbox?.dataset.state).toBe('checked')
141172
expect(document.activeElement).toBe(input)
142173

174+
act(() => checkbox?.focus())
175+
act(() => checkbox?.click())
176+
177+
expect(checkbox?.dataset.state).toBe('unchecked')
178+
143179
act(() => root.unmount())
144180
container.remove()
145181
})

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx

Lines changed: 82 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useEffect, useRef, useState } from 'react'
3+
import { useRef, useState } from 'react'
44
import {
55
ArrowRight,
66
Button,
@@ -52,15 +52,6 @@ const OPTION_ROW_CLASSES =
5252
/** Ghost icon-button chrome shared by the stepper chevrons and the dismiss X. */
5353
const ICON_BUTTON_CLASSES = 'relative size-[14px] flex-shrink-0 p-0'
5454

55-
/** Leading number slot matching the suggested follow-ups rows. */
56-
function RowNumber({ value }: { value: number }) {
57-
return (
58-
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
59-
<span className='text-[var(--text-icon)] text-sm'>{value}</span>
60-
</div>
61-
)
62-
}
63-
6455
/**
6556
* Leading checkbox slot for multi_select rows. Purely presentational — it
6657
* reuses the emcn Checkbox chrome via its exported variants, but the row
@@ -101,38 +92,32 @@ interface QuestionDisplayProps {
10192
* Inline renderer for the `<question>` special tag: a chat-inline div with the
10293
* user input's chrome, the current question's prompt at the top left, dismiss
10394
* (and a `‹ N of M ›` stepper for multi-step batches) at the top right, and
104-
* suggested-action option rows beneath, always followed by a "Something else"
105-
* row that reads as a plain option until clicked and then becomes the focused
106-
* text box. `single_select` answers and advances on click (or on submitting
107-
* typed text); `multi_select` rows toggle checkboxes and an option-styled
108-
* Submit row confirms the step. Answering the last question sends one
109-
* combined user message and collapses the div to a question/answer recap.
95+
* suggested-action option rows beneath, always followed by a custom-answer
96+
* text field whose placeholder reads "Something else". `single_select`
97+
* answers and advances on click (or on submitting typed text); `multi_select`
98+
* rows toggle checkboxes and an option-styled Submit row confirms the step.
99+
* Answering the last question sends one combined user message and collapses
100+
* the div to a question/answer recap.
110101
*/
111102
export function QuestionDisplay({
112103
data,
113104
answers: transcriptAnswers,
114105
onSelect,
115106
}: QuestionDisplayProps) {
116107
const freeTextInputRef = useRef<HTMLInputElement>(null)
108+
const freeTextCheckboxRef = useRef<HTMLButtonElement>(null)
117109
const disabled = !onSelect
118110
const [phase, setPhase] = useState<QuestionPhase>('active')
119111
const [step, setStep] = useState(0)
120112
const [selectedByStep, setSelectedByStep] = useState<string[][]>(() => data.map(() => []))
121113
const [customByStep, setCustomByStep] = useState<string[]>(() => data.map(() => ''))
122114
const [freeText, setFreeText] = useState('')
123-
// The "Something else" row reads as a plain option until clicked, then
124-
// becomes the focused text box (and reverts when left empty).
125-
const [freeTextEditing, setFreeTextEditing] = useState(false)
126115
// multi_select only: whether the typed "Something else" text is included in
127116
// the answer. Unchecking keeps the text; it just stops counting.
128117
const [customCheckedByStep, setCustomCheckedByStep] = useState<boolean[]>(() =>
129118
data.map(() => false)
130119
)
131120

132-
useEffect(() => {
133-
if (freeTextEditing) freeTextInputRef.current?.focus()
134-
}, [freeTextEditing, step])
135-
136121
// The typed text that actually joins a step's answer: multi_select customs
137122
// only count while checked; single_select customs always count.
138123
const customFor = (i: number, customs: string[]): string =>
@@ -187,15 +172,13 @@ export function QuestionDisplay({
187172
setStep(next)
188173
const prefill = customByStep[next] ?? ''
189174
setFreeText(prefill)
190-
setFreeTextEditing(prefill.trim().length > 0)
191175
}
192176

193177
const finishStep = (selections: string[][], customs: string[]) => {
194178
if (!isLast) {
195179
setStep(step + 1)
196180
const prefill = customs[step + 1] ?? ''
197181
setFreeText(prefill)
198-
setFreeTextEditing(prefill.trim().length > 0)
199182
return
200183
}
201184
setPhase('answered')
@@ -239,6 +222,12 @@ export function QuestionDisplay({
239222
setCustomCheckedByStep(next)
240223
}
241224

225+
const toggleCustomChecked = () => {
226+
const isChecked = customCheckedByStep[step] ?? false
227+
setCustomChecked(!isChecked)
228+
if (!isChecked) freeTextInputRef.current?.focus()
229+
}
230+
242231
/** single_select free-text arrow: the typed text IS the answer. */
243232
const submitSingleFreeText = () => {
244233
const customs = commitCustom()
@@ -333,118 +322,88 @@ export function QuestionDisplay({
333322
isSelected && 'bg-[var(--surface-5)]'
334323
)}
335324
>
336-
{isMulti ? (
337-
<RowCheckbox checked={isSelected} disabled={disabled} />
338-
) : (
339-
<RowNumber value={i + 1} />
340-
)}
325+
{isMulti ? <RowCheckbox checked={isSelected} disabled={disabled} /> : null}
341326
<span className='min-w-0 flex-1 whitespace-normal break-words text-[var(--text-body)] text-sm'>
342327
{option.label}
343328
</span>
344329
{!isMulti && <ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />}
345330
</button>
346331
)
347332
})}
348-
{freeTextEditing ? (
349-
<div className={cn(OPTION_ROW_CLASSES, options.length > 0 && 'border-t')}>
350-
{isMulti ? (
351-
// Checked from the moment the row is clicked into; blur with
352-
// nothing typed reverts to the plain option row. A real button
353-
// (the editing row is a div, so no nesting hazard) so the box
354-
// can be toggled even after typing — unchecking keeps the text,
355-
// it just stops counting toward the answer.
356-
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
357-
<button
358-
type='button'
359-
aria-label='Include "Something else" in the answer'
360-
disabled={disabled}
361-
onClick={() => setCustomChecked(!(customCheckedByStep[step] ?? false))}
362-
data-state={(customCheckedByStep[step] ?? false) ? 'checked' : 'unchecked'}
363-
data-disabled={disabled ? '' : undefined}
364-
className={checkboxVariants({ size: 'sm' })}
365-
>
366-
{(customCheckedByStep[step] ?? false) && (
367-
<Check
368-
className={cn(
369-
checkboxIconVariants({ size: 'sm' }),
370-
'text-[var(--surface-2)]'
371-
)}
372-
/>
373-
)}
374-
</button>
375-
</div>
376-
) : (
377-
<RowNumber value={options.length + 1} />
378-
)}
379-
<input
380-
ref={freeTextInputRef}
381-
type='text'
382-
value={freeText}
383-
disabled={disabled}
384-
onChange={(e) => setFreeText(e.target.value)}
385-
onBlur={() => {
386-
if (freeText.trim().length === 0) {
387-
setFreeTextEditing(false)
388-
if (isMulti) setCustomChecked(false)
389-
}
390-
}}
391-
onKeyDown={(e) => {
392-
if (e.key === 'Escape') {
393-
e.currentTarget.blur()
394-
return
395-
}
396-
if (e.key === 'Enter' && canSubmitStep) {
397-
e.preventDefault()
398-
if (isMulti) {
399-
submitMultiStep()
400-
} else {
401-
submitSingleFreeText()
402-
}
403-
}
404-
}}
405-
aria-label={question.prompt}
406-
className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none disabled:cursor-not-allowed'
407-
/>
408-
{!isMulti && (
333+
<div className={cn(OPTION_ROW_CLASSES, options.length > 0 && 'border-t')}>
334+
{isMulti && (
335+
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
409336
<button
337+
ref={freeTextCheckboxRef}
410338
type='button'
411-
aria-label='Submit answer'
412-
disabled={!canSubmitStep}
413-
onClick={submitSingleFreeText}
414-
className='disabled:cursor-default'
339+
aria-label='Include "Something else" in the answer'
340+
disabled={disabled}
341+
onClick={toggleCustomChecked}
342+
data-state={(customCheckedByStep[step] ?? false) ? 'checked' : 'unchecked'}
343+
data-disabled={disabled ? '' : undefined}
344+
className={checkboxVariants({ size: 'sm' })}
415345
>
416-
<ArrowRight
417-
className={cn(
418-
'size-[16px] shrink-0 transition-colors',
419-
canSubmitStep ? 'text-[var(--text-body)]' : 'text-[var(--text-icon)]'
420-
)}
421-
/>
346+
{(customCheckedByStep[step] ?? false) && (
347+
<Check
348+
className={cn(checkboxIconVariants({ size: 'sm' }), 'text-[var(--surface-2)]')}
349+
/>
350+
)}
422351
</button>
423-
)}
424-
</div>
425-
) : (
426-
<button
427-
type='button'
352+
</div>
353+
)}
354+
<input
355+
ref={freeTextInputRef}
356+
type='text'
357+
value={freeText}
358+
placeholder='Something else'
428359
disabled={disabled}
429-
onClick={() => {
430-
setFreeTextEditing(true)
360+
onFocus={() => {
431361
if (isMulti) setCustomChecked(true)
432362
}}
433-
className={cn(
434-
OPTION_ROW_CLASSES,
435-
options.length > 0 && 'border-t',
436-
disabled ? 'cursor-not-allowed' : 'hover-hover:bg-[var(--surface-5)]'
437-
)}
438-
>
439-
{isMulti ? (
440-
<RowCheckbox checked={false} disabled={disabled} />
441-
) : (
442-
<RowNumber value={options.length + 1} />
443-
)}
444-
<span className='flex-1 truncate text-[var(--text-body)] text-sm'>Something else</span>
445-
{!isMulti && <ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />}
446-
</button>
447-
)}
363+
onChange={(e) => setFreeText(e.target.value)}
364+
onBlur={(event) => {
365+
if (
366+
isMulti &&
367+
event.relatedTarget !== freeTextCheckboxRef.current &&
368+
freeText.trim().length === 0
369+
) {
370+
setCustomChecked(false)
371+
}
372+
}}
373+
onKeyDown={(e) => {
374+
if (e.key === 'Escape') {
375+
e.currentTarget.blur()
376+
return
377+
}
378+
if (e.key === 'Enter' && canSubmitStep) {
379+
e.preventDefault()
380+
if (isMulti) {
381+
submitMultiStep()
382+
} else {
383+
submitSingleFreeText()
384+
}
385+
}
386+
}}
387+
aria-label={question.prompt}
388+
className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed'
389+
/>
390+
{!isMulti && (
391+
<button
392+
type='button'
393+
aria-label='Submit answer'
394+
disabled={!canSubmitStep}
395+
onClick={submitSingleFreeText}
396+
className='disabled:cursor-default'
397+
>
398+
<ArrowRight
399+
className={cn(
400+
'size-[16px] shrink-0 transition-colors',
401+
canSubmitStep ? 'text-[var(--text-body)]' : 'text-[var(--text-icon)]'
402+
)}
403+
/>
404+
</button>
405+
)}
406+
</div>
448407
{isMulti && (
449408
<button
450409
type='button'

0 commit comments

Comments
 (0)