Feature/nps (#17570)

Co-authored-by: markkaylor <mark.kaylor@strapi.io>
Co-authored-by: Gustav Hansen <gustav.hansen@strapi.io>
Co-authored-by: Rémi de Juvigny <8087692+remidej@users.noreply.github.com>
Co-authored-by: Fernando Chavez <fernando.chavez@strapi.io>
Co-authored-by: Madhuri Sandbhor <madhurisandbhor@gmail.com>
This commit is contained in:
Simone 2023-08-22 12:59:43 +02:00 committed by GitHub
parent d25fed47e8
commit 5785e8f4f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 743 additions and 0 deletions

View File

@ -0,0 +1,30 @@
---
title: NPS
tags:
- admin
- nps
---
## What does it do
The NPS survey is shown to users to get their feedback about Strapi. It is based on a rating scale from 0 to 10, and we also invite users to provide additional comments.
## When do we show the survey?
The NPS survey is only displayed to admin users who have selected the "Keep me updated" checkbox during registration. The survey is displayed after 5 minutes of activity.
The survey is shown to eligible users based on the following rules:
- If a user responds to the survey, the survey will be presented again within 90 days.
- If a user does not respond to the survey the first time after their last response, the survey will be presented again after 7 days.
- If a user does not respond to the survey for the second or subsequent time after their last response, the survey will be presented again after 90 days.
## Where data is submitted
The data is sent to this endpoint: `https://analytics.strapi.io/submit-nps`.
## Hooks
### useNpsSurveySettings
This hook uses the `usePersistentState` hook from the helper-plugin (more information available [here](/docs/core/helper-plugin/hooks/use-persistent-state)). It is exported so that it can be used during the registration process to determine whether users have selected the "Keep me updated" checkbox.

View File

@ -0,0 +1,27 @@
---
title: usePersistentState
description: API reference for the usePersistentState hook in Strapi
tags:
- hooks
- helper-plugin
---
Provides a easily usable hook to store data on the local storage.
## Usage
```js
import { usePersistentState } from '@strapi/helper-plugin';
const MyComponent = () => {
const [navbarOpened, setNavbarOpened] = usePersistentState('navbar-open', true);
return <nav>{navbarOpened ? <div>My menu!</div> : null}</nav>;
};
```
## Typescript
```ts
function usePersistentState<T>(key: string, defaultValue: T): [T, Dispatch<SetStateAction<T>>];
```

View File

@ -18,6 +18,7 @@ import { Admin } from '../pages/Admin';
import { getFullName } from '../utils/getFullName';
import { hashAdminUserEmail } from '../utils/uniqueAdminHash';
import NpsSurvey from './NpsSurvey';
import RBACProvider from './RBACProvider';
const strapiVersion = packageJSON.version;
@ -222,6 +223,7 @@ export const AuthenticatedApp = () => {
userDisplayName={userDisplayName}
>
<RBACProvider permissions={permissions} refetchPermissions={refetch}>
<NpsSurvey />
<Admin />
</RBACProvider>
</AppInfoProvider>

View File

@ -0,0 +1,17 @@
import { usePersistentState } from '@strapi/helper-plugin';
// Exported to make it available during admin user registration.
// Because we only enable the NPS for users who subscribe to the newsletter when signing up
export function useNpsSurveySettings() {
const [npsSurveySettings, setNpsSurveySettings] = usePersistentState(
'STRAPI_NPS_SURVEY_SETTINGS',
{
enabled: true,
lastResponseDate: null,
firstDismissalDate: null,
lastDismissalDate: null,
}
);
return { npsSurveySettings, setNpsSurveySettings };
}

View File

@ -0,0 +1,365 @@
import * as React from 'react';
import {
Box,
Flex,
IconButton,
Button,
Typography,
Textarea,
Portal,
Field,
FieldLabel,
FieldInput,
VisuallyHidden,
} from '@strapi/design-system';
import { auth, useNotification, useAppInfo } from '@strapi/helper-plugin';
import { Cross } from '@strapi/icons';
import { Formik, Form } from 'formik';
import { useIntl } from 'react-intl';
import { useMutation } from 'react-query';
import styled, { useTheme } from 'styled-components';
import * as yup from 'yup';
import { useNpsSurveySettings } from './hooks/useNpsSurveySettings';
const FieldWrapper = styled(Field)`
height: ${32 / 16}rem;
width: ${32 / 16}rem;
> label,
~ input {
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
> label {
color: inherit;
cursor: pointer;
padding: ${({ theme }) => theme.spaces[2]};
text-align: center;
vertical-align: middle;
}
&:hover,
&:focus-within {
background-color: ${({ theme }) => theme.colors.neutral0};
}
&:active,
&.selected {
color: ${({ theme }) => theme.colors.primary700};
background-color: ${({ theme }) => theme.colors.neutral0};
border-color: ${({ theme }) => theme.colors.primary700};
}
`;
const delays = {
postResponse: 90 * 24 * 60 * 60 * 1000, // 90 days in ms
postFirstDismissal: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
postSubsequentDismissal: 90 * 24 * 60 * 60 * 1000, // 90 days in ms
display: 5 * 60 * 1000, // 5 minutes in ms
};
const ratingArray = [...Array(11).keys()];
const checkIfShouldShowSurvey = (settings) => {
const { enabled, lastResponseDate, firstDismissalDate, lastDismissalDate } = settings;
// This function goes through all the cases where we'd want to not show the survey:
// 1. If the survey is disabled, abort mission, don't bother checking the other settings.
// 2. If the user has already responded to the survey, check if enough time has passed since the last response.
// 3. If the user has dismissed the survey twice or more before, check if enough time has passed since the last dismissal.
// 4. If the user has only dismissed the survey once before, check if enough time has passed since the first dismissal.
// If none of these cases check out, then we show the survey.
// Note that submitting a response resets the dismissal counts.
// Checks 3 and 4 should not be reversed, since the first dismissal will also exist if the user has dismissed the survey twice or more before.
// User hasn't enabled NPS feature
if (!enabled) {
return false;
}
// The user has already responded to the survey
if (lastResponseDate) {
const timeSinceLastResponse = Date.now() - new Date(lastResponseDate).getTime();
if (timeSinceLastResponse >= delays.postResponse) {
return true;
}
return false;
}
// The user has dismissed the survey twice or more before
if (lastDismissalDate) {
const timeSinceLastDismissal = Date.now() - new Date(lastDismissalDate).getTime();
if (timeSinceLastDismissal >= delays.postSubsequentDismissal) {
return true;
}
return false;
}
// The user has only dismissed the survey once before
if (firstDismissalDate) {
const timeSinceFirstDismissal = Date.now() - new Date(firstDismissalDate).getTime();
if (timeSinceFirstDismissal >= delays.postFirstDismissal) {
return true;
}
return false;
}
// The user has not interacted with the survey before
return true;
};
const NpsSurvey = () => {
const theme = useTheme();
const { formatMessage } = useIntl();
const { npsSurveySettings, setNpsSurveySettings } = useNpsSurveySettings();
const [isFeedbackResponse, setIsFeedbackResponse] = React.useState(false);
const toggleNotification = useNotification();
const { currentEnvironment, strapiVersion } = useAppInfo();
const { mutate, isLoading } = useMutation(
async (form) => {
const res = await fetch('https://analytics.strapi.io/submit-nps', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(form),
});
if (!res.ok) {
throw new Error('Failed to submit NPS survey');
}
return res;
},
{
onSuccess() {
setNpsSurveySettings((settings) => ({
...settings,
lastResponseDate: new Date(),
firstDismissalDate: null,
lastDismissalDate: null,
}));
setIsFeedbackResponse(true);
// Thank you message displayed in the banner should disappear after few seconds.
setTimeout(() => {
setSurveyIsShown(false);
}, 3000);
},
onError() {
toggleNotification({
type: 'warning',
message: formatMessage({ id: 'notification.error' }),
});
},
}
);
// Only check on first render if the survey should be shown
const [surveyIsShown, setSurveyIsShown] = React.useState(
checkIfShouldShowSurvey(npsSurveySettings)
);
// Set a cooldown to show the survey when session begins
const [displaySurvey, setDisplaySurvey] = React.useState(false);
React.useEffect(() => {
const displayTime = setTimeout(() => {
setDisplaySurvey(true);
}, delays.display);
return () => {
clearTimeout(displayTime);
};
}, []);
if (!displaySurvey) {
return null;
}
if (!surveyIsShown) {
return null;
}
const handleSubmitResponse = ({ npsSurveyRating: rating, npsSurveyFeedback: comment }) => {
const { email } = auth.getUserInfo();
mutate({
email,
rating,
comment,
environment: currentEnvironment,
version: strapiVersion,
license: window.strapi.projectType,
});
};
const handleDismiss = () => {
setNpsSurveySettings((settings) => {
const nextSettings = {
...settings,
lastResponseDate: null,
};
if (settings.firstDismissalDate) {
// If the user dismisses the survey for the second time
nextSettings.lastDismissalDate = new Date();
} else {
// If the user dismisses the survey for the first time
nextSettings.firstDismissalDate = new Date();
}
return nextSettings;
});
setSurveyIsShown(false);
};
return (
<Portal>
<Formik
initialValues={{ npsSurveyFeedback: '', npsSurveyRating: null }}
onSubmit={handleSubmitResponse}
validationSchema={yup.object({
npsSurveyFeedback: yup.string(),
npsSurveyRating: yup.number().required(),
})}
>
{({ values, handleChange, setFieldValue }) => (
<Form name="npsSurveyForm">
<Flex
hasRadius
direction="column"
padding={4}
borderColor="primary200"
background="neutral0"
shadow="popupShadow"
position="fixed"
bottom={0}
left="50%"
transform="translateX(-50%)"
zIndex={theme.zIndices[2]}
width="50%"
>
{isFeedbackResponse ? (
<Typography fontWeight="semiBold">
{formatMessage({
id: 'app.components.NpsSurvey.feedback-response',
defaultMessage: 'Thank you very much for your feedback!',
})}
</Typography>
) : (
<Box as="fieldset" width="100%">
<Flex justifyContent="space-between" width="100%">
<Box marginLeft="auto" marginRight="auto">
<Typography fontWeight="semiBold" as="legend">
{formatMessage({
id: 'app.components.NpsSurvey.banner-title',
defaultMessage:
'How likely are you to recommend Strapi to a friend or colleague?',
})}
</Typography>
</Box>
<IconButton
onClick={handleDismiss}
aria-label={formatMessage({
id: 'app.components.NpsSurvey.dismiss-survey-label',
defaultMessage: 'Dismiss survey',
})}
icon={<Cross />}
/>
</Flex>
<Flex gap={2} marginTop={2} marginBottom={2} justifyContent="center">
<Typography variant="pi" textColor="neutral600">
{formatMessage({
id: 'app.components.NpsSurvey.no-recommendation',
defaultMessage: 'Not at all likely',
})}
</Typography>
{ratingArray.map((number) => {
return (
<FieldWrapper
key={number}
className={values.npsSurveyRating === number ? 'selected' : null} // "selected" class added when child radio button is checked
hasRadius
background="primary100"
borderColor="primary200"
color="primary600"
position="relative"
cursor="pointer"
>
<FieldLabel htmlFor={`nps-survey-rating-${number}-input`}>
<VisuallyHidden>
<FieldInput
type="radio"
id={`nps-survey-rating-${number}-input`}
name="npsSurveyRating"
checked={values.npsSurveyRating === number}
onChange={(e) =>
setFieldValue('npsSurveyRating', parseInt(e.target.value, 10))
}
value={number}
/>
</VisuallyHidden>
{number}
</FieldLabel>
</FieldWrapper>
);
})}
<Typography variant="pi" textColor="neutral600">
{formatMessage({
id: 'app.components.NpsSurvey.happy-to-recommend',
defaultMessage: 'Extremely likely',
})}
</Typography>
</Flex>
{values.npsSurveyRating !== null && (
<Flex direction="column">
<Box marginTop={2}>
<FieldLabel htmlFor="npsSurveyFeedback" fontWeight="semiBold" fontSize={2}>
{formatMessage({
id: 'app.components.NpsSurvey.feedback-question',
defaultMessage: 'Do you have any suggestion for improvements?',
})}
</FieldLabel>
</Box>
<Box width="62%" marginTop={3} marginBottom={4}>
<Textarea
id="npsSurveyFeedback" // formik element attribute "id" should be same as the values key to work
width="100%"
onChange={handleChange}
>
{values.npsSurveyFeedback}
</Textarea>
</Box>
<Button marginBottom={2} type="submit" loading={isLoading}>
{formatMessage({
id: 'app.components.NpsSurvey.submit-feedback',
defaultMessage: 'Submit Feedback',
})}
</Button>
</Flex>
)}
</Box>
)}
</Flex>
</Form>
)}
</Formik>
</Portal>
);
};
export default NpsSurvey;

View File

@ -0,0 +1,290 @@
import React from 'react';
import { lightTheme, ThemeProvider } from '@strapi/design-system';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { IntlProvider } from 'react-intl';
import { QueryClient, QueryClientProvider } from 'react-query';
import NpsSurvey from '..';
const toggleNotification = jest.fn();
jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'),
auth: {
getUserInfo: jest.fn(() => ({
email: 'john@doe.com',
})),
},
useNotification: jest.fn().mockImplementation(() => toggleNotification),
useAppInfo: jest
.fn()
.mockImplementation(() => ({
autoReload: true,
strapiVersion: 'test',
communityEdition: false,
})),
}));
const handlers = [
rest.post('*/submit-nps', (req, res, ctx) => {
return res.once(ctx.status(200));
}),
];
const server = setupServer(...handlers);
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
const originalLocalStorage = global.localStorage;
const user = userEvent.setup({ delay: null });
const queryClient = new QueryClient();
const setup = () =>
render(<NpsSurvey />, {
wrapper({ children }) {
return (
<IntlProvider locale="en" defaultLocale="en">
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={lightTheme}>{children}</ThemeProvider>
</QueryClientProvider>
</IntlProvider>
);
},
});
describe('NPS survey', () => {
beforeAll(() => {
global.localStorage = localStorageMock;
server.listen();
});
afterEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
});
beforeEach(() => {
jest.useFakeTimers();
});
afterAll(() => {
server.close();
});
it('renders survey if enabled', () => {
localStorageMock.getItem.mockReturnValueOnce({ enabled: true });
setup();
act(() => jest.runAllTimers());
expect(screen.getByLabelText('0')).toBeInTheDocument();
expect(screen.getByLabelText('10')).toBeInTheDocument();
expect(screen.getByText(/not at all likely/i)).toBeInTheDocument();
expect(screen.getByText(/extremely likely/i)).toBeInTheDocument();
});
it('does not render survey if disabled', () => {
localStorageMock.getItem.mockReturnValueOnce({ enabled: false });
setup();
act(() => jest.runAllTimers());
expect(screen.queryByText(/not at all likely/i)).not.toBeInTheDocument();
});
it('saves user response', async () => {
localStorageMock.getItem.mockReturnValueOnce({ enabled: true });
setup();
act(() => jest.runAllTimers());
fireEvent.click(screen.getByRole('radio', { name: '10' }));
expect(screen.getByRole('button', { name: /submit feedback/i }));
act(() => {
fireEvent.submit(screen.getByRole('form'));
});
await waitFor(() => {
expect(screen.queryByText(/not at all likely/i)).not.toBeInTheDocument();
expect(screen.queryByText(/thank you very much for your feedback!/i)).toBeInTheDocument();
});
const storedData = JSON.parse(localStorageMock.setItem.mock.calls.at(-1).at(1));
expect(storedData).toEqual({
enabled: true,
lastResponseDate: expect.any(String),
firstDismissalDate: null,
lastDismissalDate: null,
});
expect(new Date(storedData.lastResponseDate)).toBeInstanceOf(Date);
});
it('show error message if request fails and keep survey open', async () => {
server.use(
rest.post('*/submit-nps', (req, res, ctx) => {
return res.once(ctx.status(500));
})
);
localStorageMock.getItem.mockReturnValueOnce({ enabled: true });
setup();
act(() => jest.runAllTimers());
fireEvent.click(screen.getByRole('radio', { name: '10' }));
expect(screen.getByRole('button', { name: /submit feedback/i }));
act(() => {
fireEvent.submit(screen.getByRole('form'));
});
await waitFor(() => {
expect(screen.queryByText(/not at all likely/i)).toBeInTheDocument();
expect(screen.queryByText(/thank you very much for your feedback!/i)).not.toBeInTheDocument();
expect(toggleNotification).toHaveBeenCalledWith({
type: 'warning',
message: 'notification.error',
});
});
});
it('saves first user dismissal', async () => {
localStorageMock.getItem.mockReturnValueOnce({ enabled: true });
setup();
act(() => jest.runAllTimers());
await user.click(screen.getByText(/dismiss survey/i));
expect(screen.queryByText(/not at all likely/i)).not.toBeInTheDocument();
const storedData = JSON.parse(localStorageMock.setItem.mock.calls.at(-1).at(1));
expect(storedData).toEqual({
enabled: true,
lastResponseDate: null,
firstDismissalDate: expect.any(String),
});
expect(new Date(storedData.firstDismissalDate)).toBeInstanceOf(Date);
});
it('saves subsequent user dismissal', async () => {
const firstDismissalDate = '2000-07-20T09:28:51.963Z';
localStorageMock.getItem.mockReturnValueOnce({
enabled: true,
firstDismissalDate,
});
setup();
act(() => jest.runAllTimers());
await user.click(screen.getByText(/dismiss survey/i));
expect(screen.queryByText(/not at all likely/i)).not.toBeInTheDocument();
const storedData = JSON.parse(localStorageMock.setItem.mock.calls.at(-1).at(1));
expect(storedData).toEqual({
enabled: true,
lastResponseDate: null,
firstDismissalDate,
lastDismissalDate: expect.any(String),
});
expect(new Date(storedData.lastDismissalDate)).toBeInstanceOf(Date);
});
it('respects the delay after user submission', async () => {
const initialDate = new Date('2020-01-01');
const withinDelay = new Date('2020-01-31');
const beyondDelay = new Date('2020-03-31');
localStorageMock.getItem.mockReturnValue({ enabled: true, lastResponseDate: initialDate });
jest.setSystemTime(initialDate);
// Survey should not show up right after submission
setup();
act(() => jest.runAllTimers());
expect(screen.queryByText(/not at all likely/i)).not.toBeInTheDocument();
// Survey should not show up during delay
jest.advanceTimersByTime(withinDelay - initialDate);
setup();
act(() => jest.runAllTimers());
expect(screen.queryByText(/not at all likely/i)).not.toBeInTheDocument();
// Survey should show up again after delay
jest.advanceTimersByTime(beyondDelay - withinDelay);
setup();
act(() => jest.runAllTimers());
expect(screen.getByText(/not at all likely/i)).toBeInTheDocument();
});
it('respects the delay after first user dismissal', async () => {
const initialDate = new Date('2020-01-01');
const withinDelay = new Date('2020-01-04');
const beyondDelay = new Date('2020-01-08');
localStorageMock.getItem.mockReturnValue({
enabled: true,
firstDismissalDate: initialDate,
lastDismissalDate: null,
lastResponseDate: null,
});
jest.setSystemTime(initialDate);
// Survey should not show up right after dismissal
setup();
act(() => jest.runAllTimers());
expect(screen.queryByText(/not at all likely/i)).not.toBeInTheDocument();
// Survey should not show up during delay
jest.advanceTimersByTime(withinDelay - initialDate);
setup();
act(() => jest.runAllTimers());
expect(screen.queryByText(/not at all likely/i)).not.toBeInTheDocument();
// Survey should show up again after delay
jest.advanceTimersByTime(beyondDelay - withinDelay);
setup();
act(() => jest.runAllTimers());
expect(screen.getByText(/not at all likely/i)).toBeInTheDocument();
jest.useRealTimers();
});
it('respects the delay after subsequent user dismissal', async () => {
const initialDate = new Date('2020-01-01');
const withinDelay = new Date('2020-03-30');
const beyondDelay = new Date('2020-04-01');
localStorageMock.getItem.mockReturnValue({
enabled: true,
firstDismissalDate: initialDate,
lastDismissalDate: initialDate,
lastResponseDate: null,
});
jest.setSystemTime(initialDate);
// Survey should not show up right after dismissal
setup();
act(() => jest.runAllTimers());
expect(screen.queryByText(/not at all likely/i)).not.toBeInTheDocument();
// Survey should not show up during delay
jest.advanceTimersByTime(withinDelay - initialDate);
setup();
act(() => jest.runAllTimers());
expect(screen.queryByText(/not at all likely/i)).not.toBeInTheDocument();
// Survey should show up again after delay
jest.advanceTimersByTime(beyondDelay - withinDelay);
setup();
act(() => jest.runAllTimers());
expect(screen.getByText(/not at all likely/i)).toBeInTheDocument();
});
afterAll(() => {
global.localStorage = originalLocalStorage;
});
});

