feat(feedback-module): multiple choice options in the survey question card

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mateusz Prusaczyk
2026-10-19 13:42:44 +02:00
committed by GitHub
parent b278b3ca54
commit 04246e2edb
3 changed files with 266 additions and 77 deletions
@@ -0,0 +1,98 @@
import {
Button,
IconButton,
InputAdornment,
styled,
TextField,
} from '@mui/material';
import Add from '@mui/icons-material/Add';
import CheckBoxOutlineBlank from '@mui/icons-material/CheckBoxOutlineBlank';
import Close from '@mui/icons-material/Close';
import { FormGroup } from 'component/common/FormGroup/FormGroup';
const StyledOptionList = styled('ul')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
margin: 0,
padding: 0,
listStyle: 'none',
}));
interface ChoiceOptionsProps {
options: string[];
errors?: (string | undefined)[];
onChange: (options: string[]) => void;
}
export const ChoiceOptions = ({
options,
errors = [],
onChange,
}: ChoiceOptionsProps) => (
<FormGroup variant='nested' title='Options'>
<StyledOptionList>
{options.map((option, index) => {
const label = `Option ${index + 1}`;
return (
// Options are plain strings that can be empty or repeated
// while typed, so they have nothing stable to key by.
<li key={index}>
<TextField
value={option}
onChange={(event) =>
onChange(
options.with(index, event.target.value),
)
}
size='small'
fullWidth
error={Boolean(errors[index])}
helperText={errors[index]}
slotProps={{
htmlInput: { 'aria-label': label },
input: {
startAdornment: (
<InputAdornment position='start'>
<CheckBoxOutlineBlank
fontSize='small'
color='disabled'
/>
</InputAdornment>
),
endAdornment: (
<InputAdornment position='end'>
<IconButton
aria-label={`Remove ${label.toLowerCase()}`}
onClick={() =>
onChange(
options.toSpliced(
index,
1,
),
)
}
disabled={options.length === 1}
edge='end'
size='small'
>
<Close fontSize='small' />
</IconButton>
</InputAdornment>
),
},
}}
/>
</li>
);
})}
</StyledOptionList>
<Button
size='small'
startIcon={<Add />}
onClick={() => onChange([...options, ''])}
>
Add option
</Button>
</FormGroup>
);
@@ -19,9 +19,11 @@ import {
type QuestionType,
withType,
} from '../../surveyDraft.ts';
import { ChoiceOptions } from './ChoiceOptions/ChoiceOptions.tsx';
const QUESTION_TYPES: { key: QuestionType; label: string }[] = [
{ key: 'rating', label: 'Rating' },
{ key: 'multi-choice', label: 'Multiple choice' },
{ key: 'free-text', label: 'Free text' },
];
@@ -33,7 +35,7 @@ const SCALES = Array.from(
},
);
const StyledCard = styled('li')(({ theme }) => ({
const StyledCard = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
@@ -87,85 +89,101 @@ export const QuestionCard = ({
) => onChange({ ...question, ...fields });
return (
<StyledCard role='group' aria-labelledby={formFieldLabelId(promptId)}>
<StyledMainRow>
<Input
id={promptId}
label={`Question ${number}`}
value={question.prompt}
onChange={(event) => update({ prompt: event.target.value })}
error={Boolean(errors.prompt)}
errorText={errors.prompt}
fullWidth
/>
<GeneralSelect
label='Type'
value={question.type}
onChange={(type) => onChange(withType(question, type))}
options={QUESTION_TYPES}
/>
<Input
label={
<>
ID
<HelpIcon
tooltip='Answers to this question are stored under this ID.'
size='16px'
/>
</>
}
value={question.id}
onChange={(event) => update({ id: event.target.value })}
error={Boolean(errors.id)}
errorText={errors.id}
fullWidth
/>
</StyledMainRow>
{question.type === 'rating' ? (
<StyledScale>
<GeneralSelect
label='Scale'
value={String(question.scale)}
onChange={(scale) =>
onChange({ ...question, scale: Number(scale) })
<li>
<StyledCard
role='group'
aria-labelledby={formFieldLabelId(promptId)}
>
<StyledMainRow>
<Input
id={promptId}
label={`Question ${number}`}
value={question.prompt}
onChange={(event) =>
update({ prompt: event.target.value })
}
options={SCALES}
error={Boolean(errors.prompt)}
errorText={errors.prompt}
fullWidth
/>
</StyledScale>
) : null}
<StyledFooter>
<Tooltip
title={
onRemove
? 'Remove question'
: 'A survey needs at least one question'
}
arrow
>
<span>
<IconButton
aria-label='Remove question'
onClick={onRemove}
disabled={!onRemove}
>
<DeleteOutlined />
</IconButton>
</span>
</Tooltip>
<Divider orientation='vertical' flexItem />
<FormControlLabel
label='Required'
labelPlacement='start'
control={
<Checkbox
checked={question.required}
onChange={(event) =>
update({ required: event.target.checked })
<GeneralSelect
label='Type'
value={question.type}
onChange={(type) => onChange(withType(question, type))}
options={QUESTION_TYPES}
/>
<Input
label={
<>
ID
<HelpIcon
tooltip='Answers to this question are stored under this ID.'
size='16px'
/>
</>
}
value={question.id}
onChange={(event) => update({ id: event.target.value })}
error={Boolean(errors.id)}
errorText={errors.id}
fullWidth
/>
</StyledMainRow>
{question.type === 'rating' ? (
<StyledScale>
<GeneralSelect
label='Scale'
value={String(question.scale)}
onChange={(scale) =>
onChange({ ...question, scale: Number(scale) })
}
options={SCALES}
/>
}
/>
</StyledFooter>
</StyledCard>
</StyledScale>
) : null}
{question.type === 'multi-choice' ? (
<ChoiceOptions
options={question.options}
errors={errors.options}
onChange={(options) =>
onChange({ ...question, options })
}
/>
) : null}
<StyledFooter>
<Tooltip
title={
onRemove
? 'Remove question'
: 'A survey needs at least one question'
}
arrow
>
<span>
<IconButton
aria-label='Remove question'
onClick={onRemove}
disabled={!onRemove}
>
<DeleteOutlined />
</IconButton>
</span>
</Tooltip>
<Divider orientation='vertical' flexItem />
<FormControlLabel
label='Required'
labelPlacement='start'
control={
<Checkbox
checked={question.required}
onChange={(event) =>
update({ required: event.target.checked })
}
/>
}
/>
</StyledFooter>
</StyledCard>
</li>
);
};
@@ -18,6 +18,12 @@ const draftQuestions = (count: number): QuestionDraft[] =>
prompt: `Prompt ${index + 1}`,
}));
const choiceQuestion = (options: string[]): QuestionDraft => ({
...draftQuestions(1)[0],
type: 'multi-choice',
options,
});
const Editable = ({
initial,
errors = [],
@@ -135,6 +141,73 @@ describe('SurveyQuestions', () => {
).toHaveTextContent('7 stars');
});
it('asks for options only for multiple choice questions', async () => {
render(<Editable initial={draftQuestions(1)} />);
const card = questionCard(1);
await userEvent.click(
within(card).getByRole('combobox', { name: 'Type' }),
);
await userEvent.click(
screen.getByRole('option', { name: 'Multiple choice' }),
);
expect(within(card).getByLabelText('Option 1')).toHaveValue('');
expect(
within(card).queryByRole('combobox', { name: 'Scale' }),
).not.toBeInTheDocument();
});
it('keeps the options entered into a multiple choice question', async () => {
render(<Editable initial={[choiceQuestion(['Dev'])]} />);
const card = questionCard(1);
await userEvent.click(
within(card).getByRole('button', { name: 'Add option' }),
);
await userEvent.type(within(card).getByLabelText('Option 2'), 'PM');
expect(within(card).getByLabelText('Option 1')).toHaveValue('Dev');
expect(within(card).getByLabelText('Option 2')).toHaveValue('PM');
});
it('removes the chosen option', async () => {
render(<Editable initial={[choiceQuestion(['Dev', 'PM'])]} />);
const card = questionCard(1);
await userEvent.click(
within(card).getByRole('button', { name: 'Remove option 1' }),
);
expect(within(card).getByLabelText('Option 1')).toHaveValue('PM');
expect(
within(card).queryByLabelText('Option 2'),
).not.toBeInTheDocument();
});
it('keeps the last option', () => {
render(<Editable initial={[choiceQuestion(['Dev'])]} />);
expect(
within(questionCard(1)).getByRole('button', {
name: 'Remove option 1',
}),
).toBeDisabled();
});
it('shows the errors of each option', () => {
render(
<Editable
initial={[choiceQuestion(['Dev', ''])]}
errors={[{ options: [undefined, 'Enter an option'] }]}
/>,
);
expect(
within(questionCard(1)).getByLabelText('Option 2'),
).toHaveAccessibleDescription('Enter an option');
});
it('shows the errors of each question', () => {
render(
<Editable