mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-10 01:16:39 +02:00
feat: add FeedbackCES component (#826)
* refactor: add screen-reader-only util class * refactor: move FeedbackNPS component * feat: add FeedbackCES component * refactor: improve hidden checkbox styles * refactor: fix IFeedbackEndpointRequestBody source type * refactor: remove unnecessary event.persist() calls * refactor: remove disableEscapeKeyDown from FeedbackCES modal * refactor: make textarea label customizable * refactor: store feedback state on the backend * refactor: add FeedbackCESForm snapshot test * refactor: use extant IAuthFeedback type * refactor: fix showNPSFeedback logic for multiple feedback types
This commit is contained in:
parent
2377def561
commit
dbb62631a6
@ -1,5 +1,5 @@
|
||||
import ConditionallyRender from 'component/common/ConditionallyRender';
|
||||
import Feedback from 'component/common/Feedback/Feedback';
|
||||
import { FeedbackNPS } from 'component/feedback/FeedbackNPS/FeedbackNPS';
|
||||
import LayoutPicker from 'component/layout/LayoutPicker/LayoutPicker';
|
||||
import Loader from 'component/common/Loader/Loader';
|
||||
import NotFound from 'component/common/NotFound/NotFound';
|
||||
@ -72,7 +72,7 @@ export const App = () => {
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Redirect to="/404" />
|
||||
</Switch>
|
||||
<Feedback openUrl="http://feedback.unleash.run" />
|
||||
<FeedbackNPS openUrl="http://feedback.unleash.run" />
|
||||
<SplashPageRedirect />
|
||||
</LayoutPicker>
|
||||
</div>
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { weightTypes } from '../feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/enums';
|
||||
import differenceInDays from 'date-fns/differenceInDays';
|
||||
|
||||
export const filterByFlags = flags => r => {
|
||||
if (r.flag && !flags[r.flag]) {
|
||||
@ -80,30 +79,3 @@ export const modalStyles = {
|
||||
transform: 'translate(-50%, -50%)',
|
||||
},
|
||||
};
|
||||
|
||||
export const showPnpsFeedback = feedbackList => {
|
||||
if (!feedbackList) return;
|
||||
if (feedbackList.length > 0) {
|
||||
const feedback = feedbackList.find(
|
||||
feedback => feedback.feedbackId === PNPS_FEEDBACK_ID
|
||||
);
|
||||
|
||||
if (!feedback) return false;
|
||||
|
||||
if (feedback.neverShow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (feedback.given) {
|
||||
const SIX_MONTHS_IN_DAYS = 182;
|
||||
const now = new Date();
|
||||
const difference = differenceInDays(now, new Date(feedback.given));
|
||||
|
||||
return difference > SIX_MONTHS_IN_DAYS;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const PNPS_FEEDBACK_ID = 'pnps';
|
||||
|
@ -0,0 +1,37 @@
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
export const useStyles = makeStyles(theme => ({
|
||||
overlay: {
|
||||
pointerEvents: 'none',
|
||||
display: 'grid',
|
||||
padding: '1rem',
|
||||
overflowY: 'auto',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
},
|
||||
modal: {
|
||||
pointerEvents: 'auto',
|
||||
position: 'relative',
|
||||
padding: '4rem',
|
||||
background: 'white',
|
||||
boxShadow: '0 0 1rem rgba(0, 0, 0, 0.25)',
|
||||
borderRadius: '1rem',
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
padding: '2rem',
|
||||
},
|
||||
},
|
||||
close: {
|
||||
all: 'unset',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
padding: '1rem',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
closeIcon: {
|
||||
fontSize: '1.5rem',
|
||||
color: theme.palette.grey[600],
|
||||
},
|
||||
}));
|
43
frontend/src/component/feedback/FeedbackCES/FeedbackCES.tsx
Normal file
43
frontend/src/component/feedback/FeedbackCES/FeedbackCES.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { Modal } from '@material-ui/core';
|
||||
import React, { useContext } from 'react';
|
||||
import {
|
||||
feedbackCESContext,
|
||||
IFeedbackCESState,
|
||||
} from 'component/feedback/FeedbackCESContext/FeedbackCESContext';
|
||||
import { FeedbackCESForm } from 'component/feedback/FeedbackCES/FeedbackCESForm';
|
||||
import { useStyles } from 'component/feedback/FeedbackCES/FeedbackCES.styles';
|
||||
import { CloseOutlined } from '@material-ui/icons';
|
||||
|
||||
export interface IFeedbackCESProps {
|
||||
state?: IFeedbackCESState;
|
||||
}
|
||||
|
||||
export const FeedbackCES = ({ state }: IFeedbackCESProps) => {
|
||||
const { hideFeedbackCES } = useContext(feedbackCESContext);
|
||||
const styles = useStyles();
|
||||
|
||||
const closeButton = (
|
||||
<button className={styles.close} onClick={hideFeedbackCES}>
|
||||
<CloseOutlined titleAccess="Close" className={styles.closeIcon} />
|
||||
</button>
|
||||
);
|
||||
|
||||
const modalContent = state && (
|
||||
<FeedbackCESForm state={state} onClose={hideFeedbackCES} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={Boolean(state)}
|
||||
onClose={hideFeedbackCES}
|
||||
aria-label={state?.title}
|
||||
>
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.modal}>
|
||||
{closeButton}
|
||||
{modalContent}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
@ -0,0 +1,36 @@
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
export const useStyles = makeStyles(theme => ({
|
||||
container: {
|
||||
fontWeight: theme.fontWeight.thin,
|
||||
},
|
||||
form: {
|
||||
display: 'grid',
|
||||
gap: '3rem',
|
||||
gridTemplateColumns: 'minmax(auto, 40rem)',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
all: 'unset',
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
color: theme.palette.grey[600],
|
||||
},
|
||||
subtitle: {
|
||||
all: 'unset',
|
||||
display: 'block',
|
||||
marginTop: '2.5rem',
|
||||
fontSize: '1.5rem',
|
||||
textAlign: 'center',
|
||||
},
|
||||
textLabel: {
|
||||
display: 'block',
|
||||
marginBottom: '0.5rem',
|
||||
},
|
||||
buttons: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
button: {
|
||||
minWidth: '15rem',
|
||||
},
|
||||
}));
|
@ -0,0 +1,21 @@
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { FeedbackCESForm } from './FeedbackCESForm';
|
||||
import mainTheme from 'themes/mainTheme';
|
||||
|
||||
test('FeedbackCESForm', () => {
|
||||
const onClose = () => {
|
||||
throw new Error('Unexpected onClose call.');
|
||||
};
|
||||
|
||||
const tree = renderer.create(
|
||||
<ThemeProvider theme={mainTheme}>
|
||||
<FeedbackCESForm
|
||||
onClose={onClose}
|
||||
state={{ title: 'a', text: 'b', path: '/c' }}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
@ -0,0 +1,96 @@
|
||||
import { useStyles } from 'component/feedback/FeedbackCES/FeedbackCESForm.styles';
|
||||
import { Button, TextField } from '@material-ui/core';
|
||||
import React, { useState } from 'react';
|
||||
import produce from 'immer';
|
||||
import useToast from 'hooks/useToast';
|
||||
import { IFeedbackCESState } from 'component/feedback/FeedbackCESContext/FeedbackCESContext';
|
||||
import { FeedbackCESScore } from 'component/feedback/FeedbackCES/FeedbackCESScore';
|
||||
import { sendFeedbackInput } from 'component/feedback/FeedbackCES/sendFeedbackInput';
|
||||
|
||||
export interface IFeedbackCESFormProps {
|
||||
state: IFeedbackCESState;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface IFeedbackCESForm {
|
||||
score: number;
|
||||
comment: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export const FeedbackCESForm = ({ state, onClose }: IFeedbackCESFormProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { setToastData } = useToast();
|
||||
const styles = useStyles();
|
||||
|
||||
const [form, setForm] = useState<Partial<IFeedbackCESForm>>({
|
||||
path: state.path,
|
||||
});
|
||||
|
||||
const onCommentChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setForm(
|
||||
produce(draft => {
|
||||
draft.comment = event.target.value;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await sendFeedbackInput(form);
|
||||
setToastData({
|
||||
type: 'success',
|
||||
title: 'Feedback sent. Thank you!',
|
||||
confetti: true,
|
||||
});
|
||||
onClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.title}>Please help us improve</h1>
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={onSubmit}
|
||||
aria-live="polite"
|
||||
>
|
||||
<p className={styles.subtitle}>{state.title}</p>
|
||||
<FeedbackCESScore form={form} setForm={setForm} />
|
||||
<div hidden={!form.score}>
|
||||
<label htmlFor="comment" className={styles.textLabel}>
|
||||
{state.text}
|
||||
</label>
|
||||
<TextField
|
||||
value={form.comment ?? ''}
|
||||
onChange={onCommentChange}
|
||||
multiline
|
||||
rows={3}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.buttons} hidden={!form.score}>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
variant="contained"
|
||||
className={styles.button}
|
||||
disabled={!form.score || loading}
|
||||
>
|
||||
Send feedback
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,55 @@
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
export const useStyles = makeStyles(theme => ({
|
||||
scoreInput: {
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
alignItems: 'center',
|
||||
margin: '0 auto',
|
||||
},
|
||||
scoreHelp: {
|
||||
width: '8rem',
|
||||
whiteSpace: 'nowrap',
|
||||
color: theme.palette.grey[600],
|
||||
'&:first-child': {
|
||||
textAlign: 'right',
|
||||
},
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
scoreValue: {
|
||||
'& input': {
|
||||
clip: 'rect(0 0 0 0)',
|
||||
clipPath: 'inset(50%)',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
whiteSpace: 'nowrap',
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
'& span': {
|
||||
display: 'grid',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: theme.palette.grey[300],
|
||||
width: '3rem',
|
||||
height: '3rem',
|
||||
borderRadius: '10rem',
|
||||
fontSize: '1.25rem',
|
||||
paddingBottom: 2,
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'& input:checked + span': {
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
background: theme.palette.primary.main,
|
||||
color: 'white',
|
||||
},
|
||||
'& input:focus-visible + span': {
|
||||
outline: '2px solid',
|
||||
outlineOffset: 2,
|
||||
outlineColor: theme.palette.primary.main,
|
||||
},
|
||||
},
|
||||
}));
|
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import produce from 'immer';
|
||||
import { useStyles } from 'component/feedback/FeedbackCES/FeedbackCESScore.styles';
|
||||
import { IFeedbackCESForm } from 'component/feedback/FeedbackCES/FeedbackCESForm';
|
||||
|
||||
interface IFeedbackCESScoreProps {
|
||||
form: Partial<IFeedbackCESForm>;
|
||||
setForm: React.Dispatch<React.SetStateAction<Partial<IFeedbackCESForm>>>;
|
||||
}
|
||||
|
||||
export const FeedbackCESScore = ({ form, setForm }: IFeedbackCESScoreProps) => {
|
||||
const styles = useStyles();
|
||||
|
||||
const onScoreChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm(
|
||||
produce(draft => {
|
||||
draft.score = Number(event.target.value);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.scoreInput}>
|
||||
<span className={styles.scoreHelp}>Very difficult</span>
|
||||
{[1, 2, 3, 4, 5, 6, 7].map(score => (
|
||||
<label key={score} className={styles.scoreValue}>
|
||||
<input
|
||||
type="radio"
|
||||
name="score"
|
||||
value={score}
|
||||
checked={form.score === score}
|
||||
onChange={onScoreChange}
|
||||
/>
|
||||
<span>{score}</span>
|
||||
</label>
|
||||
))}
|
||||
<span className={styles.scoreHelp}>Very easy</span>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,222 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`FeedbackCESForm 1`] = `
|
||||
<div
|
||||
className="makeStyles-container-1"
|
||||
>
|
||||
<h1
|
||||
className="makeStyles-title-3"
|
||||
>
|
||||
Please help us improve
|
||||
</h1>
|
||||
<form
|
||||
aria-live="polite"
|
||||
className="makeStyles-form-2"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<p
|
||||
className="makeStyles-subtitle-4"
|
||||
>
|
||||
a
|
||||
</p>
|
||||
<div
|
||||
className="makeStyles-scoreInput-8"
|
||||
>
|
||||
<span
|
||||
className="makeStyles-scoreHelp-9"
|
||||
>
|
||||
Very difficult
|
||||
</span>
|
||||
<label
|
||||
className="makeStyles-scoreValue-10"
|
||||
>
|
||||
<input
|
||||
checked={false}
|
||||
name="score"
|
||||
onChange={[Function]}
|
||||
type="radio"
|
||||
value={1}
|
||||
/>
|
||||
<span>
|
||||
1
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="makeStyles-scoreValue-10"
|
||||
>
|
||||
<input
|
||||
checked={false}
|
||||
name="score"
|
||||
onChange={[Function]}
|
||||
type="radio"
|
||||
value={2}
|
||||
/>
|
||||
<span>
|
||||
2
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="makeStyles-scoreValue-10"
|
||||
>
|
||||
<input
|
||||
checked={false}
|
||||
name="score"
|
||||
onChange={[Function]}
|
||||
type="radio"
|
||||
value={3}
|
||||
/>
|
||||
<span>
|
||||
3
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="makeStyles-scoreValue-10"
|
||||
>
|
||||
<input
|
||||
checked={false}
|
||||
name="score"
|
||||
onChange={[Function]}
|
||||
type="radio"
|
||||
value={4}
|
||||
/>
|
||||
<span>
|
||||
4
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="makeStyles-scoreValue-10"
|
||||
>
|
||||
<input
|
||||
checked={false}
|
||||
name="score"
|
||||
onChange={[Function]}
|
||||
type="radio"
|
||||
value={5}
|
||||
/>
|
||||
<span>
|
||||
5
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="makeStyles-scoreValue-10"
|
||||
>
|
||||
<input
|
||||
checked={false}
|
||||
name="score"
|
||||
onChange={[Function]}
|
||||
type="radio"
|
||||
value={6}
|
||||
/>
|
||||
<span>
|
||||
6
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="makeStyles-scoreValue-10"
|
||||
>
|
||||
<input
|
||||
checked={false}
|
||||
name="score"
|
||||
onChange={[Function]}
|
||||
type="radio"
|
||||
value={7}
|
||||
/>
|
||||
<span>
|
||||
7
|
||||
</span>
|
||||
</label>
|
||||
<span
|
||||
className="makeStyles-scoreHelp-9"
|
||||
>
|
||||
Very easy
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
hidden={true}
|
||||
>
|
||||
<label
|
||||
className="makeStyles-textLabel-5"
|
||||
htmlFor="comment"
|
||||
>
|
||||
b
|
||||
</label>
|
||||
<div
|
||||
className="MuiFormControl-root MuiTextField-root MuiFormControl-fullWidth"
|
||||
>
|
||||
<div
|
||||
className="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-fullWidth MuiInputBase-formControl MuiInputBase-multiline MuiOutlinedInput-multiline"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<textarea
|
||||
aria-invalid={false}
|
||||
autoFocus={false}
|
||||
className="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputMultiline MuiOutlinedInput-inputMultiline"
|
||||
disabled={false}
|
||||
onAnimationStart={[Function]}
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
required={false}
|
||||
rows={3}
|
||||
value=""
|
||||
/>
|
||||
<fieldset
|
||||
aria-hidden={true}
|
||||
className="PrivateNotchedOutline-root-11 MuiOutlinedInput-notchedOutline"
|
||||
style={
|
||||
Object {
|
||||
"paddingLeft": 8,
|
||||
}
|
||||
}
|
||||
>
|
||||
<legend
|
||||
className="PrivateNotchedOutline-legend-12"
|
||||
style={
|
||||
Object {
|
||||
"width": 0.01,
|
||||
}
|
||||
}
|
||||
>
|
||||
<span
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "​",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</legend>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="makeStyles-buttons-6"
|
||||
hidden={true}
|
||||
>
|
||||
<button
|
||||
className="MuiButtonBase-root MuiButton-root MuiButton-contained makeStyles-button-7 MuiButton-containedPrimary Mui-disabled Mui-disabled"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onDragLeave={[Function]}
|
||||
onFocus={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onKeyUp={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseLeave={[Function]}
|
||||
onMouseUp={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
onTouchMove={[Function]}
|
||||
onTouchStart={[Function]}
|
||||
tabIndex={-1}
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
className="MuiButton-label"
|
||||
>
|
||||
Send feedback
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
@ -0,0 +1,40 @@
|
||||
import { IFeedbackCESForm } from 'component/feedback/FeedbackCES/FeedbackCESForm';
|
||||
|
||||
interface IFeedbackEndpointRequestBody {
|
||||
source: 'app' | 'app:segments';
|
||||
data: {
|
||||
score: number;
|
||||
comment?: string;
|
||||
customerType?: 'open source' | 'paying';
|
||||
openedManually?: boolean;
|
||||
currentPage?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const sendFeedbackInput = async (
|
||||
form: Partial<IFeedbackCESForm>
|
||||
): Promise<void> => {
|
||||
if (!form.score) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body: IFeedbackEndpointRequestBody = {
|
||||
source: 'app:segments',
|
||||
data: {
|
||||
score: form.score,
|
||||
comment: form.comment,
|
||||
currentPage: form.path,
|
||||
openedManually: false,
|
||||
customerType: 'paying',
|
||||
},
|
||||
};
|
||||
|
||||
await fetch(
|
||||
'https://europe-west3-docs-feedback-v1.cloudfunctions.net/function-1',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
};
|
@ -0,0 +1,29 @@
|
||||
import React, { createContext } from 'react';
|
||||
|
||||
export type ShowFeedbackCES = React.Dispatch<
|
||||
React.SetStateAction<IFeedbackCESState | undefined>
|
||||
>;
|
||||
|
||||
export interface IFeedbackCESContext {
|
||||
showFeedbackCES: ShowFeedbackCES;
|
||||
hideFeedbackCES: () => void;
|
||||
}
|
||||
|
||||
export interface IFeedbackCESState {
|
||||
path: `/${string}`;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const showFeedbackCESPlaceholder = () => {
|
||||
throw new Error('showFeedbackCES called outside feedbackCESContext');
|
||||
};
|
||||
|
||||
const hideFeedbackCESPlaceholder = () => {
|
||||
throw new Error('hideFeedbackCES called outside feedbackCESContext');
|
||||
};
|
||||
|
||||
export const feedbackCESContext = createContext<IFeedbackCESContext>({
|
||||
showFeedbackCES: showFeedbackCESPlaceholder,
|
||||
hideFeedbackCES: hideFeedbackCESPlaceholder,
|
||||
});
|
@ -0,0 +1,60 @@
|
||||
import React, {
|
||||
useState,
|
||||
ReactNode,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { FeedbackCES } from 'component/feedback/FeedbackCES/FeedbackCES';
|
||||
import {
|
||||
feedbackCESContext,
|
||||
ShowFeedbackCES,
|
||||
IFeedbackCESContext,
|
||||
IFeedbackCESState,
|
||||
} from 'component/feedback/FeedbackCESContext/FeedbackCESContext';
|
||||
import { useFeedbackCESSeen } from 'component/feedback/FeedbackCESContext/useFeedbackCESSeen';
|
||||
import { useFeedbackCESEnabled } from 'component/feedback/FeedbackCESContext/useFeedbackCESEnabled';
|
||||
|
||||
interface IFeedbackProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const FeedbackCESProvider = ({ children }: IFeedbackProviderProps) => {
|
||||
const [state, setState] = useState<IFeedbackCESState>();
|
||||
const { isSeen, setSeen } = useFeedbackCESSeen();
|
||||
const enabled = useFeedbackCESEnabled();
|
||||
|
||||
useEffect(() => {
|
||||
state && setSeen(state);
|
||||
}, [state, setSeen]);
|
||||
|
||||
// Set a new feedback state iff the path is unseen and CES is enabled.
|
||||
const showFeedbackCES: ShowFeedbackCES = useCallback(
|
||||
value => {
|
||||
setState(prev => {
|
||||
const next = value instanceof Function ? value(prev) : value;
|
||||
return !enabled || !next || isSeen(next) ? undefined : next;
|
||||
});
|
||||
},
|
||||
[enabled, isSeen]
|
||||
);
|
||||
|
||||
const hideFeedbackCES = useCallback(() => {
|
||||
setState(undefined);
|
||||
}, [setState]);
|
||||
|
||||
const value: IFeedbackCESContext = useMemo(
|
||||
() => ({
|
||||
showFeedbackCES: showFeedbackCES,
|
||||
hideFeedbackCES: hideFeedbackCES,
|
||||
}),
|
||||
[showFeedbackCES, hideFeedbackCES]
|
||||
);
|
||||
|
||||
return (
|
||||
<feedbackCESContext.Provider value={value}>
|
||||
{children}
|
||||
<FeedbackCES state={state} />
|
||||
</feedbackCESContext.Provider>
|
||||
);
|
||||
};
|
@ -0,0 +1,10 @@
|
||||
export const useFeedbackCESEnabled = (): boolean => {
|
||||
const { hostname } = window.location;
|
||||
|
||||
return (
|
||||
hostname === 'localhost' ||
|
||||
hostname.endsWith('.vercel.app') ||
|
||||
hostname.endsWith('.getunleash.io') ||
|
||||
hostname.endsWith('.unleash-hosted.com')
|
||||
);
|
||||
};
|
@ -0,0 +1,36 @@
|
||||
import { useAuthFeedback } from 'hooks/api/getters/useAuth/useAuthFeedback';
|
||||
import { useAuthFeedbackApi } from 'hooks/api/actions/useAuthFeedbackApi/useAuthFeedbackApi';
|
||||
import { IFeedbackCESState } from 'component/feedback/FeedbackCESContext/FeedbackCESContext';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
interface IUseFeedbackCESSeen {
|
||||
setSeen: (state: IFeedbackCESState) => void;
|
||||
isSeen: (state: IFeedbackCESState) => boolean;
|
||||
}
|
||||
|
||||
export const useFeedbackCESSeen = (): IUseFeedbackCESSeen => {
|
||||
const { createFeedback } = useAuthFeedbackApi();
|
||||
const { feedback } = useAuthFeedback();
|
||||
|
||||
const isSeen = useCallback(
|
||||
(state: IFeedbackCESState) =>
|
||||
!!feedback &&
|
||||
feedback.some(f => f.feedbackId === formatFeedbackCESId(state)),
|
||||
[feedback]
|
||||
);
|
||||
|
||||
const setSeen = useCallback(
|
||||
(state: IFeedbackCESState) =>
|
||||
createFeedback({ feedbackId: formatFeedbackCESId(state) }),
|
||||
[createFeedback]
|
||||
);
|
||||
|
||||
return {
|
||||
isSeen,
|
||||
setSeen,
|
||||
};
|
||||
};
|
||||
|
||||
const formatFeedbackCESId = (state: IFeedbackCESState): string => {
|
||||
return `ces${state.path}`;
|
||||
};
|
@ -4,44 +4,37 @@ import classnames from 'classnames';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import { ReactComponent as Logo } from 'assets/icons/logoPlain.svg';
|
||||
import { useCommonStyles } from 'common.styles';
|
||||
import { useStyles } from './Feedback.styles';
|
||||
import AnimateOnMount from '../AnimateOnMount/AnimateOnMount';
|
||||
import ConditionallyRender from '../ConditionallyRender';
|
||||
import { formatApiPath } from 'utils/formatPath';
|
||||
import { useStyles } from 'component/feedback/FeedbackNPS/FeedbackNPS.styles';
|
||||
import AnimateOnMount from 'component/common/AnimateOnMount/AnimateOnMount';
|
||||
import ConditionallyRender from 'component/common/ConditionallyRender';
|
||||
import UIContext from 'contexts/UIContext';
|
||||
import { PNPS_FEEDBACK_ID, showPnpsFeedback } from '../util';
|
||||
import {
|
||||
PNPS_FEEDBACK_ID,
|
||||
showNPSFeedback,
|
||||
} from 'component/feedback/FeedbackNPS/showNPSFeedback';
|
||||
import { useAuthFeedback } from 'hooks/api/getters/useAuth/useAuthFeedback';
|
||||
import { useAuthFeedbackApi } from 'hooks/api/actions/useAuthFeedbackApi/useAuthFeedbackApi';
|
||||
|
||||
interface IFeedbackProps {
|
||||
interface IFeedbackNPSProps {
|
||||
openUrl: string;
|
||||
}
|
||||
|
||||
const Feedback = ({ openUrl }: IFeedbackProps) => {
|
||||
export const FeedbackNPS = ({ openUrl }: IFeedbackNPSProps) => {
|
||||
const { showFeedback, setShowFeedback } = useContext(UIContext);
|
||||
const { feedback, refetchFeedback } = useAuthFeedback();
|
||||
const { createFeedback, updateFeedback } = useAuthFeedbackApi();
|
||||
const { feedback } = useAuthFeedback();
|
||||
const [answeredNotNow, setAnsweredNotNow] = useState(false);
|
||||
const styles = useStyles();
|
||||
const commonStyles = useCommonStyles();
|
||||
const feedbackId = PNPS_FEEDBACK_ID;
|
||||
|
||||
const onConfirm = async () => {
|
||||
const url = formatApiPath('api/admin/feedback');
|
||||
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ feedbackId }),
|
||||
});
|
||||
await refetchFeedback();
|
||||
await createFeedback({ feedbackId });
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
setShowFeedback(false);
|
||||
}
|
||||
|
||||
// Await api call to register confirmation
|
||||
window.open(openUrl, '_blank');
|
||||
setTimeout(() => {
|
||||
@ -50,32 +43,18 @@ const Feedback = ({ openUrl }: IFeedbackProps) => {
|
||||
};
|
||||
|
||||
const onDontShowAgain = async () => {
|
||||
const feedbackId = 'pnps';
|
||||
const url = formatApiPath(
|
||||
`api/admin/feedback/${encodeURIComponent(feedbackId)}`
|
||||
);
|
||||
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ feedbackId, neverShow: true }),
|
||||
});
|
||||
await refetchFeedback();
|
||||
await updateFeedback({ feedbackId, neverShow: true });
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
setShowFeedback(false);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setShowFeedback(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
if (!showPnpsFeedback(feedback)) {
|
||||
if (!showNPSFeedback(feedback)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -152,5 +131,3 @@ const Feedback = ({ openUrl }: IFeedbackProps) => {
|
||||
</AnimateOnMount>
|
||||
);
|
||||
};
|
||||
|
||||
export default Feedback;
|
@ -0,0 +1,37 @@
|
||||
import differenceInDays from 'date-fns/differenceInDays';
|
||||
import { IAuthFeedback } from 'hooks/api/getters/useAuth/useAuthEndpoint';
|
||||
|
||||
export const PNPS_FEEDBACK_ID = 'pnps';
|
||||
|
||||
export const showNPSFeedback = (
|
||||
feedbackList: IAuthFeedback[] | undefined
|
||||
): boolean => {
|
||||
if (!feedbackList) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (feedbackList.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const feedback = feedbackList.find(
|
||||
feedback => feedback.feedbackId === PNPS_FEEDBACK_ID
|
||||
);
|
||||
|
||||
if (!feedback) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (feedback.neverShow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (feedback.given) {
|
||||
const SIX_MONTHS_IN_DAYS = 182;
|
||||
const now = new Date();
|
||||
const difference = differenceInDays(now, new Date(feedback.given));
|
||||
return difference > SIX_MONTHS_IN_DAYS;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
@ -6,15 +6,17 @@ import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValida
|
||||
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import useToast from 'hooks/useToast';
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||
import { useSegmentForm } from '../hooks/useSegmentForm';
|
||||
import { SegmentForm } from '../SegmentForm/SegmentForm';
|
||||
import { feedbackCESContext } from 'component/feedback/FeedbackCESContext/FeedbackCESContext';
|
||||
|
||||
export const CreateSegment = () => {
|
||||
const { uiConfig } = useUiConfig();
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const { showFeedbackCES } = useContext(feedbackCESContext);
|
||||
const history = useHistory();
|
||||
const { createSegment, loading } = useSegmentsApi();
|
||||
const { refetchSegments } = useSegments();
|
||||
@ -54,6 +56,11 @@ export const CreateSegment = () => {
|
||||
confetti: true,
|
||||
type: 'success',
|
||||
});
|
||||
showFeedbackCES({
|
||||
title: 'How easy was it to create a segment?',
|
||||
text: 'Please help us understand how we can improve segments',
|
||||
path: '/segments/create',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
|
@ -0,0 +1,48 @@
|
||||
import { formatApiPath } from 'utils/formatPath';
|
||||
import { useCallback } from 'react';
|
||||
import { useAuthFeedback } from 'hooks/api/getters/useAuth/useAuthFeedback';
|
||||
import { IAuthFeedback } from 'hooks/api/getters/useAuth/useAuthEndpoint';
|
||||
|
||||
interface IUseAuthFeedbackApi {
|
||||
createFeedback: (feedback: IAuthFeedback) => Promise<void>;
|
||||
updateFeedback: (feedback: IAuthFeedback) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthFeedbackApi = (): IUseAuthFeedbackApi => {
|
||||
const { refetchFeedback } = useAuthFeedback();
|
||||
const path = formatApiPath('api/admin/feedback');
|
||||
|
||||
const createFeedback = useCallback(
|
||||
async (feedback: IAuthFeedback): Promise<void> => {
|
||||
await sendFeedback('POST', path, feedback);
|
||||
await refetchFeedback();
|
||||
},
|
||||
[path, refetchFeedback]
|
||||
);
|
||||
|
||||
const updateFeedback = useCallback(
|
||||
async (feedback: IAuthFeedback): Promise<void> => {
|
||||
const pathWithId = `${path}/${feedback.feedbackId}`;
|
||||
await sendFeedback('PUT', pathWithId, feedback);
|
||||
await refetchFeedback();
|
||||
},
|
||||
[path, refetchFeedback]
|
||||
);
|
||||
|
||||
return {
|
||||
createFeedback,
|
||||
updateFeedback,
|
||||
};
|
||||
};
|
||||
|
||||
const sendFeedback = async (
|
||||
method: 'PUT' | 'POST',
|
||||
path: string,
|
||||
feedback: IAuthFeedback
|
||||
): Promise<void> => {
|
||||
await fetch(path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(feedback),
|
||||
});
|
||||
};
|
@ -32,10 +32,10 @@ export interface IAuthOptions {
|
||||
}
|
||||
|
||||
export interface IAuthFeedback {
|
||||
neverShow: boolean;
|
||||
feedbackId: string;
|
||||
neverShow?: boolean;
|
||||
given?: string;
|
||||
userId: number;
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
export interface IAuthSplash {
|
||||
|
@ -13,6 +13,7 @@ import ScrollToTop from 'component/scrollToTop';
|
||||
import AccessProvider from './component/providers/AccessProvider/AccessProvider';
|
||||
import { getBasePath } from 'utils/formatPath';
|
||||
import UIProvider from './component/providers/UIProvider/UIProvider';
|
||||
import { FeedbackCESProvider } from 'component/feedback/FeedbackCESContext/FeedbackCESProvider';
|
||||
|
||||
ReactDOM.render(
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
@ -21,10 +22,12 @@ ReactDOM.render(
|
||||
<Router basename={`${getBasePath()}`}>
|
||||
<ThemeProvider theme={mainTheme}>
|
||||
<StylesProvider injectFirst>
|
||||
<CssBaseline />
|
||||
<ScrollToTop>
|
||||
<Route path="/" component={App} />
|
||||
</ScrollToTop>
|
||||
<FeedbackCESProvider>
|
||||
<CssBaseline />
|
||||
<ScrollToTop>
|
||||
<Route path="/" component={App} />
|
||||
</ScrollToTop>
|
||||
</FeedbackCESProvider>
|
||||
</StylesProvider>
|
||||
</ThemeProvider>
|
||||
</Router>
|
||||
|
Loading…
Reference in New Issue
Block a user