Apptension Boilerplate

Initialise project

npx create-react-app [app-name] --scripts-version @apptension/react-scripts

npx create-react-app suchar-workshop --scripts-version @apptension/react-scripts

Project structure

Component / module structure

Suchar generator

 

//.env

REACT_APP_BASE_API_URL = 'https://official-joke-api.appspot.com/'

Adding new component

plop

yarn plop component button
yarn plop
yarn plop container detailsPage
yarn plop module contentRegistry

Jokes module

  • reducers.js
  • sagas.js
export const INITIAL_STATE = new Immutable({
  jokes: Immutable([]),
});
export const { Types: JokesTypes, Creators: JokesActions } = createActions(
  {
    fetch: [''],
    fetchSuccess: ['newJoke'],
  },
  { prefix: 'JOKES/' }
);

Sagas

import { put, takeLatest } from 'redux-saga/effects';

import api from '../../shared/services/api';
import { JokesTypes, JokesActions } from './jokes.redux';

export function* fetch() {
  const { data } = yield api.get('jokes/programming/random');
  yield put(JokesActions.fetchSuccess(data));
}

export function* watchJokes() {
  yield takeLatest(JokesTypes.FETCH, fetch);
}

testing Sagas

import mockApi from '../../../shared/utils/mockApi';
import { expectSaga } from 'redux-saga-test-plan';

it('should dispatch fetchSuccess action with response from api', async () => {
    //will it work?
    const data = ["some data from API"];

    await expectSaga(watchJokes)
      .withState(defaultState)
      .put(JokesActions.fetchSuccess(data))
      .dispatch(JokesActions.fetch())
      .run();
  });

Nock.js

export default nock('https://official-joke-api.appspot.com/')
    .defaultReplyHeaders({
      'access-control-allow-origin': '*',
    });
//jokes.sagas.spec.js

mockApi.get('/jokes/programming/random').reply(200, data);

Reducers

import { createActions, createReducer } from 'reduxsauce';
import Immutable from 'seamless-immutable';

export const { Types: JokesTypes, Creators: JokesActions } = createActions(
  {
    fetch: [''],
    fetchSuccess: ['newJoke'],
  },
  { prefix: 'JOKES/' }
);

export const INITIAL_STATE = new Immutable({
  jokes: Immutable([]),
});

const fetchSuccess = (state, { newJoke }) => 
    state.update('jokes', jokes => jokes.concat(newJoke));

export const reducer = createReducer(INITIAL_STATE, {
  [JokesTypes.FETCH_SUCCESS]: fetchSuccess,
});

testing Reducers

import { jokes } from '../../../shared/utils/fixtures';

const state = Immutable({
    jokes: [],
});

describe('when fetchSuccess action is received', () => {
  it('should save new jokes to store', () => {
    const action = JokesActions.fetchSuccess([jokes[0], jokes[1]]);
    const expectedState = state.set('jokes', [jokes[0], jokes[1]]);
    expect(jokesReducer(state, action)).toEqual(expectedState);
  });
});

fixtures.js

export const apiResponse = {
  id: 28,
  type: 'programming',
  setup: 'To understand what recursion is...',
  punchline: 'You must first understand what recursion is',
};

export const jokes = [
  {
    id: 28,
    type: 'programming',
    setup: 'To understand what recursion is...',
    punchline: 'You must first understand what recursion is',
  },
  {
    id: 1333,
    type: 'programming',
    setup: 'Why do C# and Java developers keep breaking their keyboards?',
    punchline: 'Because they use a strongly typed language.',
  },
  {
    id: 123,
    type: 'something else',
    setup: 'How do you check if a webpage is HTML5?',
    punchline: 'Try it out on Internet Explorer',
  },
];

Selectors

import { createSelector } from 'reselect';
import { prop } from 'ramda';

export const selectJokesDomain = prop('jokes');

export const selectRecipes = createSelector(
  selectJokesDomain,
  prop('jokes')
);

testing Selectors

describe('Jokes: selectors', () => {
  const defaultState = Immutable({
    jokes: [],
  });

  describe('selectJokes', () => {
    it('should select all jokes', () => {
      const state = defaultState.set('jokes', [{ id: 1 }, { id: 2 }]);
      const expectedSelection = [{ id: 1 }, { id: 2 }];

      expect(selectJokes(state)).toEqual(expectedSelection);
    });
  });
});

Show me the jokes...

const mapStateToProps = createStructuredSelector({
  jokes: selectJokes,
});
yarn plop container jokes
export const mapDispatchToProps = dispatch =>
  bindActionCreators(
    {
      fetchJoke: JokesActions.fetch,
    },
    dispatch
  );

jokes.component.js

export class Jokes extends PureComponent {
  static propTypes = {
    fetchJoke: PropTypes.func.isRequired,
    jokes: PropTypes.array.isRequired,
  };

