1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-31 01:16:01 +02:00

Feat/pnps (#306)

* feat: animation

* feat: setup api calls

* feat: try catch

* feat: refetch user

* fix: remove unused variables

* fix: call hideFeedback

* fix: return expression
This commit is contained in:
Fredrik Strand Oseberg 2021-06-07 10:29:08 +02:00 committed by GitHub
parent a17e2f29b2
commit 7fddf04398
12 changed files with 370 additions and 5 deletions

View File

@ -0,0 +1,6 @@
<svg width="23" height="23" viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.288513" y="0.288574" width="7.13461" height="22.4231" fill="#1A4049"/>
<rect x="15.5769" y="0.288574" width="7.13461" height="22.4231" fill="#1A4049"/>
<path d="M22.7115 15.5771L22.7115 22.7118L0.28841 22.7118L0.28841 15.5771L22.7115 15.5771Z" fill="#1A4049"/>
<rect x="15.5769" y="15.5771" width="7.13461" height="7.13461" fill="#817AFE"/>
</svg>

After

Width:  |  Height:  |  Size: 455 B

View File

@ -12,12 +12,14 @@ import styles from './styles.module.scss';
import IAuthStatus from '../interfaces/user';
import { useEffect } from 'react';
import NotFound from './common/NotFound/NotFound';
import Feedback from './common/Feedback';
interface IAppProps extends RouteComponentProps {
user: IAuthStatus;
fetchUiBootstrap: any;
feedback: any;
}
const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
const App = ({ location, user, fetchUiBootstrap, feedback }: IAppProps) => {
useEffect(() => {
fetchUiBootstrap();
/* eslint-disable-next-line */
@ -84,6 +86,10 @@ const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
<Route path="/404" component={NotFound} />
<Redirect to="/404" />
</Switch>
<Feedback
feedbackId="pnps"
openUrl="https://getunleash.ai/pnps"
/>
</LayoutPicker>
</div>
);
@ -91,6 +97,7 @@ const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
// Set state to any for now, to avoid typing up entire state object while converting to tsx.
const mapStateToProps = (state: any) => ({
user: state.user.toJS(),
feedback: state.feedback,
});
export default connect(mapStateToProps)(App);

View File

@ -0,0 +1,60 @@
import { useEffect, useState, useRef, FC } from 'react';
import ConditionallyRender from '../ConditionallyRender';
interface IAnimateOnMountProps {
mounted: boolean;
enter: string;
start: string;
leave: string;
container?: string;
}
const AnimateOnMount: FC<IAnimateOnMountProps> = ({
mounted,
enter,
start,
leave,
container,
children,
}) => {
const [show, setShow] = useState(mounted);
const [styles, setStyles] = useState('');
const mountedRef = useRef<null | boolean>(null);
useEffect(() => {
if (mountedRef.current !== mounted || mountedRef === null) {
if (mounted) {
setShow(true);
setTimeout(() => {
setStyles(enter);
}, 50);
} else {
setStyles(leave);
}
}
}, [mounted, enter, leave]);
const onTransitionEnd = () => {
if (!mounted) {
setShow(false);
}
};
return (
<ConditionallyRender
condition={show}
show={
<div
className={`${start} ${styles} ${
container ? container : ''
}`}
onTransitionEnd={onTransitionEnd}
>
{children}
</div>
}
/>
);
};
export default AnimateOnMount;

View File

@ -0,0 +1,56 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
feedback: {
borderRadius: '3px',
backgroundColor: '#fff',
zIndex: '9999',
boxShadow: '2px 2px 4px 4px rgba(143,143,143, 0.25)',
padding: '1.5rem',
maxWidth: '400px',
width: '400px',
height: '200px',
},
animateContainer: {
zIndex: '9999',
},
feedbackStart: {
opacity: '0',
position: 'fixed',
right: '40px',
bottom: '40px',
transform: 'translateY(400px)',
},
feedbackEnter: {
transform: 'translateY(0)',
opacity: '1',
transition: 'transform 0.6s ease, opacity 1s ease',
},
feedbackLeave: {
transform: 'translateY(400px)',
opacity: '0',
transition: 'transform 1.25s ease, opacity 1s ease',
},
container: {
display: 'flex',
flexDirection: 'column',
position: 'relative',
},
close: {
position: 'absolute',
right: '-38px',
top: '-47px',
backgroundColor: '#fff',
boxShadow: '2px 2px 4px 4px rgba(143,143,143, 0.25)',
['&:hover']: {
backgroundColor: '#fff',
},
},
logo: {
width: '25px',
height: '25px',
},
cancel: {
marginLeft: '1rem',
},
}));

