The webapp package

This is the React-based "frontend" application. It implements the user interface.

Conceptually, we divide the frontend into three categories: pure components, container components, and stories.

Pure components

“Pure” components are found under packages/webapp/src/components. These simple React components hold no logic and simply control the layout and rendering of their props.

For example, in the "list view" of notifications, the user sees a "card" for each notification.

This PureNotificationCard component is defined in packages/webapp/src/components/Card/NotificationCard/NotificationCard.jsx. Excerpts are shown below.

/** * Renders a card containing notification data. * @param {NotificationCardConfig} param0 * @returns {ReactComponent} */ export function PureNotificationCard({ alert, status, translation_key, variables, context, created_at, onClick }) { /* ... */ return ( <Card className={clsx(status === 'Read' ? styles.notificationRead : styles.notificationUnread)} style={{ /* ... */ }} classes={{ /* ... */ }} onClick={onClick} > <div> <div style={{ /* ... */ }} > {created_at} </div> <Icon style={{ /* ... */ }} /> </div> <div> <Semibold style={{ color: colors.teal700, marginBottom: '12px', lineHeight: '20px' }}> {t(`NOTIFICATION.${translation_key}.TITLE`)} {alert && <AlertIcon style={{ marginLeft: '8px', marginBottom: '2px' }} />} </Semibold> <Text style={{ margin: 0, lineHeight: '18px' }}> {t(`NOTIFICATION.${translation_key}.BODY`, tOptions)} </Text> </div> </Card> ); }

Styling and many other details have been removed to focus on the main point: the limited nature of pure components that simply render data.

Notice the use of the t function calls, which support internationalization by translating strings into the user's selected language. We will explore the translation approach later on.

Containers

“Container” components, sometimes called “smart” components, are found under packages/webapp/src/containers. These complex React components implement logic, connect to the Redux store, dispatch actions, and render the pure components with the required props. For example, packages/webapp/src/containers/Notification/NotificationCard/index.jsx defines the NotificationCard component, which imports the pure component discussed above.

Redux, Slices, and Sagas

LiteFarm containers rely on Redux to manage a single authoritative "store" for application state. State is mutated by "dispatching actions" to the store, triggering the pure "reducer" function to create a new state. The frontend uses the "slice" concept from Redux Toolkit (RTK) to streamline this process. For example, here is an excerpt from packages/webapp/src/containers/notificationSlice.js.

/** * Adds a set of notifications to the Redux store. */ const addManyNotifications = (state, { payload: notifications }) => { notificationAdapter.upsertMany(state, notifications); }; /** * Generate prebuilt reducers and selectors for CRUD operations on a normalized state structure containing notifications. * @see {@link <https://redux-toolkit.js.org/api/createEntityAdapter/}> */ const notificationAdapter = createEntityAdapter({ selectId: (notification) => notification.notification_id, }); /** * Generate action creators and action types that correspond to a specified initial state and a specified set of reducers. * @see {@link <https://redux-toolkit.js.org/api/createSlice/}> */ const notificationSlice = createSlice({ name: 'notificationReducer', initialState: notificationAdapter.getInitialState({ loading: false, loaded: false, error: undefined, }), reducers: { onLoadingNotificationStart: (state) => { state.loading = true; }, onLoadingNotificationFail: (state, { payload: error }) => { state.loading = false; state.error = error; state.loaded = true; }, getNotificationSuccess: (state, { payload: notifications }) => { addManyNotifications(state, { payload: notifications }); state.loading = false; state.loaded = true; state.error = null; }, }, });

The code first defines a function that adds a given set of notifications to the frontend's Redux store. To do this, the function uses an adapter returned by calling RTK's createEntityAdapter function. This call automatically generates code to perform Redux store operations that Create, Read, Update, and Delete (CRUD) objects of a particular type (notifications, in this case).

The code also calls RTK's createSlice function, passing a slice name, an inital state, and a set of reducer functions. (Notice that the initial state is provided by the adapter already discussed, and the last reducer calls the function defined at the top of this code.) Creating a slice generates additional useful code, eliminating the need to write "boilerplate" functions specifically for our notifications object type.

Within the container folders we also define "sagas". From a user interface perspective, a saga is, roughly, a set of one or more related screens that work together in sequence. From a code perspective, sagas are generator functions that yield declarative side effects (i.e., objects) to the redux-saga middleware.

When the yielded effect represents an asynchronous operation, the middleware suspends the saga until resolution. Other yielded effects represent an instruction to the middleware, e.g., dispatch action X or call function Y. Separating the creation of these instructions from their execution is a strategy to simplify UI testing. For example, here is an excerpt from packages/webapp/src/containers/Notification/saga.js.

export const getNotification = createAction('getNotificationSaga'); export function* getNotificationSaga() { const { user_id, farm_id } = yield select(userFarmSelector); const header = getHeader(user_id, farm_id); try { yield put(onLoadingNotificationStart(user_id, farm_id)); const result = yield call(axios.get, notificationsUrl, header); yield put(getNotificationSuccess(result.data)); } catch (e) { console.error(e); } } export default function* notificationSaga() { yield takeEvery(getNotification.type, getNotificationSaga); yield takeEvery(readNotification.type, readNotificationSaga); yield takeEvery(clearAlerts.type, clearAlertsSaga); }

The saga is defined by the generator function at the bottom of this code. Each of its three statements are, in effect, listening for a particular .type of event to occur. (These event types were generated with RTK's help.) When a getNotification.type event occurs, we call the function getNotificationSaga. (Since that's a generator, we could also say that we are invoking another saga.)

getNotificationSaga begins by using some utilities that we won't explore here: it selects the current user and farm from the Redux store, and calls a helper function to get the headers for a request to the API. Then, in the try block, three things happen. First it uses put to schedule the dispatch of an event; this tells the store that app state is changing-- starting the loading of notifications via a call to the LiteFarm API. Second, it uses call and the axios library to perform an HTTP GET request to the API URL for notifications, passing necessary headers. Finally, it uses put to dispatch an event telling the store that we have successfully loaded notifications, and here they are (result.data). This final event will lead to execution of the addManyNotifications function we saw previously, storing the notifications in the Redux store.

Important point: the three steps described above are non-blocking. The combination of yield with asynchronous operations such as calling axios.get mean that the React framework will be free to perform other actions while these steps are running on background threads. In addition to making efficient use of processing resources, this keeps the UI responsive to user actions.

Stories

In this context, "stories" are tests of our containers that are rendered through Storybook. A story can import both pure and container components. In the case of testing isolated views, the most likely scenario is importing pure components. When rendering complete flows, however, you might use container components to test user interactions with the store or to test changes on events.

To do that, create a new folder and corresponding file that ends in .stories.js in the stories folder, and import the pure component in that file.