Repository: mapbox/mr-ui
Pull request #59 (2018-11-21 17:45:52)

Add ControlAlert component, tests, and examples
### Related issues

This PR closes https://github.com/mapbox/mr-ui/issues/50 as it adds `ControlAlert`.

### Description of changes

👋 Open to discussion/feedback!

**ControlAlert**

This kind of alert has been used by Accounts-related web apps.

- New component, examples, and tests that uses `Icon` and `Tooltip`. Allows opinionated variation in the following:
  - autoFocus
  - onButtonClick
  - theme ('error', 'locked', 'success', 'warning')

- Conscious design decisions with @angel:
  - While cards and modals can `Close` or `Collapse`, alerts are `Dismiss`ed
  - Theme coloring and icons are set by severity:
    - success (green)
    - warning or locked (yellow)
    - error (red)
  - TODO:
    - Should the icons scale with the children text? We do this for `ChevronousText` but currently not for `ControlCard`. The screenshots below display `Icon` with a set width of `18px` and an adjusting inline height to the the parent element's line-height.

### Testing

- Created/updated documentation examples for each component. See the documentation Batfish site with `npm run start-docs`.
- Created/updated Jest test cases and snapshots for each component. See `npm test` and the test cases app with `npm start`.

<img width="518" alt="ControlAlert doc examples" src="https://user-images.githubusercontent.com/9087698/48858842-505a9080-ed71-11e8-8bbd-3b510eaa08fe.png">

<img width="518" alt="ControlAlert test cases" src="https://user-images.githubusercontent.com/9087698/48858854-56e90800-ed71-11e8-880b-6f9b9a42254a.png">



angel: @lshig these look good to me, why is the yellow power ranger centered and in caps?
lshig: @angel Oh. Just an example of what's possible... Yellow, eye-catching, and right in the center.

