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

Feat/splash (#491)

* splash screen

* add styles for controllers

* feat: animated circles

* fix: remove unused code

* fix: folder structure

* create splash screens for envs

* add styles and ui changes

* fix: revert App.tsx

* add splash state to store

* add splash to app.tsx + add a loader

* fix: mobile view + desktop view

* fix: render splash condition + styling fix

* fix: change splash display to full screen

* Update src/hooks/api/actions/useSplashApi/useSplashApi.ts

Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>

* fix: change function type

Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>

* fix: disable incrementing counter when matching the components length.

* fix: add SWR configuration

* fix: spelling mistakes in splash screen

* fix: add keys and adjust styling

* fix: tests

* fix: tests

* fix: default command timeout

Co-authored-by: Fredrik Oseberg <fredrik.no@gmail.com>
This commit is contained in:
Youssef Khedher 2021-11-26 11:12:37 +01:00 committed by GitHub
parent 00b9a6c38d
commit c34d8439bd
19 changed files with 659 additions and 27 deletions

View File

@ -1,3 +1,4 @@
{
"projectId": "tc2qff"
"projectId": "tc2qff",
"defaultCommandTimeout": 12000
}

View File

@ -67,6 +67,16 @@ describe('feature toggle', () => {
cy.get('[data-test="LOGIN_PASSWORD_ID"]').type('qY70$NDcJNXA');
cy.get("[data-test='LOGIN_BUTTON']").click();
cy.request({
method: 'POST',
url: `${
Cypress.config().baseUrl
}/api/admin/features/${featureToggleName}`,
headers: {
Authorization: authToken,
},
});
} else {
cy.get('[data-test=LOGIN_EMAIL_ID]').type('test@unleash-e2e.com');
cy.get('[data-test=LOGIN_BUTTON]').click();
@ -74,6 +84,12 @@ describe('feature toggle', () => {
});
it('Creates a feature toggle', () => {
if (
document.querySelectorAll("[data-test='CLOSE_SPLASH']").length > 0
) {
cy.get("[data-test='CLOSE_SPLASH']").click();
}
cy.get('[data-test=NAVIGATE_TO_CREATE_FEATURE').click();
cy.intercept('POST', '/api/admin/features').as('createFeature');

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 81 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

@ -10,25 +10,36 @@ import { routes } from './menu/routes';
import styles from './styles.module.scss';
import IAuthStatus from '../interfaces/user';
import { useEffect } from 'react';
import { useState, useEffect } from 'react';
import NotFound from './common/NotFound/NotFound';
import Feedback from './common/Feedback';
import useToast from '../hooks/useToast';
import SWRProvider from './providers/SWRProvider/SWRProvider';
import ConditionallyRender from './common/ConditionallyRender';
import EnvironmentSplash from './common/EnvironmentSplash/EnvironmentSplash';
import Loader from './common/Loader/Loader';
import useUser from '../hooks/api/getters/useUser/useUser';
interface IAppProps extends RouteComponentProps {
user: IAuthStatus;
fetchUiBootstrap: any;
feedback: any;
}
const App = ({ location, user, fetchUiBootstrap, feedback }: IAppProps) => {
const App = ({ location, user, fetchUiBootstrap }: IAppProps) => {
const { toast, setToastData } = useToast();
// because we need the userId when the component load.
const { splash, user: userFromUseUser } = useUser();
const [showSplash, setShowSplash] = useState(false);
useEffect(() => {
fetchUiBootstrap();
/* eslint-disable-next-line */
}, [user.authDetails?.type]);
useEffect(() => {
setShowSplash(!splash?.environments && !isUnauthorized());
/* eslint-disable-next-line */
}, [splash]);
const renderMainLayoutRoutes = () => {
return routes.filter(route => route.layout === 'main').map(renderRoute);
};
@ -79,29 +90,46 @@ const App = ({ location, user, fetchUiBootstrap, feedback }: IAppProps) => {
setToastData={setToastData}
isUnauthorized={isUnauthorized}
>
{' '}
<div className={styles.container}>
<LayoutPicker location={location}>
<Switch>
<ProtectedRoute
exact
path="/"
unauthorized={isUnauthorized()}
component={Redirect}
renderProps={{ to: '/features' }}
<ConditionallyRender
condition={!isUnauthorized() && !userFromUseUser?.id}
show={<Loader />}
elseShow={
<div className={styles.container}>
<ConditionallyRender
condition={showSplash}
show={
<EnvironmentSplash onFinish={setShowSplash} />
}
elseShow={
<LayoutPicker location={location}>
<Switch>
<ProtectedRoute
exact
path="/"
unauthorized={isUnauthorized()}
component={Redirect}
renderProps={{ to: '/features' }}
/>
{renderMainLayoutRoutes()}
{renderStandaloneRoutes()}
<Route
path="/404"
component={NotFound}
/>
<Redirect to="/404" />
</Switch>
<Feedback
feedbackId="pnps"
openUrl="http://feedback.unleash.run"
/>
</LayoutPicker>
}
/>
{renderMainLayoutRoutes()}
{renderStandaloneRoutes()}
<Route path="/404" component={NotFound} />
<Redirect to="/404" />
</Switch>
<Feedback
feedbackId="pnps"
openUrl="http://feedback.unleash.run"
/>
</LayoutPicker>
{toast}
</div>
{toast}
</div>
}
/>
</SWRProvider>
);
};

View File

@ -0,0 +1,57 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
splashContainer: {
position: 'fixed',
},
title: {
textAlign: 'center',
marginBottom: '20px',
lineHeight: '1.3',
[theme.breakpoints.down('xs')]: {
marginTop: '1rem',
},
},
topDescription: {
padding: '0px 40px',
marginBottom: '15px',
fontSize: '17px',
[theme.breakpoints.down('xs')]: {
padding: '0 20px',
},
},
bottomDescription: {
padding: '0px 20px',
fontSize: '17px',
marginTop: '15px',
[theme.breakpoints.down('xs')]: {
padding: '0 20px',
},
},
icon: {
fontSize: '150px',
display: 'block',
margin: 'auto',
[theme.breakpoints.down('xs')]: {
fontSize: '90px',
},
},
logo: {
width: '70%',
height: '60%',
display: 'block',
margin: 'auto',
marginTop: '2rem',
[theme.breakpoints.down('xs')]: {
width: '80%',
height: '80%',
marginTop: '0rem',
},
},
linkList: {
padding: '30px 25px',
},
link: {
color: '#fff',
},
}));

View File

@ -0,0 +1,204 @@
import Splash from '../Splash/Splash';
import EnvironmentSplashPage from './EnvironmentSplashPage/EnvironmentSplashPage';
import { VpnKey, CloudCircle } from '@material-ui/icons';
import { useStyles } from './EnvironmentSplash.styles';
import { ReactComponent as Logo1 } from '../../../assets/img/splash_env1.svg';
import { ReactComponent as Logo2 } from '../../../assets/img/splash_env2.svg';
import { useEffect } from 'react';
import useSplashApi from '../../../hooks/api/actions/useSplashApi/useSplashApi';
interface IEnvironmentSplashProps {
onFinish: (status: boolean) => void;
}
const EnvironmentSplash = ({ onFinish }: IEnvironmentSplashProps) => {
const styles = useStyles();
const { setSplashSeen } = useSplashApi();
useEffect(() => {
setSplashSeen('environments');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<Splash
onFinish={onFinish}
components={[
<EnvironmentSplashPage
key={1}
title={
<h2 className={styles.title}>
Environments are coming to Unleash!
</h2>
}
topDescription={
<p className={styles.topDescription}>
We are bringing native environment support to
Unleash.{' '}
<b>
Your current configurations wont be
affected,
</b>{' '}
but youll have the option of adding strategies
to specific environments going forward.
</p>
}
bottomDescription={
<p className={styles.bottomDescription}>
By default you will get access to three
environments: <b>default</b>, <b>development</b>{' '}
and<b> production</b>. All of your current
configurations will live in the default
environment and{' '}
<b>
nothing will change until you make a
conscious decision to change.
</b>
</p>
}
image={<CloudCircle className={styles.icon} />}
/>,
<EnvironmentSplashPage
key={2}
title={
<h2 className={styles.title}>
Strategies live in environments
</h2>
}
topDescription={
<p className={styles.topDescription}>
A feature toggle lives as an entity across
multiple environments, but your strategies will
live in a specific environment. This allows you
to have different configuration per environment
for a feature toggle.
</p>
}
image={<Logo1 className={styles.logo} />}
/>,
<EnvironmentSplashPage
key={3}
title={
<h2 className={styles.title}>
Environments are turned on per project
</h2>
}
topDescription={
<p className={styles.topDescription}>
In order to enable an environment for a feature
toggle you must first enable the environment in
your project. Navigate to your project settings
and enable the environments you want to be
available. The toggles in that project will get
access to all of the projects enabled
environments.
</p>
}
image={<Logo2 className={styles.logo} />}
/>,
<EnvironmentSplashPage
key={4}
title={
<h2 className={styles.title}>
API Keys control which environment you get the
configuration from
</h2>
}
topDescription={
<p className={styles.topDescription}>
When you have set up environments for your
feature toggles and added strategies to the
specific environments, you must create
environment-specific API keys one for each
environment.
</p>
}
bottomDescription={
<p className={styles.bottomDescription}>
Environment-specific API keys lets the SDK
receive configuration only for the specified
environment.
</p>
}
image={<VpnKey className={styles.icon} />}
/>,
<EnvironmentSplashPage
key={5}
title={
<h2 className={styles.title}>Want to know more?</h2>
}
topDescription={
<div className={styles.topDescription}>
If youd like some more info on environments,
check out some of the resources below! The
documentation or the video walkthrough is a
great place to start. If youd like to try it
out in a risk-free setting first, how about
heading to the demo instance?
<ul className={styles.linkList}>
<li>
<a
href="https://www.loom.com/share/95239e875bbc4e09a5c5833e1942e4b0?t=0"
target="_blank"
rel="noreferrer"
className={styles.link}
>
Video walkthrough
</a>
</li>
<li>
<a
href="https://app.unleash-hosted.com/demo/"
target="_blank"
rel="noreferrer"
className={styles.link}
>
The Unleash demo instance
</a>
</li>
<li>
<a
href="https://docs.getunleash.io/user_guide/environments"
target="_blank"
rel="noreferrer"
className={styles.link}
>
Environments reference documentation
</a>
</li>
<li>
<a
href="https://www.getunleash.io/blog/simplify-rollout-management-with-the-new-environments-feature"
target="_blank"
rel="noreferrer"
className={styles.link}
>
Blog post introducing environments
</a>
</li>
</ul>
</div>
}
bottomDescription={
<p className={styles.bottomDescription}>
If you have any questions or need help, feel
free to ping us on{' '}
<a
target="_blank"
href="https://slack.unleash.run/"
rel="noreferrer"
className={styles.link}
>
slack!
</a>
</p>
}
/>,
]}
/>
</>
);
};
export default EnvironmentSplash;

View File

@ -0,0 +1,24 @@
interface EnvironmentSplashPageProps {
title: React.ReactNode;
topDescription: React.ReactNode;
image?: React.ReactNode;
bottomDescription?: React.ReactNode;
}
const EnvironmentSplashPage = ({
title,
topDescription,
image,
bottomDescription,
}: EnvironmentSplashPageProps) => {
return (
<div>
{title}
{topDescription}
{image}
{bottomDescription}
</div>
);
};
export default EnvironmentSplashPage;

View File

@ -0,0 +1,14 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
loader: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
},
img: {
width: '100px',
height: '100px',
},
}));

