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() {}
}
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>
);
}
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';
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*
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:
- Grabbing the current system date/time to use as reference
- Taking in a datetime as a string prop, and parsing it to a Date
- Using the difference between those dates to store the days, hours, minutes, and seconds in state
- Using the data in state to render how long until
whiskeyChristmas.
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();
});
});
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);
}
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);
});
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);
});
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.