Skip to content

Commit

Permalink
[Web UI] Introduce toast notification system (#2026)
Browse files Browse the repository at this point in the history
## What is this change?

### Replace react-motion with react-spring

React-spring is directly inspired by the API of react-motion but has much better performance

Upgrading to latest version of react due to a bug in 16.3.x
  see:
   - pmndrs/react-spring#136
   - pmndrs/react-spring#215
   - https://reactjs.org/blog/2018/05/23/react-v-16-4.html#bugfix-for-getderivedstatefromprops

### Create "relocation" system

This PR introduces "relocation" as more flexible alternative to `React.Portal` supporting both declarative (`Relocation.Sink`) and imperative (`Relocation.Consumer`) interfaces.

The intention is to eventually rely on the published `relocation` package once it is updated to match the pattern used here.

Showing a toast notification:

```jsx
const SomeComponent = props => (
  <ToastProvider>
    {({ addToast }) => (
      <button
        onClick={someAsyncAction.then(
          result =>
            addToast(({ id, remove }) => (
              <Toast
                variant="success"
                message={`Action succeeded with result: ${result}`}
                onClose={remove}
                maxAge={5000} // 5 seconds
              />
            )),
          error =>
            addToast(({ id, remove }) => (
              <Toast
                variant="error"
                message={`Action failed with error: ${error}`}
                onClose={remove}
                maxAge={5000} // 5 seconds
              />
            )),
        )}
      >
        Fire Async Action
      </button>
    )}
  </ToastProvider>
);
```

### Add feedback toast for check execution 

Displays a toast indicating pending and success states for each requested check execution.

![check toast](https://user-images.githubusercontent.com/1074748/45127989-58227580-b130-11e8-9995-1aba1f1e071b.gif)

## Why is this change necessary?

Closes #1932 
Closes #2012

## Does your change need a Changelog entry?

No

## Do you need clarification on anything?

No

## Were there any complications while making this change?

No

## Have you reviewed and updated the documentation for this change? Is new documentation required?

No
  • Loading branch information
10xjs committed Sep 18, 2018
1 parent 0206af2 commit 87d8242
Show file tree
Hide file tree
Showing 32 changed files with 1,200 additions and 238 deletions.
6 changes: 3 additions & 3 deletions dashboard/package.json
Expand Up @@ -102,14 +102,14 @@
"prettier": "^1.12.1",
"prop-types": "^15.6.0",
"raw-loader": "^0.5.1",
"react": "^16.3.0",
"react": "^16.4.2",
"react-apollo": "^2.1.3",
"react-dev-utils": "^5.0.1",
"react-dom": "^16.3.0",
"react-motion": "^0.5.2",
"react-dom": "^16.4.2",
"react-redux": "^5.0.6",
"react-resize-observer": "^0.2.2",
"react-router-dom": "^4.2.2",
"react-spring": "^5.6.10",
"react-tap-event-plugin": "^3.0.2",
"recompose": "^0.27.0",
"redux": "^3.0.0",
Expand Down
8 changes: 2 additions & 6 deletions dashboard/src/components/AnimatedLogo.js
@@ -1,11 +1,7 @@
import React from "react";
import PropTypes from "prop-types";

let id = 0;
const getNextId = () => {
id += 1;
return id;
};
import uniqueId from "/utils/uniqueId";

class AnimatedLogo extends React.PureComponent {
static propTypes = {
Expand All @@ -21,7 +17,7 @@ class AnimatedLogo extends React.PureComponent {
};

componentWillMount() {
this._id = `AnimatedLogo-${getNextId()}`;
this._id = `AnimatedLogo-${uniqueId()}`;
}

render() {
Expand Down
20 changes: 16 additions & 4 deletions dashboard/src/components/AppLayout/AppLayout.js
Expand Up @@ -3,6 +3,8 @@ import PropTypes from "prop-types";
import { withStyles } from "@material-ui/core/styles";
import ResizeObserver from "react-resize-observer";

import ToastWell from "/components/relocation/ToastWell";

import MobileFullWidthContent from "./MobileFullWidthContent";
import Context from "./Context";

Expand All @@ -12,6 +14,8 @@ const styles = theme => ({
display: "flex",
flexDirection: "column",
alignItems: "stretch",
paddingLeft: "env(safe-area-inset-left)",
paddingRight: "env(safe-area-inset-right)",
},

topBarContainer: {
Expand Down Expand Up @@ -64,6 +68,7 @@ const styles = theme => ({
contentContainer: {
flex: 1,
display: "flex",
zIndex: 0,
},

content: {
Expand Down Expand Up @@ -95,12 +100,19 @@ const styles = theme => ({
position: "fixed",
bottom: 0,
left: 0,
right: 0,
height: 0,
},

toast: {
position: "absolute",
bottom: 0,
right: 0,
left: 0,

[theme.breakpoints.up("md")]: {
left: "auto",
},
},
});

Expand All @@ -111,15 +123,13 @@ class AppLayout extends React.PureComponent {
quickNav: PropTypes.node,
content: PropTypes.node,
alert: PropTypes.node,
toast: PropTypes.node,
};

static defaultProps = {
topBar: undefined,
quickNav: undefined,
content: undefined,
alert: undefined,
toast: undefined,
};

static MobileFullWidthContent = MobileFullWidthContent;
Expand All @@ -138,7 +148,7 @@ class AppLayout extends React.PureComponent {
};

render() {
const { classes, topBar, quickNav, content, alert, toast } = this.props;
const { classes, topBar, quickNav, content, alert } = this.props;

const contentOffset =
CSS && CSS.supports && CSS.supports("position: sticky")
Expand All @@ -165,7 +175,9 @@ class AppLayout extends React.PureComponent {
<div className={classes.content}>{content}</div>
</div>
<div className={classes.toastContainer}>
<div className={classes.toast}>{toast}</div>
<div className={classes.toast}>
<ToastWell />
</div>
</div>
</div>
</Context.Provider>
Expand Down
57 changes: 30 additions & 27 deletions dashboard/src/components/AppRoot.js
Expand Up @@ -16,6 +16,7 @@ import AuthInvalidRoute from "/components/util/AuthInvalidRoute";
import DefaultRedirect from "/components/util/DefaultRedirect";
import LastEnvironmentRedirect from "/components/util/LastEnvironmentRedirect";
import SigninRedirect from "/components/util/SigninRedirect";
import { Provider as RelocationProvider } from "/components/relocation/Relocation";

import EnvironmentView from "/components/views/EnvironmentView";
import SignInView from "/components/views/SignInView";
Expand All @@ -35,33 +36,35 @@ class AppRoot extends React.PureComponent {
const { reduxStore, apolloClient } = this.props;

return (
<Provider store={reduxStore}>
<ApolloProvider client={apolloClient}>
<AppThemeProvider>
<Switch>
<Route exact path="/" component={DefaultRedirect} />
<UnauthenticatedRoute
exact
path="/signin"
component={SignInView}
fallbackComponent={LastEnvironmentRedirect}
/>
<AuthenticatedRoute
path="/:organization/:environment"
component={EnvironmentView}
fallbackComponent={SigninRedirect}
/>
<Route component={NotFoundView} />
</Switch>
<Switch>
<UnauthenticatedRoute exact path="/signin" />
<AuthInvalidRoute component={AuthInvalidDialog} />
</Switch>
<ResetStyles />
<ThemeStyles />
</AppThemeProvider>
</ApolloProvider>
</Provider>
<RelocationProvider>
<Provider store={reduxStore}>
<ApolloProvider client={apolloClient}>
<AppThemeProvider>
<Switch>
<Route exact path="/" component={DefaultRedirect} />
<UnauthenticatedRoute
exact
path="/signin"
component={SignInView}
fallbackComponent={LastEnvironmentRedirect}
/>
<AuthenticatedRoute
path="/:organization/:environment"
component={EnvironmentView}
fallbackComponent={SigninRedirect}
/>
<Route component={NotFoundView} />
</Switch>
<Switch>
<UnauthenticatedRoute exact path="/signin" />
<AuthInvalidRoute component={AuthInvalidDialog} />
</Switch>
<ResetStyles />
<ThemeStyles />
</AppThemeProvider>
</ApolloProvider>
</Provider>
</RelocationProvider>
);
}
}
Expand Down
Expand Up @@ -8,14 +8,10 @@ import {
componentFromProp,
} from "recompose";

let id = 0;
const getNextId = () => {
id += 1;
return id;
};
import uniqueId from "/utils/uniqueId";

export const withStyle = styles => {
const path = `with-style-${getNextId()}`;
const path = `with-style-${uniqueId()}`;
return compose(
withStyles(theme => ({ [path]: styles(theme) })),
mapProps(
Expand Down
Expand Up @@ -7,32 +7,52 @@ import { withApollo } from "react-apollo";

import executeCheck from "/mutations/executeCheck";

import ToastConnector from "/components/relocation/ToastConnector";
import ExecuteCheckStatusToast from "/components/relocation/ExecuteCheckStatusToast";

class CheckDetailsExecuteAction extends React.PureComponent {
static propTypes = {
children: PropTypes.func.isRequired,
client: PropTypes.object.isRequired,
check: PropTypes.object,
};

static defaultProps = {
check: null,
check: PropTypes.object.isRequired,
};

static fragments = {
check: gql`
fragment CheckDetailsExecuteAction_check on CheckConfig {
id
name
namespace {
organization
environment
}
}
`,
};

handleClick = () => {
const { client, check } = this.props;
executeCheck(client, { id: check.id });
};

render() {
return this.props.children(this.handleClick);
const { children, client, check } = this.props;

return (
<ToastConnector>
{({ addToast }) =>
children(() => {
const promise = executeCheck(client, {
id: check.id,
});

addToast(({ remove }) => (
<ExecuteCheckStatusToast
onClose={remove}
mutation={promise}
checkName={check.name}
namespace={check.namespace}
/>
));
})
}
</ToastConnector>
);
}
}

Expand Down
15 changes: 14 additions & 1 deletion dashboard/src/components/partials/ChecksList/ChecksList.js
Expand Up @@ -16,6 +16,7 @@ import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableRow from "@material-ui/core/TableRow";
import { TableListEmptyState } from "/components/TableList";
import ExecuteCheckStatusToast from "/components/relocation/ExecuteCheckStatusToast";

import ChecksListHeader from "./ChecksListHeader";
import ChecksListItem from "./ChecksListItem";
Expand All @@ -33,6 +34,7 @@ class ChecksList extends React.Component {
limit: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
offset: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
refetch: PropTypes.func.isRequired,
addToast: PropTypes.func.isRequired,
};

static defaultProps = {
Expand Down Expand Up @@ -116,7 +118,18 @@ class ChecksList extends React.Component {
};

executeChecks = checks => {
checks.forEach(({ id }) => executeCheck(this.props.client, { id }));
checks.forEach(({ id, name, namespace }) => {
const promise = executeCheck(this.props.client, { id });

this.props.addToast(({ remove }) => (
<ExecuteCheckStatusToast
onClose={remove}
mutation={promise}
checkName={name}
namespace={namespace}
/>
));
});
};

deleteChecks = checks => {
Expand Down
66 changes: 66 additions & 0 deletions dashboard/src/components/partials/CircularProgress.js
@@ -0,0 +1,66 @@
import React from "react";
import PropTypes from "prop-types";
import ResizeObserver from "react-resize-observer";

class CircularProgress extends React.PureComponent {
static propTypes = {
width: PropTypes.number,
value: PropTypes.number,
children: PropTypes.node,
opacity: PropTypes.number,
};

static defaultProps = {
width: 8,
value: 0,
children: undefined,
opacity: 1,
};

state = {
size: 0,
};

handleResize = rect => {
this.setState(state => {
const size = Math.min(rect.width, rect.height);
if (size === state.size) {
return null;
}
return { size };
});
};

render() {
const { size } = this.state;
const { width, value, children, opacity } = this.props;

return (
<div style={{ position: "relative" }}>
<ResizeObserver onResize={this.handleResize} />
<svg
viewBox={`0 0 ${size} ${size}`}
style={{ display: "block", position: "absolute" }}
>
{size > 0 && (
<circle
transform={`rotate(-90, ${size * 0.5}, ${size * 0.5})`}
cx={size * 0.5}
cy={size * 0.5}
r={(size - width) / 2}
strokeDasharray={Math.PI * (size - width)}
strokeDashoffset={Math.PI * (size - width) * (1 - value)}
fill="none"
stroke="currentColor"
opacity={opacity}
strokeWidth={width}
/>
)}
</svg>
{children}
</div>
);
}
}

export default CircularProgress;

0 comments on commit 87d8242

Please sign in to comment.