Skip to content

Latest commit

 

History

History
 
 

www

NUSMods R

NUSMods R is built using React, Redux and Bootstrap, and is designed to be fast, modern and responsive.

To install NUSMods V2 (the previous version of NUSMods), refer here.

Browser support

Desktop browsers:

  • Last two versions of all evergreen desktop browsers (Chrome, Firefox, Edge, Safari)
  • IE is completely unsupported

Mobile browsers:

  • iOS 9 and above
  • Chrome Mobile last two versions

Contributing

Don't know where to start? First, read our repository contribution guide. Next, we highly recommend reading this fantastic beginner's guide written by one of our contributors. Alternatively, have a poke at our open issues.

Getting Started

Install Node 8+ and Yarn then run the following command:

$ yarn

This will install all of the dependencies you need.

Development

To run the development build, simply run:

$ yarn start

This will start webpack dev server, which will automatically rebuild and reload any code and components that you have changed. If your editor or IDE has built in support for Flow/ESLint/StyleLint, you can disable them to speed up the build process.

$ DISABLE_ESLINT=1 DISABLE_FLOW=1 DISABLE_STYLELINT=1 yarn start

We recommend the following development tools to help speed up your work

Writing styles

We uses CSS Modules to structure styles. This means that with the exception of a few global styles, styles for each component lives beside their source files (see colocation). This allows us to write short, semantic names for styles without worrying about collision.

// MyComponent.scss
import "~styles/utils/modules-entry"; // Import variables, mixins

.myComponent {
  // .col will be included in the class name whenever .myComponent is used
  composes: col from global;
  color: theme-color();

  :global(.btn) {
    // Selects all child .btn elements
  }

  :global {
    // :global is required for animation since animations are defined globally
    animation: fadeIn 0.3s;
  }
}
// MyComponent.jsx
import styles from './MyComponent.scss';

// To use styles from MyComponent.scss:
<div className={styles.myComponent}>

Note that specificity still matters. This is important if you are trying to override Bootstrap styles.

SCSS variables vs. CSS custom properties