View File

@ -0,0 +1,14 @@
import logo from '../../../assets/img/unleash_logo_icon_dark _ alpha.gif';
import { useStyles } from './Loader.styles';
const Loader = () => {
const styles = useStyles();
return (
<div className={styles.loader}>
<img className={styles.img} src={logo} alt="loading..." />
</div>
);
};
export default Loader;

View File

@ -0,0 +1,96 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
splashMainContainer: {
backgroundColor: theme.palette.primary.light,
height: '100%',
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '3rem 0',
[theme.breakpoints.down('xs')]: {
padding: '0',
},
},
splashContainer: {
backgroundColor: theme.palette.primary.main,
position: 'relative',
minHeight: '650px',
width: '600px',
padding: '2rem 1.5rem',
borderRadius: '5px',
color: '#fff',
display: 'flex',
overflowX: 'hidden',
flexDirection: 'column',
[theme.breakpoints.down('xs')]: {
top: '0px',
left: '0px',
right: '0px',
bottom: '0px',
padding: '2rem 0',
zIndex: '500',
position: 'fixed',
width: '100%',
height: '100%',
borderRadius: 0,
},
},
closeButtonContainer: {
display: 'inline-flex',
justifyContent: 'flex-end',
color: '#fff',
position: 'absolute',
right: '-10px',
top: '5px',
},
closeButton: {
textDecoration: 'none',
right: '10px',
color: '#fff',
'&:hover': {
backgroundColor: 'inherit',
},
},
controllers: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
height: 'inherit',
marginBottom: 5,
marginTop: 'auto',
},
circlesContainer: {
display: 'flex',
justifyContent: 'center',
},
circles: {
display: 'inline-flex',
justifyContent: 'center',
marginTop: 20,
marginBottom: 15,
position: 'relative',
},
buttonsContainer: {
display: 'flex',
justifyContent: 'center',
},
button: {
textDecoration: 'none',
width: '100px',
color: '#fff',
'&:hover': {
backgroundColor: 'inherit',
},
},
nextButton: {
textDecoration: 'none',
width: '100px',
color: theme.palette.primary.light,
backgroundColor: '#fff',
'&:hover': {
backgroundColor: '#fff',
},
},
}));

