-
Notifications
You must be signed in to change notification settings - Fork 3
Home
This wiki will dive into how the application is built and how you should incorporate your changes, bearing in mind our way of doing things to ensure a decent level of consistency.
An important aspect of keeping a project clean and newcomers friendly is to have a standard way of making changes and contribute. Thus, like other projects at NIAEFEUP, we like to follow the Feature Branch Workflow. For all you know, there's only the develop
branch, and whichever branch you create for what you are working on. To start developing, you should create a branch from the tip of develop
. Normally, we name them according to the following rule:
<feature|fix>/<small-title>-<related-issue-number>
An example is feature/homepage-10
that was created to solve issue #10
Once you are done, you should create a Pull Request from your branch to develop
and explain what problems you solved and the overall ideas of how you did it in the description. Ask for a reviewer (or more, if possible), normally some with more experience are recommended so that a better and more in-depth review is provided.
Tip: We like to keep our git history as clean as possible. To do that, you should squash your commits in a way that only the most relevant are there
- don't:
Created enum field
Added switch statement
Updated errors thrown to use new enum
- do:
Implemented Error Handler
A very useful tool to help you do this after you committed more than you should isgit rebase -i
See here
Your PR is only ready when it has the needed code AND the related test code, as well as some metrics logging, if applicable. More information about those specific tasks is available further. If you are not done (i.e. you developed the code, but not the tests), but want to show someone your progress, you can open a PR in Draft Mode to have access to Netlify Previews in the checks area that can be viewed by other devs or UI/UX people.
The project is built in React.js, so you can expect a lot of Components. However, there are other parts needed and they need to be structured in a way that makes sense so that we can easily find what we are looking for at any time.
The main structure looks like this:
.
├── ...
└── src
├── actions
├── components
│ └── ...
├── hooks
├── pages
├── reducers
└── utils
As you can see, there are 6 main directories:
-
actions
- Contains the functions that generate Redux actions, as well as some service call helpers (that may dispatch Redux actions) -
components
- Contains the React components. Here you should create as many sub-directories as you need, in order to clearly separate the components. Inside we have, for example, theHomePage
directory which has further sub-directories regarding the Home Page's components -
hooks
- Here we define our own custom React Hooks to be reused across the application -
pages
- These are also React Components, but only the entrypoints for the application's pages -
reducers
- Contains the Redux reducers -
utils
- Generic utils that may be used across the application
React.js is the base of this project and, as such, we need to have good and well performant React code.
We use React Functional Components, as they are smaller, and we can now manage the state and interactions with React Hooks. Remember to create small components, to apply the Separation of Concerns design pattern. There is a common React Design Pattern which is to have a Controller Component and multiple Rendering Components - React Patterns.
For example, when developing a form, it makes sense to have a Form Controller that stores the state for all the variables in the form, and contains a set of children components that only render their value depending on the controller's state and, when changing state, they notify the controller in order to update their value. This way, when submitting the form, the controller component has all the information it needs to do it.
In order to have a maintainable application, having a reliable test suite is fundamental. In our case, each component is tested in a sibling file which has the following name structure: <ComponentName>.spec.js
which is located at the same level as the Component it is testing. Apart from these, we also test other functions such as Redux actions or reducers and utility functions following the same pattern. The main test framework we use is Jest, but we also use Enzyme on top of it to test React specific code.
Regarding Components testing, we normally try to test each component individually (e.g. test how the component behaves for a given set of props), not caring for the behavior of its children. Some example tests are:
- Is this child component present (we don't care if it is showing properly, we just test it is there)
- When I pass an empty array as prop, does it not render the list?
- When I pass a filled array as prop, does it render
n
list items?
For Redux connected components, you should test the mapDispatchToProps
and mapStateToProps
functions. Again, do not test redux internal behavior, just test that the functions are doing what you expect.
Sometimes you might need to mock the Redux Store to test some specific scenarios. We tend to avoid this, as it brings a lot of complexity, but if you have to, use the included redux-mock-store
package to create a store mock with the state you need.
Even though the page won't reload, we can emulate a multi-page behavior using React Router. This way, in the application entrypoint App.js
, we inserted the <AppRouter>
. This component is responsible for defining the routes (e.g. When the user goes to /user/{id}
, it should render the User page component with the id as prop). As specified above, we make a distinction between page components and the rest, to make it clear that they are the entrypoint for each page.
Homepage route example:
<Route
exact
path="/"
component={HomePage}
/>
Redux is the usual solution when it comes to having a global state in React applications. In order to use it, it normally requires 3 steps: Creating the object you want to have in the Redux Store and defining how to interact with it (function to add item to list, filter values, toogle boolean, etc...) - done via the Reducer
, Creating the functions that trigger those interaction points - done via Actions
and connecting the component to the redux store, in order to have access to the state and/or the actions.
There are some rules when using this:
- The reducers never mutate the state directly, they should always return a mutated copy. In order words, the reducers should be pure.
- In order to access the state in your component, you should not export the object globally, instead, you map the state variables you want into component props when connecting it:
const mapStateToProps = state => ({
propObj: state.objIWant,
propObj2: state.otherObjIWant,
})
export default connect(mapStateToProps, mapDispatchToProps)(MyComponent)
By doing this, MyComponent
will have access to propObj
and propObj2
in their props. This works because Redux uses the Higher-Order-Component (HOC) pattern - React Patterns.
As you might have noticed, there is a second parameter when connecting the component: mapDispatchToProps
. First of all, you should know that dispatch
is a special redux function that is used when you want to call your actions.
An action is simply an object with at least a type
field, the other fields depends on what you need. For example, in our notification system, there is a function that generates the addSnackbar
action, which is something like this:
{
type: ADD_SNACKBAR
notification: {...}
}
When a component wants to render a notification(aka Snackbar), it will have to dispatch an addSnackbar
action, so it will have to call dispatch
with the above object as argument. Normally what is done is having the mapDispatchToProps
hide some of this and adding to our objects props a function that will dispatch the action. It would look something like this:
import {addSnackbar} from "../actions/notificationActions";
const mapDispatchToProps = dispatch => ({
addSnackBar: (notification) => dispatch(addSnackbar(notification)) //the addSnackbar in the dispatch argument is the imported function that returns the action, based on the parameters
})
Like mapStateToProps
, having a mapDispatchToProps
like that will provide the connected component with a addSnackbar
function that automatically dispatches when called.
Once an action is dispatched, the reducer for the action type is called. Each reducer is normally a switch statement or a set of if statements, based on the action.type
, and then it defines the behavior towards the state based on it.
Here's the definition of the notifications reducer:
const initialState = {
notifications: [],
};
export default typeToReducer({
[notificationTypes.ADD_SNACKBAR]: (state, action) => ({
...state,
notifications: [
...state.notifications,
{
...action.notification,
},
],
}),
[notificationTypes.REMOVE_SNACKBAR]: (state, action) => ({
...state,
notifications: state.notifications.filter(
(notification) => notification.key !== action.key,
),
}),
}, initialState);
This is not vanilla redux, as you can see, there is no switch or if there. What we use is a package called type-to-reducer
that allows us to have the code a little bit cleaner by hiding the infinite switch cases. What the code above is doing is: if the action type is notificationTypes.ADD_SNACKBAR
(notificationTypes
is an enum that stores the action types for the notification actions), then return a new state where the notifications array is the previous plus the new notification. The inverse is done for the REMOVE_SNACKBAR
action.
Sometimes there are actions that are not instantaneous (e.g. fetching some extra information for a specific object), and so, you need to have the knowledge that the information is loading, done, failed, etc. So, you would need normally 3 action types: <NON_INSTANTANEOUS_ACTION>_PENDING
, <NON_INSTANTANEOUS_ACTION>_FULFILLED
, <NON_INSTANTANEOUS_ACTION>_ERROR
. When receiving the pending action, you would set the loading
boolean, when receiving the fulfilled, you would populate the state data, and in the error case, probably you would have an error object with more information to handle it in the required components.
This extension allows you to have nested action reducers like it's shown in their docs:
[ API_FETCH ]: {
PENDING: () => ({
...initialState,
isPending: true
}),
REJECTED: (state, action) => ({
...initialState,
error: action.payload,
}),
FULFILLED: (state, action) => ({
...initialState,
data: action.payload
})
}
asd
asd
asd