View File

@ -0,0 +1,156 @@
import { useState } from 'react';
import { Button, IconButton } from '@material-ui/core';
import classnames from 'classnames';
import CloseIcon from '@material-ui/icons/Close';
import { ReactComponent as Logo } from '../../../assets/icons/logo-plain.svg';
import { useCommonStyles } from '../../../common.styles';
import { useStyles } from './Feedback.styles';
import AnimateOnMount from '../AnimateOnMount/AnimateOnMount';
import ConditionallyRender from '../ConditionallyRender';
import { formatApiPath } from '../../../utils/format-path';
import { Action, Dispatch } from 'redux';
interface IFeedbackProps {
show?: boolean;
hideFeedback: () => Dispatch<Action>;
fetchUser: () => void;
feedbackId: string;
openUrl: string;
}
const Feedback = ({
show,
hideFeedback,
fetchUser,
feedbackId,
openUrl,
}: IFeedbackProps) => {
const [answeredNotNow, setAnsweredNotNow] = useState(false);
const styles = useStyles();
const commonStyles = useCommonStyles();
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 fetchUser();
} catch {
hideFeedback();
}
// Await api call to register confirmation
window.open(openUrl, '_blank');
setTimeout(() => {
hideFeedback();
}, 200);
};
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 fetchUser();
} catch {
hideFeedback();
}
setTimeout(() => {
hideFeedback();
}, 100);
};
return (
<AnimateOnMount
mounted={show}
enter={styles.feedbackEnter}
start={styles.feedbackStart}
leave={styles.feedbackLeave}
container={styles.animateContainer}
>
<div className={styles.feedback}>
<div
className={classnames(
styles.container,
commonStyles.contentSpacingY
)}
>
<IconButton
className={styles.close}
onClick={() => hideFeedback()}
>
<CloseIcon />
</IconButton>
<Logo className={styles.logo} />
<ConditionallyRender
condition={answeredNotNow}
show={
<p>
Alright, apologies for the disruption. Have a
nice day!
</p>
}
elseShow={
<p>
Hi. Do you have 2 minutes to help us improve
Unleash?{' '}
</p>
}
/>
<div>
<ConditionallyRender
condition={answeredNotNow}
show={
<Button
variant="outlined"
onClick={onDontShowAgain}
>
Don't show again
</Button>
}
elseShow={
<>
<Button
variant="contained"
color="primary"
onClick={onConfirm}
>
Yes, no problem
</Button>
<Button
className={styles.cancel}
onClick={() => setAnsweredNotNow(true)}
>
Not now
</Button>
</>
}
/>
</div>
</div>
</div>
</AnimateOnMount>
);
};
export default Feedback;

View File

