mirror of
https://github.com/Unleash/unleash.git
synced 2026-10-26 21:58:10 +00:00
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:
committed by
GitHub
parent
b278b3ca54
commit
04246e2edb
+98
@@ -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>
|
||||
);
|
||||
+95
-77
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+73
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user