Просмотр исходного кода

feat(ui): New toast indicators (#7174)

Removes settingsIndicator and merges it with existing + redesigned toasts.

Moving forward, let's use the indicator action creators rather than the store directly.
Billy Vong 7 лет назад
Родитель
Сommit
046730c34a

+ 93 - 0
docs-ui/components/indicators.stories.js

@@ -0,0 +1,93 @@
+import React from 'react';
+import {storiesOf} from '@storybook/react';
+import {withInfo} from '@storybook/addon-info';
+import {select} from '@storybook/addon-knobs';
+
+import IndicatorContainer, {Indicators} from 'sentry-ui/indicators';
+import IndicatorStore from 'application-root/stores/indicatorStore';
+import {
+  addSuccessMessage,
+  addErrorMessage,
+  addMessage,
+} from 'application-root/actionCreators/indicator';
+import Button from 'sentry-ui/buttons/button';
+
+const stories = storiesOf('Toast Indicators', module);
+stories
+  .add(
+    'static',
+    withInfo('Toast Indicators')(() => {
+      let type = select(
+        'Type',
+        {success: 'success', error: 'error', loading: 'loading'},
+        'success'
+      );
+
+      return (
+        <div style={{backgroundColor: 'white', padding: 12}}>
+          <Indicators
+            items={[
+              {
+                id: '',
+                type,
+                message: 'Indicator message',
+              },
+            ]}
+          />
+        </div>
+      );
+    })
+  )
+  .add(
+    'interactive',
+    withInfo({
+      propTablesExclude: [Button],
+      text: 'Toast Indicators',
+    })(() => {
+      let success;
+      let error;
+      let loading;
+
+      return (
+        <div style={{backgroundColor: 'white', padding: 12}}>
+          <Button
+            onClick={() => {
+              if (success) {
+                IndicatorStore.remove(success);
+                success = null;
+              } else {
+                success = addSuccessMessage('Success');
+              }
+            }}
+          >
+            Toggle Success
+          </Button>
+          <Button
+            onClick={() => {
+              if (loading) {
+                IndicatorStore.remove(loading);
+                loading = null;
+              } else {
+                loading = addMessage('Loading', 'loading');
+              }
+            }}
+          >
+            Toggle Loading
+          </Button>
+          <Button
+            onClick={() => {
+              if (error) {
+                IndicatorStore.remove(error);
+                error = null;
+              } else {
+                error = addErrorMessage('Error');
+              }
+            }}
+          >
+            Toggle Error
+          </Button>
+          <IndicatorContainer />
+        </div>
+      );
+    })
+  );

+ 71 - 0
src/sentry/static/sentry/app/actionCreators/indicator.jsx

@@ -0,0 +1,71 @@
+import {DEFAULT_TOAST_DURATION} from '../constants';
+import IndicatorActions from '../actions/indicatorActions';
+
+// Removes a single indicator
+export function remove(indicator) {
+  IndicatorActions.remove(indicator);
+}
+
+// Clears all indicators
+export function clear() {
+  IndicatorActions.clear();
+}
+
+// Note previous IndicatorStore.add behavior was to default to "loading" if no type was supplied
+export function addMessage(msg, type, options = {}) {
+  let {duration} = options;
+
+  // use default only if undefined, as 0 is a valid duration
+  duration = typeof duration === 'undefined' ? DEFAULT_TOAST_DURATION : duration;
+
+  let action = options.append ? 'append' : 'replace';
+  return IndicatorActions[action](msg, type, {...options, duration});
+}
+
+export function addErrorMessage(msg, duration, options = {}) {
+  addMessage(msg, 'error', {...options, duration});
+}
+
+export function addSuccessMessage(msg, duration, options = {}) {
+  addMessage(msg, 'success', {...options, duration});
+}
+
+/**
+ * This will call an action creator to generate a "Toast" message that
+ * notifies user the field that changed with its previous and current values.
+ *
+ * Also allows for undo
+ */
+
+export function saveOnBlurUndoMessage(change, model, fieldName) {
+  if (!model) return;
+
+  let label = model.getDescriptor(fieldName, 'label');
+
+  if (!label) return;
+
+  addSuccessMessage(
+    `Changed ${label} from "${change.old}" to "${change.new}"`,
+    DEFAULT_TOAST_DURATION,
+    {
+      model,
+      id: fieldName,
+      undo: () => {
+        if (!model || !fieldName) return;
+
+        let oldValue = model.getValue(fieldName);
+        let didUndo = model.undo();
+        let newValue = model.getValue(fieldName);
+
+        if (!didUndo) return;
+        if (!label) return;
+
+        model.saveField(fieldName, newValue).then(() => {
+          addMessage(`Restored ${label} from "${oldValue}" to "${newValue}"`, 'undo', {
+            duration: DEFAULT_TOAST_DURATION,
+          });
+        });
+      },
+    }
+  );
+}

+ 0 - 46
src/sentry/static/sentry/app/actionCreators/settingsIndicator.jsx

@@ -1,46 +0,0 @@
-import {DEFAULT_TOAST_DURATION} from '../constants';
-import SettingsIndicatorActions from '../actions/settingsIndicatorActions';
-
-let clearId;
-
-export function remove() {
-  SettingsIndicatorActions.remove();
-}
-
-export function undo() {
-  SettingsIndicatorActions.undo();
-}
-
-/**
- *
- * @params {string} msg Message
- * @params {string} type ['success', 'error', '']
- * @params {object} options
- * @params {boolean} options.disableUndo Disables undo (default false)
- * @params {number} options.duration Duration to show indicator, default in constants
- */
-export function addMessage(msg, type, options = {}) {
-  let {duration} = options;
-
-  // use default only if undefined, as 0 is a valid duration
-  duration = typeof duration === 'undefined' ? DEFAULT_TOAST_DURATION : duration;
-
-  SettingsIndicatorActions.add(msg, type, options);
-
-  // clear existing timeout if exists
-  if (duration && clearId) {
-    window.clearTimeout(clearId);
-  }
-
-  if (duration) {
-    clearId = window.setTimeout(remove, duration);
-  }
-}
-
-export function addErrorMessage(msg, duration, options = {}) {
-  addMessage(msg, 'error', {...options, duration});
-}
-
-export function addSuccessMessage(msg, duration, options = {}) {
-  addMessage(msg, 'success', {...options, duration});
-}

+ 11 - 0
src/sentry/static/sentry/app/actions/indicatorActions.jsx

@@ -0,0 +1,11 @@
+import Reflux from 'reflux';
+
+const IndicatorActions = Reflux.createActions([
+  'replace',
+  'append',
+  'remove',
+  'clear',
+  'undo',
+]);
+
+export default IndicatorActions;

+ 0 - 5
src/sentry/static/sentry/app/actions/settingsIndicatorActions.jsx

@@ -1,5 +0,0 @@
-import Reflux from 'reflux';
-
-const SettingsIndicatorActions = Reflux.createActions(['add', 'remove', 'undo']);
-
-export default SettingsIndicatorActions;

+ 95 - 7
src/sentry/static/sentry/app/components/alerts/toastIndicator.jsx

@@ -1,18 +1,106 @@
-import classNames from 'classnames';
 import PropTypes from 'prop-types';
 import React from 'react';
+import styled from 'react-emotion';
 
-function ToastIndicator({type, children}) {
+import {t} from '../../locale';
+import InlineSvg from '../inlineSvg';
+import LoadingIndicator from '../../components/loadingIndicator';
+
+const Toast = styled.div`
+  display: flex;
+  align-items: center;
+  height: 40px;
+  padding: 0 15px 0 10px;
+  margin-top: 15px;
+  background: #fff;
+  background-image: linear-gradient(
+    -180deg,
+    rgba(255, 255, 255, 0.12) 0%,
+    rgba(240, 238, 245, 0.35) 98%
+  );
+  color: ${p => p.theme.gray5};
+  border-radius: 44px 5px 5px 44px;
+  box-shadow: 0 0 0 1px rgba(47, 40, 55, 0.12), 0 1px 2px 0 rgba(47, 40, 55, 0.12),
+    0 4px 12px 0 rgba(47, 40, 55, 0.16);
+  transition: opacity 0.25s linear;
+
+  &.toast-enter {
+    opacity: 0;
+  }
+
+  &.toast-enter-active {
+    opacity: 1;
+  }
+
+  &.toast-leave {
+    opacity: 1;
+  }
+
+  &.toast-leave-active {
+    opacity: 0;
+  }
+`;
+
+const Icon = styled.div`
+  margin-right: 6px;
+  svg {
+    display: block;
+  }
+
+  color: ${p => (p.type == 'success' ? p.theme.green : p.theme.red)};
+`;
+
+const Message = styled.div`
+  flex: 1;
+`;
+
+const Undo = styled.div`
+  display: inline-block;
+  color: ${p => p.theme.gray2};
+  padding-left: 16px;
+  margin-left: 16px;
+  border-left: 1px solid ${p => p.theme.borderLight};
+  cursor: pointer;
+
+  &:hover {
+    color: ${p => p.theme.gray3};
+  }
+`;
+
+function ToastIndicator({indicator, onDismiss, ...props}) {
+  let icon;
+  let {options, message, type} = indicator;
+  let {undo, disableDismiss} = options || {};
+  let showUndo = typeof undo === 'function';
+  const handleClick = e => {
+    if (disableDismiss) return;
+    if (typeof onDismiss === 'function') {
+      onDismiss(indicator, e);
+    }
+  };
+
+  if (type == 'success') {
+    icon = <InlineSvg src="icon-circle-check" size="24px" />;
+  } else if (type == 'error') {
+    icon = <InlineSvg src="icon-circle-close" size="24px" />;
+  }
   return (
-    <div className={classNames('toast', type)}>
-      <span className="icon" />
-      <div className="toast-message">{children}</div>
-    </div>
+    <Toast onClick={handleClick} {...props}>
+      {type == 'loading' ? <LoadingIndicator mini /> : <Icon type={type}>{icon}</Icon>}
+      <Message>{message}</Message>
+      {showUndo && <Undo onClick={undo}>{t('Undo')}</Undo>}
+    </Toast>
   );
 }
 
 ToastIndicator.propTypes = {
-  type: PropTypes.string.isRequired,
+  indicator: PropTypes.shape({
+    type: PropTypes.oneOf(['error', 'success', 'loading', 'undo', '']),
+    id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+    message: PropTypes.node,
+    options: PropTypes.object,
+  }),
+  onDismiss: PropTypes.func,
 };
 
 export default ToastIndicator;

+ 72 - 28
src/sentry/static/sentry/app/components/indicators.jsx

@@ -1,15 +1,75 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 import createReactClass from 'create-react-class';
 import Reflux from 'reflux';
 import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
+import styled from 'react-emotion';
+import {ThemeProvider} from 'emotion-theming';
+import {cx} from 'emotion';
 
-import LoadingIndicator from '../components/loadingIndicator';
 import ToastIndicator from '../components/alerts/toastIndicator';
-
 import IndicatorStore from '../stores/indicatorStore';
+import theme from '../utils/theme';
+import {remove} from '../actionCreators/indicator';
+
+const Toasts = styled.div`
+  position: fixed;
+  right: 30px;
+  bottom: 30px;
+  z-index: ${p => p.theme.zIndex.toast};
+`;
+
+class Indicators extends React.Component {
+  static propTypes = {
+    items: PropTypes.arrayOf(
+      PropTypes.shape({
+        type: PropTypes.oneOf(['error', 'success', 'loading', 'undo', '']),
+        id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+        message: PropTypes.node,
+        options: PropTypes.object,
+      })
+    ),
+  };
+
+  static defaultProps = {
+    items: [],
+  };
+
+  handleDismiss = indicator => {
+    remove(indicator);
+  };
+
+  render() {
+    let {items, className, ...props} = this.props;
+
+    return (
+      <Toasts {...props} className={cx(className, 'ref-toasts')}>
+        <ReactCSSTransitionGroup
+          transitionName="toast"
+          transitionEnterTimeout={200}
+          transitionLeaveTimeout={200}
+        >
+          {items.map((indicator, i) => {
+            // We purposefully use `i` as key here because of transitions
+            // Toasts can now queue up, so when we change from [firstToast] -> [secondToast],
+            // we don't want to  animate `firstToast` out and `secondToast` in, rather we want
+            // to replace `firstToast` with `secondToast`
+            return (
+              <ToastIndicator
+                onDismiss={this.handleDismiss}
+                indicator={indicator}
+                key={i}
+              />
+            );
+          })}
+        </ReactCSSTransitionGroup>
+      </Toasts>
+    );
+  }
+}
 
-const Indicators = createReactClass({
-  displayName: 'Indicators',
+const IndicatorsContainer = createReactClass({
+  displayName: 'IndicatorsContainer',
   mixins: [Reflux.connect(IndicatorStore, 'items')],
 
   getInitialState() {
@@ -19,32 +79,16 @@ const Indicators = createReactClass({
   },
 
   render() {
+    // #NEW-SETTINGS - remove ThemeProvider here once new settings is merged
+    // `alerts.html` django view includes this container and doesn't have a theme provider
+    // not even sure it is used in django views but this is just an easier temp solution
     return (
-      <div {...this.props}>
-        <ReactCSSTransitionGroup
-          transitionName="toast"
-          transitionEnter={false}
-          transitionLeaveTimeout={500}
-        >
-          {this.state.items.map(indicator => {
-            if (indicator.type === 'error' || indicator.type === 'success') {
-              return (
-                <ToastIndicator type={indicator.type} key={indicator.id}>
-                  {indicator.message}
-                </ToastIndicator>
-              );
-            } else {
-              return (
-                <LoadingIndicator className="toast" key={indicator.id}>
-                  {indicator.message}
-                </LoadingIndicator>
-              );
-            }
-          })}
-        </ReactCSSTransitionGroup>
-      </div>
+      <ThemeProvider theme={theme}>
+        <Indicators {...this.props} items={this.state.items} />
+      </ThemeProvider>
     );
   },
 });
 
-export default Indicators;
+export default IndicatorsContainer;
+export {Indicators};

+ 58 - 5
src/sentry/static/sentry/app/stores/indicatorStore.jsx

@@ -1,10 +1,15 @@
 import Reflux from 'reflux';
 import {t} from '../locale';
+import IndicatorActions from '../actions/indicatorActions';
 
 const IndicatorStore = Reflux.createStore({
   init() {
     this.items = [];
     this.lastId = 0;
+    this.listenTo(IndicatorActions.append, this.append);
+    this.listenTo(IndicatorActions.replace, this.add);
+    this.listenTo(IndicatorActions.remove, this.remove);
+    this.listenTo(IndicatorActions.clear, this.clear);
   },
 
   addSuccess(message) {
@@ -15,30 +20,78 @@ const IndicatorStore = Reflux.createStore({
     return this.add(message, 'error', {duration: 2000});
   },
 
-  add(message, type, options) {
-    options = options || {};
-
+  addMessage(message, type, {append, ...options} = {}) {
     let indicator = {
       id: this.lastId++,
       message,
       type,
       options,
+      clearId: null,
     };
 
     if (options.duration) {
-      setTimeout(() => {
+      indicator.clearId = setTimeout(() => {
         this.remove(indicator);
       }, options.duration);
     }
-    this.items = [indicator]; // replace
+
+    let newItems = append ? [...this.items, indicator] : [indicator];
+
+    this.items = newItems;
     this.trigger(this.items);
     return indicator;
   },
 
+  /**
+   * Appends a message to be displayed in list of indicators
+   *
+   * @param {String} message Toast message to be displayed
+   * @param {String} type One of ['error', 'success', '']
+   * @param {Object} options Options object
+   * @param {Number} options.duration Duration the toast should be displayed
+   */
+  append(message, type, options) {
+    return this.addMessage(message, type, {
+      ...options,
+      append: true,
+    });
+  },
+
+  /**
+   * When this method is called directly via older parts of the application,
+   * we want to maintain the old behavior in that it is replaced (and not queued up)
+   *
+   * @param {String} message Toast message to be displayed
+   * @param {String} type One of ['error', 'success', '']
+   * @param {Object} options Options object
+   * @param {Number} options.duration Duration the toast should be displayed
+   */
+  add(message, type = 'loading', options) {
+    return this.addMessage(message, type, {
+      ...options,
+      append: false,
+    });
+  },
+
+  // Clear all indicators
+  clear() {
+    this.items = [];
+    this.trigger(this.items);
+  },
+
+  // Remove a single indicator
   remove(indicator) {
+    if (!indicator) return;
+
     this.items = this.items.filter(item => {
       return item !== indicator;
     });
+
+    if (indicator.clearId) {
+      window.clearTimeout(indicator.options.clearId);
+      indicator.options.clearId = null;
+    }
+
     this.trigger(this.items);
   },
 });

+ 0 - 64
src/sentry/static/sentry/app/stores/settingsIndicatorStore.jsx

@@ -1,64 +0,0 @@
-import Reflux from 'reflux';
-
-import {defined} from '../utils';
-import SettingsIndicatorActions from '../actions/settingsIndicatorActions';
-
-const SettingsIndicatorStore = Reflux.createStore({
-  init() {
-    this.state = null;
-    this.model = null;
-    this.id = null;
-    this.listenTo(SettingsIndicatorActions.add, this.add);
-    this.listenTo(SettingsIndicatorActions.undo, this.undo);
-    this.listenTo(SettingsIndicatorActions.remove, this.remove);
-  },
-
-  add(message, type, options = {}) {
-    if (options.model) {
-      this.model = options.model;
-    }
-    this.id = options.id;
-
-    this.state = {
-      options: {
-        ...options,
-
-        // Use options, else default to disable if model does not exist
-        disableUndo: defined(options.disableUno) ? options.disableUndo : !options.model,
-      },
-      message,
-      type,
-    };
-    this.trigger(this.state);
-  },
-
-  remove() {
-    // Do nothing if already null
-    if (!this.state) return;
-
-    this.state = null;
-    this.trigger(this.state);
-  },
-
-  undo() {
-    if (!this.model || !this.id) return;
-
-    // Remove current messages
-    this.remove();
-    let oldValue = this.model.getValue(this.id);
-    let didUndo = this.model.undo();
-    let newValue = this.model.getValue(this.id);
-
-    if (!didUndo) return;
-
-    // billy: I don't like the store <-> model coupling
-    let label = this.model.getDescriptor(this.id, 'label');
-    if (!label) return;
-
-    this.model.saveField(this.id, newValue).then(() => {
-      this.add(`Restored ${label} from "${oldValue}" to "${newValue}"`, 'undo', 5000);
-    });
-  },
-});
-
-export default SettingsIndicatorStore;

+ 1 - 0
src/sentry/static/sentry/app/utils/theme.jsx

@@ -56,6 +56,7 @@ const theme = {
     header: 1000,
     dropdown: 1001,
     modal: 10000,
+    toast: 10001,
   },
 
   alert: {

Некоторые файлы не были показаны из-за большого количества измененных файлов