View File

@ -29,6 +29,7 @@ import { useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import styled from 'styled-components';
import { useNpsSurveySettings } from '../../../../components/NpsSurvey/hooks/useNpsSurveySettings';
import Logo from '../../../../components/UnauthenticatedLogo';
import UnauthenticatedLayout, { LayoutContent } from '../../../../layouts/UnauthenticatedLayout';
import FieldActionWrapper from '../FieldActionWrapper';
@ -55,6 +56,7 @@ const Register = ({ authType, fieldsToDisable, noSignin, onSubmit, schema }) =>
const query = useQuery();
const { formatAPIError } = useAPIErrorHandler();
const { get } = useFetchClient();
const { setNpsSurveySettings } = useNpsSurveySettings();
const registrationToken = query.get('registrationToken');
@ -143,6 +145,9 @@ const Register = ({ authType, fieldsToDisable, noSignin, onSubmit, schema }) =>
} else {
onSubmit(normalizedData, formik);
}
// Only enable EE survey if user accepted the newsletter
setNpsSurveySettings({ enabled: data.news });
} catch (err) {
const errors = getYupInnerErrors(err);
setSubmitCount(submitCount + 1);

View File

@ -479,6 +479,13 @@
"app.components.MarketplaceBanner.link": "Check it out now",
"app.components.NotFoundPage.back": "Back to homepage",
"app.components.NotFoundPage.description": "Not Found",
"app.components.NpsSurvey.banner-title": "How likely are you to recommend Strapi to a friend or colleague?",
"app.components.NpsSurvey.feedback-response": "Thank you very much for your feedback!",
"app.components.NpsSurvey.feedback-question": "Do you have any suggestion for improvements?",
"app.components.NpsSurvey.submit-feedback": "Submit Feedback",
"app.components.NpsSurvey.dismiss-survey-label": "Dismiss survey",
"app.components.NpsSurvey.no-recommendation": "Not at all likely",
"app.components.NpsSurvey.happy-to-recommend": "Extremely likely",
"app.components.Official": "Official",
"app.components.Onboarding.help.button": "Help button",
"app.components.Onboarding.label.completed": "% completed",