Both SCSS and CSS variables (aka. custom properties) are used. In most cases, prefer SCSS variables as they can be used with SCSS mixins and functions, and integrate with Bootstrap. CSS variable generates more code (since we need to include a fallback for browsers that don't support it), and doesn't play well with SCSS.

Currently CSS variables are used only for colors that change under night mode.

Fetching data

We use Redux actions to make REST requests. This allows us to store request status in the Redux store, making it available to any component that needs it, and also allows the Redux store to cache the results from requests to make it offline if necessary. Broadly, our strategy corresponds to

Writing request actions

To write an action that makes a request, simple call and return the result from requestAction(key: string, type?: string, options: AxiosXHRConfig).

  • type should describe what the action is fetching, eg. FETCH_MODULE. By convention these actions should start with FETCH_.
  • key should be unique for each endpoint the action calls. If the action will only call one endpoint then key can be omitted, and type will be used automatically. For example, fetch module calls a different endpoint for each module, so the key used is FETCH_MODULE_[Module Code].
  • options is passed directly to axios(), so see its documentation for the full list of configs. Minimally url should be specified.

Example

import { requestAction } from 'actions/requests';

export const FETCH_DATA = 'FETCH_DATA';
export function fetchData() {
  return requestAction(FETCH_DATA, {
    url: 'http://example.com/api/my-data'
  });
}

Calling actions from components

Components should dispatch the action to fetch data. The dispatch function returns a Promise of the request response which the component can consume.

Example

import { fetchData } from 'actions/example';

type Props = {
  fetchData: () => Promise<MyData>,
}

type State = {
  data: ?MyData,
  error?: any,
}

class MyComponent extends Component<Props> {
  componentDidMount() {
    this.props.fetchData()
      .then(data => this.setState({ data }))
      .catch(error => this.setState({ error });
  }

  render() {
    const { data, error } = this.state;

    if (error) {
      return <ErrorPage />;
    }

    if (!data) {
      return <LoadingSpinner />;
    }

    // Render something with the data
  }
}

export default connect(null, { fetchData })(MyComponent);

Caching data

To make the data available offline, the data must be stored in the Redux store which is then persisted. To do this create a reducer which listens to [request type] + SUCCESS. The payload of the action is the result of the API call. Then in the component, instead of using the result from the Promise directly, we pull the data from the Redux store instead.

This is the cache-then-network strategy described in the Offline Cookbook and is similar to Workbox's revalidate-while-stale strategy.

Note: This assumes the result from the API will not be significantly different after it is loaded. If this is not the case, you might want to use another strategy, otherwise the user may be surprised by the content of the page changing while they're reading it.

Reducer example

import { SUCCESS } from 'types/reducers';
import { FETCH_DATA } from 'actions/example';

export function exampleBank(state: ExampleBank, action: FSA): ExampleBank {
  switch(action.type) {
    case FETCH_DATA + SUCCESS:
      return action.payload;

    // Other actions...
  }

  return state;
}

Component example

type Props = {
  myData: ?MyData,
  fetchData: () => Promise<MyData>,
}

type State = {
  error?: any,
}

class MyComponent extends Component<Props> {
  componentDidMount() {
    this.props.fetchData()
      .catch(error => this.setState({ error });
  }

  render() {
    const { data, error } = this.state;

    // ErrorPage is only show if there is no cached data available
    // and the request failed
    if (error && !data) {
      return <ErrorPage />;
    }

    if (!data) {
      return <LoadingSpinner />;
    }

    // Render something with the data
  }
}

export default connect(state => ({
  myData: state.exampleBank,
}), { fetchData })(MyComponent);

Getting request status

If you need to access the status of a request from outside the component which initiated the request, you can use the isSuccess and isFailure selectors to get the status of any request given its key.

Testing and Linting

We use Jest with Enzyme to test our code and React components, Flow for typechecking, Stylelint and ESLint using Airbnb config and Prettier for linting and formatting.

# Run all tests once with code coverage
$ yarn test

# Writing tests with watch
$ yarn test:watch

# Lint all JS and CSS
$ yarn lint

# Linting CSS, JS source, tests and scripts separately
$ yarn lint:styles
$ yarn lint:src
$ yarn lint:test
$ yarn lint:scripts
# Append `--fix` to fix lint errors automatically
# e.g. yarn lint:src --fix
# p.s. Use yarn lint:styles --fix with care (it's experimental),
#      remember to reset changes for themes.scss.

# Run Flow type checking
$ yarn flow

End to End testing

We currently have some simple E2E tests set up courtesy of Browserstack using Nightwatch. The purpose of this is mainly to catch major regression in browsers at the older end of our browser support matrix (Safari 9, Edge, Firefox ESR) which can be difficult to test manually.

By default the tests are ran against http://staging.nusmods.com, although they can be configured to run against any host, including localhost if you use Browserstack's local testing feature.

# All commands must include BROWSERSTACK_USER and BROWSERSTACK_ACCESS_KEY env variables
# these are omitted for brevity

# Run end to end test against staging
yarn e2e

# Run against deploy preview
LAUNCH_URL="https://deploy-preview-1024--nusmods.netlify.com" yarn e2e

# Enable local testing
./BrowserStackLocal --key $BROWSERSTACK_ACCESS_KEY
LAUNCH_URL="http://localhost:8080" LOCAL_TEST=1 yarn e2e

Deployment

Our staging is served from the ./dist directory, which is generated using yarn build. From there, it can be promoted to production using yarn promote-staging. This flow is summarized below:

$ yarn                  # Install dependencies
$ yarn test             # Ensure all unit tests pass
$ yarn build            # Build to staging ./dist directory
# Open http://staging.nusmods.com and manually test to ensure it works
$ yarn promote-staging  # Promote ./dist to production
  • yarn build packages and optimizes the app for deployment. The files will be placed in the ./dist directory.
  • yarn promote-staging deploys ./dist to the production folder, currently ../../beta.nusmods.com. It is designed to be safe, executing a dry run and asking for confirmation before deployment.
  • yarn rsync <dest-dir> syncs ./dist to the specified destination folder <dest-dir>. It is mainly used by yarn promote-staging but could be used to sync ./dist to any folder.

Project Structure

├── scripts                  - Command line scripts to help with development
├── src
│   ├── img
│   ├── js
│   │   ├── actions          - Redux actions
│   │   ├── apis             - Code to interface with external APIs
│   │   ├── bootstrapping    - Code that runs once only on app initialization
│   │   ├── config           - App configuration
│   │   ├── data             - Static data such as theme colors
│   │   ├── e2e              - End-to-end tests
│   │   ├── middlewares      - Redux middlewares
│   │   ├── reducers         - Redux reducers
│   │   ├── selectors        - Redux state selectors
│   │   ├── storage          - Persistance layer for Redux
│   │   ├── test-utils       - Utilities for testing - this directory is not counted
│   │   │                      for test coverage
│   │   ├── timetable-export - Entry point for timetable only build for exports
│   │   ├── types            - Flow type definitions
│   │   ├── utils            - Utility functions and classes
│   │   └── views
│   │       ├── browse       - Module info and module finder related components
│   │       ├── components   - Reusable components
│   │       ├── errors       - Error pages
│   │       ├── hocs         - Higher order components
│   │       ├── layout       - Global layout components
│   │       ├── modules      - Module finder and module info components
│   │       ├── routes       - Routing related components
│   │       ├── settings     - Settings page component
│   │       ├── static       - Static pages like /team and /developers
│   │       ├── timetable    - Timetable builder related components
│   │       └── venues       - Venues page related components
│   └── styles
│       ├── bootstrap        - Bootstrapping, uh, Bootstrap
│       ├── material         - Material components
│       ├── components       - Legacy component styles
│       │                      (new components should colocate their styles)
│       ├── layout           - Site-wide layout styles
│       ├── pages            - Page specific styles
│       └── utils            - Utility classes, mixins, functions
├── static                   - Static assets, eg. favicons
│                              These will be copied directly into /dist
└── webpack                  - webpack config

Colocation

Components should keep their styles and tests in the same directory with the same name. For instance, if you have a component called MyComponent, the files should look like

├── MyComponent.jsx         - Defines the component MyComponent
├── MyComponent.test.jsx    - Tests for MyComponent
└── MyComponent.scss        - Styles for MyComponent