  render() {
    return (
      <Container>
        <h1>
          Jokes generator
        </h1>
        <button onClick={() => this.props.fetchJoke()}>
          fetch a joke
        </button>
        <div>
          {this.props.jokes.map(joke => (
            <div key={joke.id}>
              <p>{joke.setup}</p>
              <p>{joke.punchline}</p>
            </div>
          ))}
        </div>
      </Container>
    );
  }
}

react-intl

import { defineMessages } from 'react-intl';

export default defineMessages({
  pageTitle: {
    id: 'home.pageTitle',
    defaultMessage: 'Homepage',
  },
  welcome: {
    id: 'home.welcome',
    defaultMessage: 'Hello World!',
  },
});
<FormattedMessage {...messages.title} />
this.props.intl.formatMessage(messages.title);

react-intl

yarn extract-intl
// src/translations/en.json

{
  "app.pageTitle": "Apptension Boilerplate",
  "home.pageTitle": "Homepage",
  "home.welcome": "Hello World!",
  "notFound.pageTitle": "Not found",
  "notFound.title": "Error: 404",
  "recipes.fetchButton": "Get recipes",
  "recipes.pageTitle": "Recipes",
  "unsupported.pageTitle": "Unsupported Browser",
  "unsupported.title": "Unsupported Browser"
}

Lets complicate things a little bit...

Testing

yarn test
expect().toBe()
expect().toEqual()

Using jest assertions / spies

No chai / sinon

Testing

describe('Button: Component', () => {
    describe('when its clicked', () => {
        describe('in active mode', () => {
            it('should do something', () => {});
        });

        describe('in inactive mode', () => {
            it('should do something else', () => {});
        });
    });
});

src/setupTests.js

global.$ = jest.fn();

jest.mock('utils/services/firebase', () => ({ ... }));

fixtures

export const jokes = [
  {
    id: 28,
    type: 'programming',
    setup: 'To understand what recursion is...',
    punchline: 'You must first understand what recursion is',
  },
  {
    id: 1333,
    type: 'programming',
    setup: 'Why do C# and Java developers keep breaking their keyboards?',
    punchline: 'Because they use a strongly typed language.',
  },
  {
    id: 123,
    type: 'something else',
    setup: 'How do you check if a webpage is HTML5?',
    punchline: 'Try it out on Internet Explorer',
  },
];

Jest mocks

const fnMock = jest.fn();
const fnSpy = jest.spyOn(window, 'addEventListener');

jestFn
    .mockClear()
    .mockReset()
    .mockRestore()


    .mockImplementation(fn)
    .mockReturnValue(value)

Jest mocks

expect(jestFn)
    .toHaveBeenCalled()
    .toHaveBeenCalledWith()

Jest mocks

// all exported functions will be replaced with jest.fn()
jest.mock('../path/to/module');

jest.mock('../path/to/module', 
    () => 'this function will override default export'
);

jest.mock('../path/to/module', () => ({
    fn1: () => 'function 1 mock',
    fn2: () => 'function 2 mock',
}));

styled-components

+ ThemeProvider

<ThemeProvider theme={{ colors: {primary: red, secondary: blue} }}>
    ...wholeApp
</ThemeProvider>
export const Container = styled.button`
  background-color: ${props => props.theme.colors.primary};
`;

styled-components

+ ThemeProvider

export const Container = styled.button`
  background-color: ${props => props.theme.colors.primary};
`;
export const Container = styled.button`
  background-color: ${themeColor('primary')};
`;
const themeColor = colorName => path(['theme', 'colors', colorName]);

styled-components

+ ThemeProvider

//header.component.js
<ThemeProvider theme={{ isShrinked: true }}>
    <Logo />
    <Menu />
</ThemeProvider>


//header.styles.js
export const Logo = styled.img`
    height: ${props => props.theme.mode.isShrinked ? 10 : 20}
`;

export const Menu = styled.img`
    color: ${themeColor('primary')};
    height: ${props => props.theme.mode.isShrinked ? 10 : 20}
`;

Storybooks

Storybooks

//button.stories.js

const themeDecorator = withTheme(theme);

storiesOf('Button', module)
  .addDecorator(themeDecorator)
  .add('Primary', () => <Button {...defaultProps} />);

storiesOf('Button', module)
  .addDecorator(themeDecorator)
  .add('Primary wide', () => <Button {...defaultProps} wide />);

Storyshots

Less need for jest snapshots

it('should render correctly', () => {
    const wrapper = shallow(component());
    expect(wrapper).toMatchSnapshot();
});

Enzyme

const wrapper = shallow(<Button/>);

wrapper.debug(); / wrapper.html();

wrapper.simulate('click'); == wrapper.prop('onClick')();

wrapper.find('.selector'); / wrapper.find(StyledComponent);

wrapper.find('multiple elements selector').prop('propName'); 
==
wrapper.find('multiple elements selector').first().prop('propName');

// find returns all matching elements, 
// but if method invoked on the result expects only one node, 
// it will use the first matching element

wrapper.dive(); // return shallow wrapper of the first child of current wrapper

wrapper.update(); // force update

wrapper.prop('name', 'value');

wrapper.unmount();

yarn eject

yarn eject
Made with Slides.com