Skip to content

Commit 9e65cc0

Browse files
authored
Merge pull request storybookjs#31944 from storybookjs/onboarding-intent-survey
Onboarding: Intent survey
2 parents a0f8800 + a207776 commit 9e65cc0

29 files changed

+1023
-460
lines changed

code/addons/onboarding/src/Onboarding.tsx

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { HighlightElement } from './components/HighlightElement/HighlightElement
1313
import type { STORYBOOK_ADDON_ONBOARDING_STEPS } from './constants';
1414
import { STORYBOOK_ADDON_ONBOARDING_CHANNEL } from './constants';
1515
import { GuidedTour } from './features/GuidedTour/GuidedTour';
16+
import { IntentSurvey } from './features/IntentSurvey/IntentSurvey';
1617
import { SplashScreen } from './features/SplashScreen/SplashScreen';
1718

1819
const SpanHighlight = styled.span(({ theme }) => ({
@@ -106,14 +107,21 @@ export default function Onboarding({ api }: { api: API }) {
106107
setEnabled(false);
107108
}, [api, setEnabled]);
108109

109-
const completeOnboarding = useCallback(() => {
110-
api.emit(STORYBOOK_ADDON_ONBOARDING_CHANNEL, {
111-
step: '6:FinishedOnboarding' satisfies StepKey,
112-
type: 'telemetry',
113-
});
114-
selectStory('configure-your-project--docs');
115-
disableOnboarding();
116-
}, [api, selectStory, disableOnboarding]);
110+
const completeOnboarding = useCallback(
111+
(answers: Record<string, unknown>) => {
112+
api.emit(STORYBOOK_ADDON_ONBOARDING_CHANNEL, {
113+
step: '7:FinishedOnboarding' satisfies StepKey,
114+
type: 'telemetry',
115+
});
116+
api.emit(STORYBOOK_ADDON_ONBOARDING_CHANNEL, {
117+
answers,
118+
type: 'survey',
119+
});
120+
selectStory('configure-your-project--docs');
121+
disableOnboarding();
122+
},
123+
[api, selectStory, disableOnboarding]
124+
);
117125

118126
useEffect(() => {
119127
api.setQueryParams({ onboarding: 'true' });
@@ -136,7 +144,9 @@ export default function Onboarding({ api }: { api: API }) {
136144

137145
useEffect(() => {
138146
setStep((current) => {
139-
if (['1:Intro', '5:StoryCreated', '6:FinishedOnboarding'].includes(current)) {
147+
if (
148+
['1:Intro', '5:StoryCreated', '6:IntentSurvey', '7:FinishedOnboarding'].includes(current)
149+
) {
140150
return current;
141151
}
142152

@@ -272,12 +282,14 @@ export default function Onboarding({ api }: { api: API }) {
272282
{showConfetti && <Confetti />}
273283
{step === '1:Intro' ? (
274284
<SplashScreen onDismiss={() => setStep('2:Controls')} />
285+
) : step === '6:IntentSurvey' ? (
286+
<IntentSurvey onComplete={completeOnboarding} onDismiss={disableOnboarding} />
275287
) : (
276288
<GuidedTour
277289
step={step}
278290
steps={steps}
279291
onClose={disableOnboarding}
280-
onComplete={completeOnboarding}
292+
onComplete={() => setStep('6:IntentSurvey')}
281293
/>
282294
)}
283295
</ThemeProvider>

code/addons/onboarding/src/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ export const STORYBOOK_ADDON_ONBOARDING_STEPS = [
66
'3:SaveFromControls',
77
'4:CreateStory',
88
'5:StoryCreated',
9-
'6:FinishedOnboarding',
9+
'6:IntentSurvey',
10+
'7:FinishedOnboarding',
1011
] as const;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
3+
import { expect, fn, screen, userEvent, waitFor } from 'storybook/test';
4+
5+
import { IntentSurvey } from './IntentSurvey';
6+
7+
const meta = {
8+
component: IntentSurvey,
9+
args: {
10+
onComplete: fn(),
11+
onDismiss: fn(),
12+
},
13+
} as Meta<typeof IntentSurvey>;
14+
15+
type Story = StoryObj<typeof meta>;
16+
export default meta;
17+
18+
export const Default: Story = {};
19+
20+
export const Submitting: Story = {
21+
play: async ({ args }) => {
22+
const button = await screen.findByRole('button', { name: 'Submit' });
23+
await expect(button).toBeDisabled();
24+
25+
await userEvent.click(await screen.findByText('Design system'));
26+
await expect(button).toBeDisabled();
27+
28+
await userEvent.click(await screen.findByText('Functional testing'));
29+
await userEvent.click(await screen.findByText('Accessibility testing'));
30+
await userEvent.click(await screen.findByText('Visual testing'));
31+
await expect(button).toBeDisabled();
32+
33+
await userEvent.selectOptions(screen.getByRole('combobox'), ['We use it at work']);
34+
await expect(button).not.toBeDisabled();
35+
36+
await userEvent.click(button);
37+
38+
await waitFor(async () => {
39+
await expect(button).toBeDisabled();
40+
await expect(args.onComplete).toHaveBeenCalledWith({
41+
building: {
42+
'application-ui': false,
43+
'design-system': true,
44+
},
45+
interest: {
46+
'accessibility-testing': true,
47+
'ai-augmented-development': false,
48+
'design-handoff': false,
49+
'functional-testing': true,
50+
'team-collaboration': false,
51+
'ui-documentation': false,
52+
'visual-testing': true,
53+
},
54+
referrer: {
55+
'ai-agent': false,
56+
'via-friend-or-colleague': false,
57+
'via-social-media': false,
58+
'we-use-it-at-work': true,
59+
'web-search': false,
60+
youtube: false,
61+
},
62+
});
63+
});
64+
},
65+
};
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import React, { useState } from 'react';
2+
3+
import { Button, Form, Modal } from 'storybook/internal/components';
4+
5+
import { styled } from 'storybook/theming';
6+
7+
import { isChromatic } from '../../../../../.storybook/isChromatic';
8+
9+
interface BaseField {
10+
label: string;
11+
options: Record<string, { label: string }>;
12+
required?: boolean;
13+
}
14+
15+
interface CheckboxField extends BaseField {
16+
type: 'checkbox';
17+
values: Record<keyof BaseField['options'], boolean>;
18+
}
19+
20+
interface SelectField extends BaseField {
21+
type: 'select';
22+
values: Record<keyof BaseField['options'], boolean>;
23+
}
24+
25+
type FormFields = {
26+
building: CheckboxField;
27+
interest: CheckboxField;
28+
referrer: SelectField;
29+
};
30+
31+
const Content = styled(Modal.Content)(({ theme }) => ({
32+
fontSize: theme.typography.size.s2,
33+
color: theme.color.defaultText,
34+
gap: 8,
35+
}));
36+
37+
const Row = styled.div({
38+
display: 'grid',
39+
gridTemplateColumns: '1fr 1fr',
40+
gap: 14,
41+
marginBottom: 8,
42+
});
43+
44+
const Question = styled.div(({ theme }) => ({
45+
marginTop: 8,
46+
marginBottom: 2,
47+
fontWeight: theme.typography.weight.bold,
48+
}));
49+
50+
const Label = styled.label({
51+
display: 'flex',
52+
gap: 8,
53+
54+
'&:has(input[type="checkbox"]:not(:disabled), input[type="radio"]:not(:disabled))': {
55+
cursor: 'pointer',
56+
},
57+
});
58+
59+
const Actions = styled(Modal.Actions)({
60+
marginTop: 8,
61+
});
62+
63+
const Checkbox = styled(Form.Checkbox)({
64+
margin: 2,
65+
});
66+
67+
export const IntentSurvey = ({
68+
onComplete,
69+
onDismiss,
70+
}: {
71+
onComplete: (formData: Record<string, Record<string, boolean>>) => void;
72+
onDismiss: () => void;
73+
}) => {
74+
const [isSubmitting, setIsSubmitting] = useState(false);
75+
76+
const [formFields, setFormFields] = useState<FormFields>({
77+
building: {
78+
label: 'What are you building?',
79+
type: 'checkbox',
80+
required: true,
81+
options: shuffleObject({
82+
'design-system': { label: 'Design system' },
83+
'application-ui': { label: 'Application UI' },
84+
}),
85+
values: {
86+
'design-system': false,
87+
'application-ui': false,
88+
},
89+
},
90+
interest: {
91+
label: 'Which of these are you interested in?',
92+
type: 'checkbox',
93+
required: true,
94+
options: shuffleObject({
95+
'ui-documentation': { label: 'Generating UI docs' },
96+
'functional-testing': { label: 'Functional testing' },
97+
'accessibility-testing': { label: 'Accessibility testing' },
98+
'visual-testing': { label: 'Visual testing' },
99+
'ai-augmented-development': { label: 'Building UI with AI' },
100+
'team-collaboration': { label: 'Team collaboration' },
101+
'design-handoff': { label: 'Design handoff' },
102+
}),
103+
values: {
104+
'ui-documentation': false,
105+
'functional-testing': false,
106+
'accessibility-testing': false,
107+
'visual-testing': false,
108+
'ai-augmented-development': false,
109+
'team-collaboration': false,
110+
'design-handoff': false,
111+
},
112+
},
113+
referrer: {
114+
label: 'How did you learn about Storybook?',
115+
type: 'select',
116+
required: true,
117+
options: shuffleObject({
118+
'we-use-it-at-work': { label: 'We use it at work' },
119+
'via-friend-or-colleague': { label: 'Via friend or colleague' },
120+
'via-social-media': { label: 'Via social media' },
121+
youtube: { label: 'YouTube' },
122+
'web-search': { label: 'Web Search' },
123+
'ai-agent': { label: 'AI Agent (e.g. ChatGPT)' },
124+
}),
125+
values: {
126+
'we-use-it-at-work': false,
127+
'via-friend-or-colleague': false,
128+
'via-social-media': false,
129+
youtube: false,
130+
'web-search': false,
131+
'ai-agent': false,
132+
},
133+
},
134+
});
135+
136+
const updateFormData = (key: keyof FormFields, optionOrValue: string, value?: boolean) => {
137+
const field = formFields[key];
138+
setFormFields((fields) => {
139+
if (field.type === 'checkbox') {
140+
const values = { ...field.values, [optionOrValue]: !!value };
141+
return { ...fields, [key]: { ...field, values } };
142+
}
143+
if (field.type === 'select') {
144+
const values = Object.fromEntries(
145+
Object.entries(field.values).map(([opt]) => [opt, opt === optionOrValue])
146+
);
147+
return { ...fields, [key]: { ...field, values } };
148+
}
149+
return fields;
150+
});
151+
};
152+
153+
const isValid = Object.values(formFields).every((field) => {
154+
if (!field.required) {
155+
return true;
156+
}
157+
// Check if at least one option is selected (true)
158+
return Object.values(field.values).some((value) => value === true);
159+
});
160+
161+
const onSubmitForm = (e: React.FormEvent<HTMLFormElement>) => {
162+
if (!isValid) {
163+
return;
164+
}
165+
e.preventDefault();
166+
setIsSubmitting(true);
167+
onComplete(
168+
Object.fromEntries(Object.entries(formFields).map(([key, field]) => [key, field.values]))
169+
);
170+
};
171+
172+
return (
173+
<Modal defaultOpen width={420} onEscapeKeyDown={onDismiss}>
174+
<Form onSubmit={onSubmitForm} id="intent-survey-form">
175+
<Content>
176+
<Modal.Header>
177+
<Modal.Title>Help improve Storybook</Modal.Title>
178+
</Modal.Header>
179+
180+
{(Object.keys(formFields) as Array<keyof FormFields>).map((key) => {
181+
const field = formFields[key];
182+
return (
183+
<React.Fragment key={key}>
184+
<Question>{field.label}</Question>
185+
{field.type === 'checkbox' && (
186+
<Row>
187+
{Object.entries(field.options).map(([opt, option]) => {
188+
const id = `${key}:${opt}`;
189+
return (
190+
<div key={id}>
191+
<Label htmlFor={id}>
192+
<Checkbox
193+
name={id}
194+
id={id}
195+
checked={field.values[opt]}
196+
disabled={isSubmitting}
197+
onChange={(e) => updateFormData(key, opt, e.target.checked)}
198+
/>
199+
{option.label}
200+
</Label>
201+
</div>
202+
);
203+
})}
204+
</Row>
205+
)}
206+
{field.type === 'select' && (
207+
<Form.Select
208+
name={key}
209+
id={key}
210+
value={
211+
Object.entries(field.values).find(([, isSelected]) => isSelected)?.[0] || ''
212+
}
213+
required={field.required}
214+
disabled={isSubmitting}
215+
onChange={(e) => updateFormData(key, e.target.value)}
216+
>
217+
<option disabled hidden value="">
218+
Select an option...
219+
</option>
220+
{Object.entries(field.options).map(([opt, option]) => (
221+
<option key={opt} value={opt}>
222+
{option.label}
223+
</option>
224+
))}
225+
</Form.Select>
226+
)}
227+
</React.Fragment>
228+
);
229+
})}
230+
231+
<Actions>
232+
<Button disabled={isSubmitting || !isValid} size="medium" type="submit" variant="solid">
233+
Submit
234+
</Button>
235+
</Actions>
236+
</Content>
237+
</Form>
238+
</Modal>
239+
);
240+
};
241+
242+
function shuffle<T>(array: T[]): T[] {
243+
for (let i = array.length - 1; i > 0; i--) {
244+
const j = Math.floor(Math.random() * (i + 1));
245+
[array[i], array[j]] = [array[j], array[i]];
246+
}
247+
return array;
248+
}
249+
250+
function shuffleObject<T extends object>(object: T): T {
251+
return isChromatic() ? object : (Object.fromEntries(shuffle(Object.entries(object))) as T);
252+
}

0 commit comments

Comments
 (0)