View File

@ -0,0 +1,114 @@
import { Fragment } from 'react';
import { Button, IconButton } from '@material-ui/core';
import { useStyles } from './Splash.styles';
import {
FiberManualRecord,
FiberManualRecordOutlined,
CloseOutlined,
} from '@material-ui/icons';
import { useState } from 'react';
import ConditionallyRender from '../ConditionallyRender';
import { CLOSE_SPLASH } from '../../../testIds';
interface ISplashProps {
components: React.ReactNode[];
onFinish: (status: boolean) => void;
}
const Splash: React.FC<ISplashProps> = ({
components,
onFinish,
}: ISplashProps) => {
const styles = useStyles();
const [counter, setCounter] = useState(0);
const onNext = () => {
if (counter === components.length - 1) {
onFinish(false);
return;
}
setCounter(counter + 1);
};
const onBack = () => {
setCounter(counter - 1);
};
const onClose = () => {
onFinish(false);
};
const calculatePosition = () => {
if (counter === 0) {
return '0';
}
return counter * 24;
};
const renderCircles = () => {
return components.map((_, index) => {
if (index === 0) {
// Use index as key because the amount of pages will never dynamically change.
return (
<Fragment key={index}>
<FiberManualRecordOutlined />
<FiberManualRecord
style={{
position: 'absolute',
transition: 'transform 0.3s ease',
left: '0',
transform: `translateX(${calculatePosition()}px)`,
}}
/>
</Fragment>
);
}
return <FiberManualRecordOutlined />;
});
};
return (
<div className={styles.splashMainContainer}>
<div className={styles.splashContainer}>
<div className={styles.closeButtonContainer}>
<IconButton
className={styles.closeButton}
onClick={onClose}
data-test={CLOSE_SPLASH}
>
<CloseOutlined />
</IconButton>
</div>
{components[counter]}
<div className={styles.controllers}>
<div className={styles.circlesContainer}>
<div className={styles.circles}>{renderCircles()}</div>
</div>
<div className={styles.buttonsContainer}>
<ConditionallyRender
condition={counter > 0}
show={
<Button
className={styles.button}
disabled={counter === 0}
onClick={onBack}
>
Back
</Button>
}
/>
<Button className={styles.nextButton} onClick={onNext}>
{counter === components.length - 1
? 'Finish'
: 'Next'}
</Button>
</div>
</div>
</div>
</div>
);
};
export default Splash;