@ -0,0 +1,19 @@
import { Dispatch } from 'react';
import { connect } from 'react-redux';
import { Action } from 'redux';
import { hideFeedback } from '../../../store/feedback/actions';
import { fetchUser } from '../../../store/user/actions';
import Feedback from './Feedback';
const mapStateToProps = (state: any) => ({
show: state.feedback.show,
});
const mapDispatchToProps = (dispatch: Dispatch<Action>) => ({
hideFeedback: () => {
hideFeedback(dispatch)();
},
fetchUser: () => fetchUser()(dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(Feedback);

View File

@ -1,4 +1,5 @@
import { weightTypes } from '../feature/variant/enums';
import differenceInDays from 'date-fns/differenceInDays';
const dateTimeOptions = {
day: '2-digit',
@ -111,3 +112,29 @@ export const modalStyles = {
export const updateIndexInArray = (array, index, newValue) =>
array.map((v, i) => (i === index ? newValue : v));
export const showPnpsFeedback = user => {
if (!user) return;
if (!user.feedback) return;
if (user.feedback.length > 0) {
const feedback = user.feedback.find(
feedback => feedback.feedbackId === 'pnps'
);
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;
};

View File

@ -6,7 +6,8 @@ import {
validateName,
} from '../../../../store/feature-toggle/actions';
import CreateFeature from './CreateFeature';
import { loadNameFromHash } from '../../../common/util';
import { loadNameFromHash, showPnpsFeedback } from '../../../common/util';
import { showFeedback } from '../../../../store/feedback/actions';
const defaultStrategy = {
name: 'default',
@ -62,8 +63,9 @@ class WrapperComponent extends Component {
};
onSubmit = async evt => {
const { user } = this.props;
evt.preventDefault();
const { createFeatureToggles, history } = this.props;
const { createFeatureToggles, history, showFeedback } = this.props;
const { featureToggle } = this.state;
const errors = Object.values(this.state.errors).filter(i => i);
@ -80,6 +82,11 @@ class WrapperComponent extends Component {
await createFeatureToggles(featureToggle).then(() =>
history.push(`/features/strategies/${featureToggle.name}`)
);
if (showPnpsFeedback(user)) {
showFeedback();
}
// Trigger
} catch (e) {
if (e.toString().includes('not allowed to be empty')) {
this.setState({
@ -119,6 +126,7 @@ const mapDispatchToProps = dispatch => ({
validateName: name => validateName(name)(dispatch),
createFeatureToggles: featureToggle =>
createFeatureToggles(featureToggle)(dispatch),
showFeedback: showFeedback(dispatch),
});
const mapStateToProps = state => {

View File

@ -0,0 +1,8 @@
export const SHOW_FEEDBACK = 'SHOW_FEEDBACK';
export const HIDE_FEEDBACK = 'HIDE_FEEDBACK';
export const showFeedback = dispatch => () => dispatch({ type: SHOW_FEEDBACK });
export const hideFeedback = dispatch => () => {
dispatch({ type: HIDE_FEEDBACK });
};

View File

@ -0,0 +1,15 @@
import { HIDE_FEEDBACK, SHOW_FEEDBACK } from './actions';
const feedback = (state = {}, action) => {
switch (action.type) {
case SHOW_FEEDBACK:
return { ...state, show: true };
case HIDE_FEEDBACK: {
return { ...state, show: false };
}
default:
return state;
}
};
export default feedback;

View File

@ -20,6 +20,7 @@ import apiAdmin from './e-api-admin';
import authAdmin from './e-admin-auth';
import apiCalls from './api-calls';
import invoiceAdmin from './e-admin-invoice';
import feedback from './feedback';
const unleashStore = combineReducers({
features,
@ -43,6 +44,7 @@ const unleashStore = combineReducers({
authAdmin,
apiCalls,
invoiceAdmin,
feedback,
});
export default unleashStore;

View File

@ -2,12 +2,13 @@ import { Map as $Map } from 'immutable';
import { USER_CHANGE_CURRENT, USER_LOGOUT } from './actions';
import { AUTH_REQUIRED } from '../util';
const userStore = (state = new $Map({permissions: []}), action) => {
const userStore = (state = new $Map({ permissions: [] }), action) => {
switch (action.type) {
case USER_CHANGE_CURRENT:
state = state
.set('profile', action.value.user)
.set('permissions', action.value.permissions || [])
.set('feedback', action.value.feedback || [])
.set('showDialog', false)
.set('authDetails', undefined);
return state;
@ -17,7 +18,7 @@ const userStore = (state = new $Map({permissions: []}), action) => {
.set('showDialog', true);
return state;
case USER_LOGOUT:
return new $Map({permissions: []});
return new $Map({ permissions: [] });
default:
return state;
}