Repository: mapbox/mr-ui
Pull request #57 (2018-11-16 19:03:39)

Add ControlCard
### Related issues

This PR closes https://github.com/mapbox/mr-ui/issues/51 as it adds `ControlCard`.

### Description of changes

👋 Open to discussion/feedback!

**ControlCard**

- New component, examples, and tests that uses `Heading` and `Icon`. Allows opinionated variation in the following:
  - buttonType ('close', 'collapse')
  - onButtonClick
  - title
  - titleSize ('primary', 'secondary', 'tertiary', 'minor')

Traditionally, this kind of card has only been used in the Account Dashboard and Atlas's Account Dashboard.

### 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`.

![controlcard](https://user-images.githubusercontent.com/9087698/48641523-e7e56b00-e98e-11e8-9a23-8aa1c2962cef.png)

<img width="696" alt="docexamples" src="https://user-images.githubusercontent.com/9087698/48641533-ec118880-e98e-11e8-8e27-7d76379e5682.png">


davidtheclark: @kepta assigning this one to you for review.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80948fd..3d23360 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
 # Changelog
 
 ## 0.4.0 - Next release
+- [feature] Add **ControlCard** component.
 - [feature] Add **GoLink** component.
 - [feature] Add **NewTabLink** component.
 - **ChevronousText**
diff --git a/src/components/control-card/control-card.js b/src/components/control-card/control-card.js
new file mode 100644
index 0000000..b4dafb3
--- /dev/null
+++ b/src/components/control-card/control-card.js
@@ -0,0 +1,92 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Heading from '../heading';
+import Icon from '../icon';
+import Tooltip from '../tooltip';
+
+export default class ControlCard extends React.Component {
+  static propTypes = {
+    /**
+     * The type of update to the card. Options are "close" and "collapse".
+     * Must have `onButtonClick` prop to enable action and button.
+     */
+    buttonType: PropTypes.oneOf(['close', 'collapse']),
+    /** The content of the card. */
+    children: PropTypes.node.isRequired,
+    /**
+     * Called on click of update button. Must have `buttonType` prop to enable
+     * this action and button.
+     */
+    onButtonClick: PropTypes.func,
+    /** Card title heading. */
+    title: PropTypes.string,
+    /**
+     * The card title heading variant. Options are "primary", "secondary",
+     * "tertiary", and "minor".
+     */
+    titleSize: PropTypes.oneOf(['primary', 'secondary', 'tertiary', 'minor'])
+  };
+
+  static defaultProps = {
+    buttonType: 'close',
+    titleSize: 'tertiary'
+  };
+
+  onButtonClick = () => {
+    const { onButtonClick } = this.props;
+    if (onButtonClick) {
+      onButtonClick();
+    }
+  };
+
+  renderTitle() {
+    const { title, titleSize } = this.props;
+
+    if (!title) return null;
+
+    return (
+      <div className="mb24">
+        <Heading variant={titleSize}>{title}</Heading>
+      </div>
+    );
+  }
+
+  renderUpdateButton() {
+    const { onButtonClick, buttonType } = this.props;
+
+    if (!onButtonClick || !buttonType) return null;
+
+    let toolTipMessage = 'Close';
+    let updateIcon = 'close';
+    if (buttonType === 'collapse') {
+      toolTipMessage = 'Collapse';
+      updateIcon = 'caret-up';
+    }
+
+    return (
+      <div className="absolute top right mt18 mr18">
+        <Tooltip content={toolTipMessage} block={true}>
+          <button
+            type="button"
+            className="bg-transparent color-gray-dark color-blue-on-hover px0 py0"
+            onClick={onButtonClick}
+          >
+            <Icon name={updateIcon} />
+          </button>
+        </Tooltip>
+      </div>
+    );
+  }
+
+  render() {
+    const { children } = this.props;
+
+    return (
+      <section className="bg-white round-bold shadow-darken10-bold relative py36 py60-ml px18 px36-mm px60-ml">
+        {this.renderTitle()}
+        {this.renderUpdateButton()}
+        {children}
+      </section>
+    );
+  }
+}
diff --git a/src/components/control-card/examples/control-card-example-basic.js b/src/components/control-card/examples/control-card-example-basic.js
new file mode 100644
index 0000000..9cf5f26
--- /dev/null
+++ b/src/components/control-card/examples/control-card-example-basic.js
@@ -0,0 +1,17 @@
+/*
+Basic.
+*/
+import React from 'react';
+import ControlCard from '../control-card';
+
+export default class Example extends React.Component {
+  render() {
+    return (
+      <ControlCard>
+        <p className="bg-gray-faint txt-mono txt-break-word px12 py12">
+          console.log('Hello world!');
+        </p>
+      </ControlCard>
+    );
+  }
+}
diff --git a/src/components/control-card/examples/control-card-example-options.js b/src/components/control-card/examples/control-card-example-options.js
new file mode 100644
index 0000000..9319b3c
--- /dev/null
+++ b/src/components/control-card/examples/control-card-example-options.js
@@ -0,0 +1,71 @@
+/*
+Controlled card with options.
+*/
+import React from 'react';
+import ControlCard from '../control-card';
+import Icon from '../../icon';
+
+export default class Example extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      showCard: true
+    };
+  }
+
+  toggleCard = () => {
+    const { showCard } = this.state;
+
+    this.setState({
+      showCard: !showCard
+    });
+  };
+
+  renderShowCardButton() {
+    const { showCard } = this.state;
+
+    if (showCard) 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.toggleCard}
+          type="button"
+        >
+          <span className="inline-block txt-s">
+            Show card <Icon name="caret-down" inline={true} />
+          </span>
+        </button>
+      </div>
+    );
+  }
+
+  renderCard() {
+    const { showCard } = this.state;
+
+    if (!showCard) return null;
+
+    return (
+      <ControlCard
+        buttonType="collapse"
+        onButtonClick={this.toggleCard}
+        title="Example card with options"
+        titleSize="minor"
+      >
+        <p className="bg-gray-faint txt-mono txt-break-word px12 py12">
+          console.log('Hello world!');
+        </p>
+      </ControlCard>
+    );
+  }
+
+  render() {
+    return (
+      <div>
+        {this.renderShowCardButton()}
+        {this.renderCard()}
+      </div>
+    );
+  }
+}
diff --git a/src/components/control-card/index.js b/src/components/control-card/index.js
new file mode 100644
index 0000000..317deea
--- /dev/null
+++ b/src/components/control-card/index.js
@@ -0,0 +1,3 @@
+import main from './control-card';
+
+export default main;


Test patch:
diff --git a/src/components/control-card/__tests__/__snapshots__/control-card.test.js.snap b/src/components/control-card/__tests__/__snapshots__/control-card.test.js.snap
new file mode 100644
index 0000000..05c6d92
--- /dev/null
+++ b/src/components/control-card/__tests__/__snapshots__/control-card.test.js.snap
@@ -0,0 +1,251 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ControlCard basic renders as expected 1`] = `
+<section
+  className="bg-white round-bold shadow-darken10-bold relative py36 py60-ml px18 px36-mm px60-ml"
+>
+  This is a basic card.
+</section>
+`;
+
+exports[`ControlCard collapsible onButtonClick is called 1`] = `
+<section
+  className="bg-white round-bold shadow-darken10-bold relative py36 py60-ml px18 px36-mm px60-ml"
+>
+  <div
+    className="mb24"
+  >
+    <Heading
+      variant="tertiary"
+    >
+      Title
+    </Heading>
+  </div>
+  <div
+    className="absolute top right mt18 mr18"
+  >
+    <Tooltip
+      alignment="center"
+      block={true}
+      coloring="light"
+      content="Collapse"
+      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"
+        onClick={
+          [MockFunction] {
+            "calls": Array [
+              Array [],
+            ],
+            "results": Array [
+              Object {
+                "isThrow": false,
+                "value": undefined,
+              },
+            ],
+          }
+        }
+        type="button"
+      >
+        <Icon
+          inline={false}
+          name="caret-up"
+          passthroughProps={Object {}}
+          size={18}
+        />
+      </button>
+    </Tooltip>
+  </div>
+  This is a titled card with a collapsible button.
+</section>
+`;
+
+exports[`ControlCard collapsible renders as expected 1`] = `
+<section
+  className="bg-white round-bold shadow-darken10-bold relative py36 py60-ml px18 px36-mm px60-ml"
+>
+  <div
+    className="mb24"
+  >
+    <Heading
+      variant="tertiary"
+    >
+      Title
+    </Heading>
+  </div>
+  <div
+    className="absolute top right mt18 mr18"
+  >
+    <Tooltip
+      alignment="center"
+      block={true}
+      coloring="light"
+      content="Collapse"
+      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"
+        onClick={[MockFunction]}
+        type="button"
+      >
+        <Icon
+          inline={false}
+          name="caret-up"
+          passthroughProps={Object {}}
+          size={18}
+        />
+      </button>
+    </Tooltip>
+  </div>
+  This is a titled card with a collapsible button.
+</section>
+`;
+
+exports[`ControlCard sized title and closable onButtonClick is called 1`] = `
+<section
+  className="bg-white round-bold shadow-darken10-bold relative py36 py60-ml px18 px36-mm px60-ml"
+>
+  <div
+    className="mb24"
+  >
+    <Heading
+      variant="secondary"
+    >
+      Title
+    </Heading>
+  </div>
+  <div
+    className="absolute top right mt18 mr18"
+  >
+    <Tooltip
+      alignment="center"
+      block={true}
+      coloring="light"
+      content="Close"
+      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"
+        onClick={
+          [MockFunction] {
+            "calls": Array [
+              Array [],
+            ],
+            "results": Array [
+              Object {
+                "isThrow": false,
+                "value": undefined,
+              },
+            ],
+          }
+        }
+        type="button"
+      >
+        <Icon
+          inline={false}
+          name="close"
+          passthroughProps={Object {}}
+          size={18}
+        />
+      </button>
+    </Tooltip>
+  </div>
+  This is a sized titled card with a closable button.
+</section>
+`;
+
+exports[`ControlCard sized title and closable renders as expected 1`] = `
+<section
+  className="bg-white round-bold shadow-darken10-bold relative py36 py60-ml px18 px36-mm px60-ml"
+>
+  <div
+    className="mb24"
+  >
+    <Heading
+      variant="secondary"
+    >
+      Title
+    </Heading>
+  </div>
+  <div
+    className="absolute top right mt18 mr18"
+  >
+    <Tooltip
+      alignment="center"
+      block={true}
+      coloring="light"
+      content="Close"
+      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"
+        onClick={[MockFunction]}
+        type="button"
+      >
+        <Icon
+          inline={false}
+          name="close"
+          passthroughProps={Object {}}
+          size={18}
+        />
+      </button>
+    </Tooltip>
+  </div>
+  This is a sized titled card with a closable button.
+</section>
+`;
+
+exports[`ControlCard sized title renders as expected 1`] = `
+<section
+  className="bg-white round-bold shadow-darken10-bold relative py36 py60-ml px18 px36-mm px60-ml"
+>
+  <div
+    className="mb24"
+  >
+    <Heading
+      variant="primary"
+    >
+      Title
+    </Heading>
+  </div>
+  This is a sized titled card.
+</section>
+`;
+
+exports[`ControlCard title renders as expected 1`] = `
+<section
+  className="bg-white round-bold shadow-darken10-bold relative py36 py60-ml px18 px36-mm px60-ml"
+>
+  <div
+    className="mb24"
+  >
+    <Heading
+      variant="tertiary"
+    >
+      Title
+    </Heading>
+  </div>
+  This is a titled card.
+</section>
+`;
diff --git a/src/components/control-card/__tests__/control-card-test-cases.js b/src/components/control-card/__tests__/control-card-test-cases.js
new file mode 100644
index 0000000..284f24b
--- /dev/null
+++ b/src/components/control-card/__tests__/control-card-test-cases.js
@@ -0,0 +1,56 @@
+import safeSpy from '../../../test-utils/safe-spy';
+import ControlCard from '../control-card';
+
+const testCases = {};
+
+testCases.basic = {
+  description: 'basic',
+  component: ControlCard,
+  props: {
+    children: 'This is a basic card.'
+  }
+};
+
+testCases.title = {
+  description: 'title',
+  component: ControlCard,
+  props: {
+    children: 'This is a titled card.',
+    title: 'Title'
+  }
+};
+
+testCases.sizedTitle = {
+  description: 'sized title',
+  component: ControlCard,
+  props: {
+    children: 'This is a sized titled card.',
+    title: 'Title',
+    titleSize: 'primary'
+  }
+};
+
+testCases.collapsible = {
+  description: 'collapsible',
+  component: ControlCard,
+  props: {
+    buttonType: 'collapse',
+    children: 'This is a titled card with a collapsible button.',
+    onButtonClick: safeSpy(),
+    title: 'Title'
+  }
+};
+
+testCases.sizedTitleclosable = {
+  description: 'sized title and closable',
+  component: ControlCard,
+  props: {
+    buttonType: 'close',
+    children: 'This is a sized titled card with a closable button.',
+    onButtonClick: safeSpy(),
+    title: 'Title',
+    titleSize: 'secondary'
+  }
+};
+
+export { testCases };
diff --git a/src/components/control-card/__tests__/control-card.test.js b/src/components/control-card/__tests__/control-card.test.js
new file mode 100644
index 0000000..2ea9360
--- /dev/null
+++ b/src/components/control-card/__tests__/control-card.test.js
@@ -0,0 +1,98 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { testCases } from './control-card-test-cases';
+
+describe('ControlCard', () => {
+  describe(testCases.basic.description, () => {
+    const wrapper = shallow(
+      React.createElement(testCases.basic.component, testCases.basic.props)
+    );
+
+    test('renders as expected', () => {
+      expect(wrapper).toMatchSnapshot();
+    });
+  });
+
+  describe(testCases.title.description, () => {
+    const wrapper = shallow(
+      React.createElement(testCases.title.component, testCases.title.props)
+    );
+
+    test('renders as expected', () => {
+      expect(wrapper).toMatchSnapshot();
+    });
+  });
+
+  describe(testCases.sizedTitle.description, () => {
+    const wrapper = shallow(
+      React.createElement(
+        testCases.sizedTitle.component,
+        testCases.sizedTitle.props
+      )
+    );
+
+    test('renders as expected', () => {
+      expect(wrapper).toMatchSnapshot();
+    });
+  });
+
+  describe(testCases.collapsible.description, () => {
+    let wrapper;
+
+    beforeEach(() => {
+      wrapper = shallow(
+        React.createElement(
+          testCases.collapsible.component,
+          testCases.collapsible.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.collapsible.props.onButtonClick).toHaveBeenCalledTimes(
+        1
+      );
+    });
+  });
+
+  describe(testCases.sizedTitleclosable.description, () => {
+    let wrapper;
+
+    beforeEach(() => {
+      wrapper = shallow(
+        React.createElement(
+          testCases.sizedTitleclosable.component,
+          testCases.sizedTitleclosable.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.sizedTitleclosable.props.onButtonClick
+      ).toHaveBeenCalledTimes(1);
+    });
+  });
+});