![yellow power ranger](https://user-images.githubusercontent.com/9087698/48860420-b6e1ad80-ed75-11e8-93ce-2bf711855abb.gif)

The documentation examples will be hosted at https://mapbox.github.io/mr-ui/ once these changes are released. They can also be viewable locally if you run `npm run start-docs`.

angel: @lshig can we restrict that the text is always left aligned? 
lshig: @angel We can make it a general rule of thumb by design: when we use this component in web apps at Mapbox, we should by default let `ControlAlert`'s children body text be left aligned and sentence cased. I don't think it should be the responsibility of `ControlAlert` to enforce the prop `children`'s styling since we vary in use of margins, padding, capitalization, text size, text alignment, etc.

We discussed this on Slack, but FYI to anyone curious, we decided to scale the `ControlAlert` icons with `1.3em` width. Also to encourage text to be sentence cased or left aligned by example, I adjusted the doc example at 9e44044.

<img width="566" alt="screen shot 2018-11-21 at 11 41 41 am" src="https://user-images.githubusercontent.com/9087698/48865548-c6b4be00-ed84-11e8-85bc-9c48ae56f977.png">


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d23360..6bf6b28 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
 # Changelog
 
 ## 0.4.0 - Next release
+- [feature] Add **ControlAlert** component.
 - [feature] Add **ControlCard** component.
 - [feature] Add **GoLink** component.
 - [feature] Add **NewTabLink** component.
diff --git a/src/components/control-alert/control-alert.js b/src/components/control-alert/control-alert.js
new file mode 100644
index 0000000..43b620c
--- /dev/null
+++ b/src/components/control-alert/control-alert.js
@@ -0,0 +1,99 @@
+import classnames from 'classnames';
+import React from 'react';
+import PropTypes from 'prop-types';
+import Icon from '../icon';
+import Tooltip from '../tooltip';
+
+export default class ControlAlert extends React.Component {
+  static propTypes = {
+    /** Whether to use ARIA alert role for screen readers or not. */
+    autoFocus: PropTypes.bool,
+    /** The content of the alert. */
+    children: PropTypes.node.isRequired,
+    /**
+     * Called on click of dismiss button. Must have this callback to display
+     * the button.
+     */
+    onButtonClick: PropTypes.func,
+    /**
+     * The alert `theme` options are "error", "locked", "success", and
+     * "warning".
+     */
+    theme: PropTypes.oneOf(['error', 'locked', 'success', 'warning']).isRequired
+  };
+
+  static defaultProps = {
+    autoFocus: false
+  };
+
+  componentDidMount() {
+    const { autoFocus } = this.props;
+
+    if (autoFocus && this.contentRef) {
+      this.contentRef.focus();
+    }
+  }
+
+  handleContentRef = ref => {
+    this.contentRef = ref;
+  };
+
+  onButtonClick = () => {
+    const { onButtonClick } = this.props;
+
+    if (onButtonClick) {
+      onButtonClick();
+    }
+  };
+
+  renderDismissButton() {
+    const { onButtonClick } = this.props;
+
+    if (!onButtonClick) return null;
+
+    return (
+      <Tooltip content="Dismiss" block={true}>
+        <button
+          className="bg-transparent color-gray-dark color-blue-on-hover px0 py0"
+          data-test="alert-dismiss"
+          onClick={this.onButtonClick}
+        >
+          <Icon name="close" inline={true} size="1.3em" />
+        </button>
+      </Tooltip>
+    );
+  }
+
+  render() {
+    const { autoFocus, children, theme } = this.props;
+    const containerClasses = classnames('round px24 py24 flex-parent', {
+      'bg-green-light': theme === 'success',
+      'bg-red-faint': theme === 'error',
+      'bg-yellow-light': theme === 'warning' || theme === 'locked'
+    });
+
+    let iconName = 'alert';
+    if (theme === 'success') {
+      iconName = 'check';
+    } else if (theme === 'locked') {
+      iconName = 'lock';
+    }
+
+    return (
+      <div className={containerClasses}>
+        <div className="flex-child flex-child--no-shrink">
+          <Icon name={iconName} inline={true} size="1.3em" />
+        </div>
+        <div
+          className="flex-child flex-child--grow"
+          ref={this.handleContentRef}
+          role={autoFocus ? 'alert' : null}
+          tabIndex={autoFocus ? -1 : null}
+        >
+          {children}
+        </div>
+        {this.renderDismissButton()}
+      </div>
+    );
+  }
+}
diff --git a/src/components/control-alert/examples/control-alert-example-basic.js b/src/components/control-alert/examples/control-alert-example-basic.js
new file mode 100644
index 0000000..e4d8416
--- /dev/null
+++ b/src/components/control-alert/examples/control-alert-example-basic.js
@@ -0,0 +1,23 @@
+/*
+Basic.
+*/
+import React from 'react';
+import ControlAlert from '../control-alert';
+
+export default class Example extends React.Component {
+  render() {
+    return (
+      <div className="txt-m">
+        <ControlAlert theme="success">
+          <p className="mx6">
+            <a className="link" href="http://www.mapbox.com">
+              You're the best!
+            </a>{' '}
+            Around! Nothing's gonna keep you down. You're the best! Around!
+            Nothing's gonna keep you down.
+          </p>
+        </ControlAlert>
+      </div>
+    );
+  }
+}
diff --git a/src/components/control-alert/examples/control-alert-example-options.js b/src/components/control-alert/examples/control-alert-example-options.js
new file mode 100644
index 0000000..13f5a6d
--- /dev/null
+++ b/src/components/control-alert/examples/control-alert-example-options.js
@@ -0,0 +1,66 @@
+/*
+Control alert with callback to toggle warning themed alert in view.
+*/
+import React from 'react';
+import ControlAlert from '../control-alert';
+import Icon from '../../icon';
+
+export default class Example extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      showAlert: true
+    };
+  }
+
+  toggleAlert = () => {
+    const { showAlert } = this.state;
+
+    this.setState({
+      showAlert: !showAlert
+    });
+  };
+
+  renderShowAlertButton() {
+    const { showAlert } = this.state;
+
+    if (showAlert) return null;
+
+    return (
+      <div className="flex-parent flex-parent--end-main mr18 mb18">
+        <button
+          className="color-gray-dark color-blue-on-hover flex-child"
+          onClick={this.toggleAlert}
+          type="button"
+        >
+          <span className="inline-block txt-s">
+            Show alert again <Icon name="refresh" inline={true} />
+          </span>
+        </button>
+      </div>
+    );
+  }
+
+  renderAlert() {
+    const { showAlert } = this.state;
+
+    if (!showAlert) return null;
+
+    return (
+      <div className="animation-fade-in animation--speed-1 txt-s">
+        <ControlAlert onButtonClick={this.toggleAlert} theme="warning">
+          <p className="mx6">The yellow power ranger is the best.</p>
+        </ControlAlert>
+      </div>
+    );
+  }
+
+  render() {
+    return (
+      <div>
+        {this.renderShowAlertButton()}
+        {this.renderAlert()}
+      </div>
+    );
+  }
+}
diff --git a/src/components/control-alert/index.js b/src/components/control-alert/index.js
new file mode 100644
index 0000000..9e90c70
--- /dev/null
+++ b/src/components/control-alert/index.js
@@ -0,0 +1,3 @@
+import main from './control-alert';
+
+export default main;
diff --git a/src/components/control-card/examples/control-card-example-options.js b/src/components/control-card/examples/control-card-example-options.js
index 9319b3c..62b550a 100644
--- a/src/components/control-card/examples/control-card-example-options.js
+++ b/src/components/control-card/examples/control-card-example-options.js
@@ -1,5 +1,5 @@
 /*
-Controlled card with options.
+Control card with callback to toggle collapsable card in view and title options.
 */
 import React from 'react';
 import ControlCard from '../control-card';


