NUSMods R is built using React, Redux and Bootstrap, and is designed to be fast, modern and responsive.
- Production deployment: https://nusmods.com/
- Latest build: https://latest.nusmods.com/
- Issues: https://github.com/nusmodifications/nusmods/issues?q=is%3Aissue+is%3Aopen
To install NUSMods V2 (the previous version of NUSMods), refer here.
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
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.
Install Node 8+ and Yarn then run the following command:
$ yarn
This will install all of the dependencies you need.
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
- React Developer Tools (Chrome, Firefox)
- Redux DevTools
- Firefox Developer Edition
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.
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.
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
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 withFETCH_
.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 isFETCH_MODULE_[Module Code]
.options
is passed directly toaxios()
, so see its documentation for the full list of configs. Minimallyurl
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'
});
}
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);
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);
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.
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
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
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 byyarn promote-staging
but could be used to sync./dist
to any folder.
├── 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
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