View File

@ -0,0 +1,27 @@
import useAPI from '../useApi/useApi';
const useSplashApi = () => {
const { makeRequest, createRequest } = useAPI({
propagateErrors: true,
});
const setSplashSeen = async (splashId: string) => {
const path = `api/admin/splash/${splashId}`;
const req = createRequest(path, {
method: 'POST',
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
console.log('An exception was caught and handled.');
}
};
return {
setSplashSeen,
};
};
export default useSplashApi;

View File

@ -6,7 +6,13 @@ import handleErrorResponses from '../httpErrorResponseHandler';
export const USER_CACHE_KEY = `api/admin/user`;
const useUser = (options: SWRConfiguration = {}) => {
const useUser = (
options: SWRConfiguration = {
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
) => {
const fetcher = () => {
const path = formatApiPath(`api/admin/user`);
return fetch(path, {
@ -31,6 +37,7 @@ const useUser = (options: SWRConfiguration = {}) => {
user: data?.user || {},
permissions: (data?.permissions || []) as IPermission[],
feedback: data?.feedback || [],
splash: data?.splash || {},
authDetails: data || {},
error,
loading,

View File

@ -3,6 +3,11 @@ export interface IAuthStatus {
showDialog: boolean;
profile?: IUser;
permissions: IPermission[];
splash: ISplash;
}
export interface ISplash {
[key: string]: boolean;
}
export interface IPermission {

View File

@ -9,6 +9,7 @@ const userStore = (state = new $Map({ permissions: [] }), action) => {
.set('profile', action.value.user)
.set('permissions', action.value.permissions || [])
.set('feedback', action.value.feedback || [])
.set('splash', action.value.splash || {})
.set('showDialog', false)
.set('authDetails', undefined);
return state;

View File

@ -34,3 +34,6 @@ export const UPDATE_STRATEGY_BUTTON_ID = 'UPDATE_STRATEGY_BUTTON_ID';
export const DELETE_STRATEGY_ID = 'DELETE_STRATEGY_ID';
export const STRATEGY_INPUT_LIST = 'STRATEGY_INPUT_LIST';
export const ADD_TO_STRATEGY_INPUT_LIST = 'ADD_TO_STRATEGY_INPUT_LIST';
/* SPLASH */
export const CLOSE_SPLASH = 'CLOSE_SPLASH';