Test patch:
diff --git a/src/components/control-alert/__tests__/__snapshots__/control-alert.test.js.snap b/src/components/control-alert/__tests__/__snapshots__/control-alert.test.js.snap
new file mode 100644
index 0000000..14798bd
--- /dev/null
+++ b/src/components/control-alert/__tests__/__snapshots__/control-alert.test.js.snap
@@ -0,0 +1,315 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ControlAlert basic error renders as expected 1`] = `
+<div
+  className="round px24 py24 flex-parent bg-red-faint"
+>
+  <div
+    className="flex-child flex-child--no-shrink"
+  >
+    <Icon
+      inline={true}
+      name="alert"
+      passthroughProps={Object {}}
+      size="1.3em"
+    />
+  </div>
+  <div
+    className="flex-child flex-child--grow"
+    role={null}
+    tabIndex={null}
+  >
+    <span
+      className="ml6"
+    >
+      Drats.
+    </span>
+  </div>
+</div>
+`;
+
+exports[`ControlAlert basic locked renders as expected 1`] = `
+<div
+  className="round px24 py24 flex-parent bg-yellow-light"
+>
+  <div
+    className="flex-child flex-child--no-shrink"
+  >
+    <Icon
+      inline={true}
+      name="lock"
+      passthroughProps={Object {}}
+      size="1.3em"
+    />
+  </div>
+  <div
+    className="flex-child flex-child--grow"
+    role={null}
+    tabIndex={null}
+  >
+    <span
+      className="ml6"
+    >
+      Nope.
+    </span>
+  </div>
+</div>
+`;
+
+exports[`ControlAlert basic success renders as expected 1`] = `
+<div
+  className="round px24 py24 flex-parent bg-green-light"
+>
+  <div
+    className="flex-child flex-child--no-shrink"
+  >
+    <Icon
+      inline={true}
+      name="check"
+      passthroughProps={Object {}}
+      size="1.3em"
+    />
+  </div>
+  <div
+    className="flex-child flex-child--grow"
+    role={null}
+    tabIndex={null}
+  >
+    <span
+      className="ml6"
+    >
+      Woohoo!
+    </span>
+  </div>
+</div>
+`;
+
+exports[`ControlAlert dismissive multiline error onButtonClick is called 1`] = `
+<div
+  className="round px24 py24 flex-parent bg-red-faint"
+>
+  <div
+    className="flex-child flex-child--no-shrink"
+  >
+    <Icon
+      inline={true}
+      name="alert"
+      passthroughProps={Object {}}
+      size="1.3em"
+    />
+  </div>
+  <div
+    className="flex-child flex-child--grow"
+    role={null}
+    tabIndex={null}
+  >
+    <p
+      className="mx6"
+    >
+      This is the song that never ends.
+       
+      <a
+        className="link"
+        href="http://www.mapbox.com"
+      >
+        Yes, it just goes on and on my friends.
+      </a>
+       
+      Some people started singing it, not knowing what it was. And they'll continue singing it forever just because. This is the song that never ends.
+    </p>
+  </div>
+  <Tooltip
+    alignment="center"
+    block={true}
+    coloring="light"
+    content="Dismiss"
+    disabled={false}
+    maxWidth="medium"
+    padding="small"
+    placement="top"
+    respondsToClick={false}
+    textSize="s"
+  >
+    <button
+      className="bg-transparent color-gray-dark color-blue-on-hover px0 py0"
+      data-test="alert-dismiss"
+      onClick={[Function]}
+    >
+      <Icon
+        inline={true}
+        name="close"
+        passthroughProps={Object {}}
+        size="1.3em"
+      />
+    </button>
+  </Tooltip>
+</div>
+`;
+
+exports[`ControlAlert dismissive multiline error renders as expected 1`] = `
+<div
+  className="round px24 py24 flex-parent bg-red-faint"
+>
+  <div
+    className="flex-child flex-child--no-shrink"
+  >
+    <Icon
+      inline={true}
+      name="alert"
+      passthroughProps={Object {}}
+      size="1.3em"
+    />
+  </div>
+  <div
+    className="flex-child flex-child--grow"
+    role={null}
+    tabIndex={null}
+  >
+    <p
+      className="mx6"
+    >
+      This is the song that never ends.
+       
+      <a
+        className="link"
+        href="http://www.mapbox.com"
+      >
+        Yes, it just goes on and on my friends.
+      </a>
+       
+      Some people started singing it, not knowing what it was. And they'll continue singing it forever just because. This is the song that never ends.
+    </p>
+  </div>
+  <Tooltip
+    alignment="center"
+    block={true}
+    coloring="light"
+    content="Dismiss"
+    disabled={false}
+    maxWidth="medium"
+    padding="small"
+    placement="top"
+    respondsToClick={false}
+    textSize="s"
+  >
+    <button
+      className="bg-transparent color-gray-dark color-blue-on-hover px0 py0"
+      data-test="alert-dismiss"
+      onClick={[Function]}
+    >
+      <Icon
+        inline={true}
+        name="close"
+        passthroughProps={Object {}}
+        size="1.3em"
+      />
+    </button>
+  </Tooltip>
+</div>
+`;
+
+exports[`ControlAlert dismissive warning onButtonClick is called 1`] = `
+<div
+  className="round px24 py24 flex-parent bg-yellow-light"
+>
+  <div
+    className="flex-child flex-child--no-shrink"
+  >
+    <Icon
+      inline={true}
+      name="alert"
+      passthroughProps={Object {}}
+      size="1.3em"
+    />
+  </div>
+  <div
+    className="flex-child flex-child--grow"
+    role={null}
+    tabIndex={null}
+  >
+    <span
+      className="mx6"
+    >
+      Uh oh...
+    </span>
+  </div>
+  <Tooltip
+    alignment="center"
+    block={true}
+    coloring="light"
+    content="Dismiss"
+    disabled={false}
+    maxWidth="medium"
+    padding="small"
+    placement="top"
+    respondsToClick={false}
+    textSize="s"
+  >
+    <button
+      className="bg-transparent color-gray-dark color-blue-on-hover px0 py0"
+      data-test="alert-dismiss"
+      onClick={[Function]}
+    >
+      <Icon
+        inline={true}
+        name="close"
+        passthroughProps={Object {}}
+        size="1.3em"
+      />
+    </button>
+  </Tooltip>
+</div>
+`;
+
+exports[`ControlAlert dismissive warning renders as expected 1`] = `
+<div
+  className="round px24 py24 flex-parent bg-yellow-light"
+>
+  <div
+    className="flex-child flex-child--no-shrink"
+  >
+    <Icon
+      inline={true}
+      name="alert"
+      passthroughProps={Object {}}
+      size="1.3em"
+    />
+  </div>
+  <div
+    className="flex-child flex-child--grow"
+    role={null}
+    tabIndex={null}
+  >
+    <span
+      className="mx6"
+    >
+      Uh oh...
+    </span>
+  </div>
+  <Tooltip
+    alignment="center"
+    block={true}
+    coloring="light"
+    content="Dismiss"
+    disabled={false}
+    maxWidth="medium"
+    padding="small"
+    placement="top"
+    respondsToClick={false}
+    textSize="s"
+  >
+    <button
+      className="bg-transparent color-gray-dark color-blue-on-hover px0 py0"
+      data-test="alert-dismiss"
+      onClick={[Function]}
+    >
+      <Icon
+        inline={true}
+        name="close"
+        passthroughProps={Object {}}
+        size="1.3em"
+      />
+    </button>
+  </Tooltip>
+</div>
+`;
diff --git a/src/components/control-alert/__tests__/control-alert-test-cases.js b/src/components/control-alert/__tests__/control-alert-test-cases.js
new file mode 100644
index 0000000..641ae39
--- /dev/null
+++ b/src/components/control-alert/__tests__/control-alert-test-cases.js
@@ -0,0 +1,64 @@
+import ControlAlert from '../control-alert';
+import React from 'react';
+import safeSpy from '../../../test-utils/safe-spy';
+
+const testCases = {};
+
+testCases.basicError = {
+  component: ControlAlert,
+  description: 'basic error',
+  props: {
+    children: <span className="ml6">Drats.</span>,
+    theme: 'error'
+  }
+};
+
+testCases.basicLocked = {
+  component: ControlAlert,
+  description: 'basic locked',
+  props: {
+    children: <span className="ml6">Nope.</span>,
+    theme: 'locked'
+  }
+};
+
+testCases.basicSuccess = {
+  component: ControlAlert,
+  description: 'basic success',
+  props: {
+    children: <span className="ml6">Woohoo!</span>,
+    theme: 'success'
+  }
+};
+
+testCases.dismissiveWarning = {
+  component: ControlAlert,
+  description: 'dismissive warning',
+  props: {
+    children: <span className="mx6">Uh oh...</span>,
+    onButtonClick: safeSpy(),
+    theme: 'warning'
+  }
+};
+
+testCases.dismissiveMultilineError = {
+  component: ControlAlert,
+  description: 'dismissive multiline error',
+  props: {
+    children: (
+      <p className="mx6">
+        This is the song that never ends.{' '}
+        <a className="link" href="http://www.mapbox.com">
+          Yes, it just goes on and on my friends.
+        </a>{' '}
+        Some people started singing it, not knowing what it was. And they'll
+        continue singing it forever just because. This is the song that never
+        ends.
+      </p>
+    ),
+    onButtonClick: safeSpy(),
+    theme: 'error'
+  }
+};
+
+export { testCases };
diff --git a/src/components/control-alert/__tests__/control-alert.test.js b/src/components/control-alert/__tests__/control-alert.test.js
new file mode 100644
index 0000000..d042743
--- /dev/null
+++ b/src/components/control-alert/__tests__/control-alert.test.js
@@ -0,0 +1,104 @@
+import { shallow } from 'enzyme';
+import { testCases } from './control-alert-test-cases';
+import React from 'react';
+
+describe('ControlAlert', () => {
+  describe(testCases.basicError.description, () => {
+    const wrapper = shallow(
+      React.createElement(
+        testCases.basicError.component,
+        testCases.basicError.props
+      )
+    );
+
+    test('renders as expected', () => {
+      expect(wrapper).toMatchSnapshot();
+    });
+  });
+
+  describe(testCases.basicLocked.description, () => {
+    const wrapper = shallow(
+      React.createElement(
+        testCases.basicLocked.component,
+        testCases.basicLocked.props
+      )
+    );
+
+    test('renders as expected', () => {
+      expect(wrapper).toMatchSnapshot();
+    });
+  });
+
+  describe(testCases.basicSuccess.description, () => {
+    const wrapper = shallow(
+      React.createElement(
+        testCases.basicSuccess.component,
+        testCases.basicSuccess.props
+      )
+    );
+
+    test('renders as expected', () => {
+      expect(wrapper).toMatchSnapshot();
+    });
+  });
+
+  describe(testCases.dismissiveWarning.description, () => {
+    let wrapper;
+
+    beforeEach(() => {
+      wrapper = shallow(
+        React.createElement(
+          testCases.dismissiveWarning.component,
+          testCases.dismissiveWarning.props
+        )
+      );
+    });
+
+    test('renders as expected', () => {
+      expect(wrapper).toMatchSnapshot();
+    });
+
+    test('onButtonClick is called', () => {
+      wrapper
+        .find('button')
+        .first()
+        .props()
+        .onClick();
+      wrapper.update();
+      expect(wrapper).toMatchSnapshot();
+      expect(
+        testCases.dismissiveWarning.props.onButtonClick
+      ).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe(testCases.dismissiveMultilineError.description, () => {
+    let wrapper;
+
+    beforeEach(() => {
+      wrapper = shallow(
+        React.createElement(
+          testCases.dismissiveMultilineError.component,
+          testCases.dismissiveMultilineError.props
+        )
+      );
+    });
+
+    test('renders as expected', () => {
+      expect(wrapper).toMatchSnapshot();
+    });
+
+    test('onButtonClick is called', () => {
+      wrapper
+        .find('button')
+        .first()
+        .props()
+        .onClick();
+      wrapper.update();
+      expect(wrapper).toMatchSnapshot();
+      expect(
+        testCases.dismissiveMultilineError.props.onButtonClick
+      ).toHaveBeenCalledTimes(1);
+    });
+  });
+});