Skip to content

Instantly share code, notes, and snippets.

@coogie
Last active November 23, 2018 10:51
Show Gist options
  • Save coogie/5469041fda3bf52e287b72aa178a1f81 to your computer and use it in GitHub Desktop.
Save coogie/5469041fda3bf52e287b72aa178a1f81 to your computer and use it in GitHub Desktop.

Dojo CountdownClock Component

Begin by scaffolding out the guts of our component

By default, Fractal gives us a lot of boilerplate to show how components are built out within the Monorepo. For the purpose of today's Dojo, we're gonna rewrite much of this component, so let's just get the skeleton.

import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';

export class CountdownClock extends PureComponent {
  static propTypes = {};
  static defaultProps = {};

  state = {};

  componentWillMount() {}

  render() {}
}

Fleshing out our render method

So, we know we're going to build a countdown clock, so let's flesh it out with what we know we're gonna put in it. Monorepo convention is to use classnames that match the component names, with BEM syntax to denote state/variants and any child elements within the component's context.

This creates predictable classnames that can be easily targeted and overwritten by downstream applications. We won't be applying any CSS to this particular component, but we will be good netizens and expose sensible classnames that will allow our component to be visually manipulated.

render() {
  return (
    <ul className="CountdownClock">
      <li className="CountdownClock__days">0 days</li>
      <li className="CountdownClock__hours">0 hours</li>
      <li className="CountdownClock__minutes">0 minutes</li>
      <li className="CountdownClock__seconds">0 seconds</li>
    </ul>
  );
}

Gettin' it in to a project

So now we have a generic component that can be used in any other project that we want. Let's go ahead and create a project-specific component in the Landing Pages project.

And now we'll just import our component into that and it should be visible on the page!

import { CountdownClock } from '@cartrawler/ui-components/CountdownClock';

Making it do the thing

So, we've got our component with a render method, now let's make it do the thing. We know we're going to take in a date as an argument, so let's make that our one and only prop.

We also know the key pieces of information that we're going to be showing in the template - days, hours, minutes, and seconds. For now, we can use component state to store these values.

static propTypes = {
  datetime: PropTypes.string.isRequired,
};

state = {
  days: 0,
  hours: 0,
  minutes: 0,
  seconds: 0,
};

So... We're going to take in a datetime, compare it to the device's current datetime, and then show the user how long is left in days, hours, minutes, and seconds until the passed-in datetime. So where's the best place to do that?

componentWillMount() {
  const { datetime } = this.props;
  const secondValue = 1000;
  const minuteValue = secondValue * 60;
  const hourValue = minuteValue * 60;
  const dayValue = hourValue * 24;

  const target = Date.parse(datetime);
  const now = Date.now();
  const difference = target - now;

  const totalDays = Math.floor(difference / dayValue);
  const totalHours = Math.floor(difference / hourValue);
  const totalMinutes = Math.floor(difference / minuteValue);
  const totalSeconds = Math.floor(difference / secondValue);

  const days = totalDays;
  const hours = totalHours - totalDays * 24;
  const minutes = totalMinutes - totalHours * 60;
  const seconds = totalSeconds - totalMinutes * 60;

  this.setState({
    days,
    hours,
    minutes,
    seconds,
  });
}

We also need to update our render method, now that we're actually doing something worthy of showing.

render() {
  const { days, hours, minutes, seconds } = this.state;
  return (
    <ul className="CountdownClock">
      <li className="CountdownClock__days">{days} days</li>
      <li className="CountdownClock__hours">{hours} hours</li>
      <li className="CountdownClock__minutes">{minutes} minutes</li>
      <li className="CountdownClock__seconds">{seconds} seconds</li>
    </ul>
  );
}

Sweet beans! We now know how long is left until Christmas, and I just want you all to know that whiskey is a perfectly acceptable Christmas gift *wink* *nudge*

Testing, attention please

So, remember how we gut the initial scaffolded component? Yeah, let's do that to the tests, too.

import { shallow } from 'enzyme';
import mockData from './mock-data.json';
import { CountdownClock } from '..';

describe('CountdownClock Component', () => {});

With that out of the way, let's get some tests in for our component. What do we need to test? Well, first, let's recap on what our component is actually doing:

  1. Grabbing the current system date/time to use as reference
  2. Taking in a datetime as a string prop, and parsing it to a Date
  3. Using the difference between those dates to store the days, hours, minutes, and seconds in state
  4. Using the data in state to render how long until whiskey Christmas.

And while we're here, let's update that incoming mockData JSON, too.

{
  "datetime": "December 25, 1955 00:00:00"
}

First up, let's make sure it's going to hit the necessary Date methods.

import { shallow } from 'enzyme';
import mockData from './mock-data.json';
import { CountdownClock } from '..';

const ORIGINAL_DATE = Date;

const expectedCountdownValues = { days: 49, hours: 17, minutes: 27, seconds: 45 };

global.Date = {
  now: jest.fn(() => new ORIGINAL_DATE('5 November 1955 06:32:15')),
  parse: jest.fn(passed => ORIGINAL_DATE.parse(passed)),
};

describe('CountdownClock Component', () => {
  beforeEach(() => {
    global.Date.now.mockClear();
    global.Date.parse.mockClear();
  });

  it('should parse the datetime prop as a Date', () => {
    shallow(<CountdownClock {...mockData} />);
    expect(global.Date.parse).toHaveBeenCalledTimes(1);
    expect(global.Date.parse).toHaveBeenCalledWith(mockData.datetime);
  });

  it("should grab the device's current time", () => {
    shallow(<CountdownClock {...mockData} />);
    expect(global.Date.now).toHaveBeenCalledTimes(1);
  });

  it('should place the calculated values into state', () => {
    const wrapper = shallow(<CountdownClock {...mockData} />);
    expect(wrapper.state()).toEqual(expect.objectContaining(expectedCountdownValues));
  });

  it('should render the expected markup', () => {
    const wrapper = shallow(<CountdownClock {...mockData} />);
    expect(wrapper).toMatchSnapshot();
  });
});

Dude, your countdown clock sucks

What we got now kinda works, but it's too static. We can do much better than this. Let's get dynamic, and use React the way it's supposed to be used.

Our componentWillMount method is a bit bloated at the moment, so let's abstract that out into another method that we'll instead call in CWM - cleaning it up.

componentWillMount() {
  this.doCount();
}

doCount = () => {
  const { datetime } = this.props;
  const secondValue = 1000;
  const minuteValue = secondValue * 60;
  const hourValue = minuteValue * 60;
  const dayValue = hourValue * 24;
  const now = Date.now();
  const target = Date.parse(datetime);
  const difference = target - now;
  const days = Math.floor(difference / dayValue);
  const totalHours = Math.floor(difference / hourValue);
  const totalMinutes = Math.floor(difference / minuteValue);
  const totalSeconds = Math.floor(difference / secondValue);
  const totalDaysInHours = days * 24;
  const totalMinutesInHours = totalHours * 60;
  const totalSecondsInMinutes = totalMinutes * 60;
  const hours = totalHours - totalDaysInHours;
  const minutes = totalMinutes - totalMinutesInHours;
  const seconds = totalSeconds - totalSecondsInMinutes;

  this.setState({
    days,
    hours,
    minutes,
    seconds,
  });
}

Great! Now our component works exactly like it always did, but with extra steps. However, this allows us to get a bit more complex and start providing a realtime countdown, so let's do that.

Our doCount function does everything we already need it to do, so realistically we can just call it over and over again - the best place for us to do that is still our componentWillMount method.

state = {
  interval: null,
};

componentWillMount() {
  this.doCount();
  const interval = setInterval(this.doCount, 1000);
  this.setState({ interval });
}

componentWillUnmount() {
  const { interval } = this.state;
  clearInterval(interval);
}

The test of time

Some of our existing tests still pass because we're deadly. However, we did add in some new functionality to the component which has broken one of them so we need to fix that. The functionality's pretty important to how the component acts, so we should definitely add tests for that too.

const expected = {
  days: 49,
  hours: 11,
  minutes: 25,
  seconds: 4,
};

it('should register an interval when it mounts', () => {
  jest.useFakeTimers();
  const wrapper = shallow(<CountdownClock {...mockData} />);
  expect(wrapper.state('interval')).toEqual(expect.any(Number));
  expect(setInterval).toHaveBeenCalledTimes(1);
  expect(setInterval).toHaveBeenLastCalledWith(wrapper.instance().doCount, 1000);
});

it('should clear the interval when it unmounts', () => {
  jest.useFakeTimers();
  const wrapper = shallow(<CountdownClock {...mockData} />);
  const intervalID = wrapper.state('interval');
  wrapper.unmount();
  expect(clearInterval).toHaveBeenCalledTimes(1);
  expect(clearInterval).toHaveBeenLastCalledWith(intervalID);
});

Clearing things up

There's just one thing left to do before we wrap things up. Currently, we only clear the interval when the component unmounts, but that's not the only occasion where we'll need to clear it.

componentWillUnmount() {
  this.clearTimer();
}

clearTimer = () => {
  const { interval } = this.state;
  clearInterval(interval);
  this.setState({
    days: 0,
    hours: 0,
    minutes: 0,
    seconds: 0,
  });
};

doCount = () => {
  // ...
  
  if (difference <= 0) {
    this.clearTimer();
    return;
  }
  
  // ...
}

And finally, let's add a test to cover that scenario, too!

it('should clear the interval when the date has been passed', () => {
  jest.useFakeTimers();
  shallow(<CountdownClock {...mockData} />);
  expect(clearInterval).toHaveBeenCalledTimes(0);
  global.Date.now.mockImplementationOnce(() => new ORIGINAL_DATE('26 December 1955'));
  jest.runOnlyPendingTimers();
  expect(clearInterval).toHaveBeenCalledTimes(1);
});

Closing

So now we know, with absolute certainty, that our component is bulletproof and can be dropped into any project without us having to worry that we're going to break something in the future should we refactor it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment