Commit 9d40fff9 authored by Robert Knight's avatar Robert Knight

Introduce a pattern for fully typed store modules

Introduce a pattern for creating store modules which are fully typed,
using the "activity" module as a test case / example. This allows
TypeScript to check both external usage of the module, as well as
internal consistency between the different elements of it (initial
state, reducers, action creators, selectors).

The elements of this pattern are:

 - A `State` type is defined in each module, which is typically whatever
   shape the module's initial state has.

 - Each function in the `reducers` map specifies the type of its `state`
   parameter as `State` and defines the fields of the action.

 - Action creators use a new `makeAction` helper, which ensures that the
   type of dispatched actions matches what the reducer expects

 - The `createStoreModule` helper ties all the elements of the module
   (reducers, actions, selectors) together and makes sure they are
   consistent with one another.

The general structure of a typed store module, to which the various existing
modules will converge, is:

```js
import { createStoreModule, makeAction } from '../create-store';

const initialState = { ... }

/** @typedef {typeof initialState} State */

const reducers = {
  /**
   * @param {State} state
   * @param {{ id: string }} action
   */
  SOME_ACTION(state, action) {
    ...
  }
}

/**
 * @param {string} id
 */
function someAction(id) {
  return makeAction(reducers, 'SOME_ACTION', { id });
}

/**
 * @param {State}
 */
function someSelector(state) {
  ...
}

export someModule = createStoreModule(initialState, {
  namespace: 'someModule',
  reducers,
  actionCreators: { someAction },
  selectors: { someSelector },
});
```
parent 788697ec
......@@ -149,7 +149,7 @@ function NotebookView({ loadAnnotationsService, streamer }) {
forcedVisibleCount={forcedVisibleCount}
isFiltered={hasAppliedFilter}
isLoading={isLoading}
resultCount={resultCount}
resultCount={resultCount ?? 0}
/>
</div>
<div className="NotebookView__items">
......
......@@ -177,6 +177,22 @@ export function createStore(modules, initArgs = [], middleware = []) {
return store;
}
/**
* Helper for creating an action which checks that the type of the action's
* payload is compatible with what the reducer expects.
*
* @template {ReducerMap<any>} Reducers
* @template {keyof Reducers} Type
* @param {Reducers} reducers - The map of reducer functions from a store module
* @param {Type} type - The name of a specific reducer in `reducers`
* @param {Parameters<Reducers[Type]>[1]} payload - The fields of the action
* except for `type`. Pass `undefined` if the reducer doesn't need an action payload.
*/
export function makeAction(reducers, type, payload) {
// nb. `reducers` is not used here. It exists purely for type inference.
return { type, ...payload };
}
// The properties of the `config` argument to `createStoreModule` below are
// declared inline due to https://github.com/microsoft/TypeScript/issues/43403.
......
......@@ -3,8 +3,9 @@
* need to be reflected in the UI.
*/
import { actionTypes } from '../util';
import { createStoreModule } from '../create-store';
import { createStoreModule, makeAction } from '../create-store';
/** @typedef {import('../../../types/api').Annotation} Annotation */
const initialState = {
/**
......@@ -34,7 +35,10 @@ const initialState = {
annotationResultCount: null,
};
/** @typedef {typeof initialState} State */
const reducers = {
/** @param {State} state */
API_REQUEST_STARTED(state) {
return {
...state,
......@@ -42,6 +46,7 @@ const reducers = {
};
},
/** @param {State} state */
API_REQUEST_FINISHED(state) {
if (state.activeApiRequests === 0) {
throw new Error(
......@@ -55,6 +60,10 @@ const reducers = {
};
},
/**
* @param {State} state
* @param {{ annotation: Annotation }} action
*/
ANNOTATION_SAVE_STARTED(state, action) {
let addToStarted = [];
if (
......@@ -71,6 +80,10 @@ const reducers = {
};
},
/**
* @param {State} state
* @param {{ annotation: Annotation }} action
*/
ANNOTATION_SAVE_FINISHED(state, action) {
const updatedSaves = state.activeAnnotationSaveRequests.filter(
$tag => $tag !== action.annotation.$tag
......@@ -81,6 +94,7 @@ const reducers = {
};
},
/** @param {State} state */
ANNOTATION_FETCH_STARTED(state) {
return {
...state,
......@@ -88,6 +102,7 @@ const reducers = {
};
},
/** @param {State} state */
ANNOTATION_FETCH_FINISHED(state) {
if (state.activeAnnotationFetches === 0) {
throw new Error(
......@@ -102,6 +117,10 @@ const reducers = {
};
},
/**
* @param {State} state
* @param {{ resultCount: number }} action
*/
SET_ANNOTATION_RESULT_COUNT(state, action) {
return {
annotationResultCount: action.resultCount,
......@@ -109,56 +128,57 @@ const reducers = {
},
};
const actions = actionTypes(reducers);
/** Action Creators */
function annotationFetchStarted() {
return { type: actions.ANNOTATION_FETCH_STARTED };
return makeAction(reducers, 'ANNOTATION_FETCH_STARTED', undefined);
}
function annotationFetchFinished() {
return { type: actions.ANNOTATION_FETCH_FINISHED };
return makeAction(reducers, 'ANNOTATION_FETCH_FINISHED', undefined);
}
/**
* @param {object} annotation — annotation object with a `$tag` property
* @param {Annotation} annotation — annotation object with a `$tag` property
*/
function annotationSaveStarted(annotation) {
return { type: actions.ANNOTATION_SAVE_STARTED, annotation };
return makeAction(reducers, 'ANNOTATION_SAVE_STARTED', { annotation });
}
/**
* @param {object} annotation — annotation object with a `$tag` property
* @param {Annotation} annotation — annotation object with a `$tag` property
*/
function annotationSaveFinished(annotation) {
return { type: actions.ANNOTATION_SAVE_FINISHED, annotation };
return makeAction(reducers, 'ANNOTATION_SAVE_FINISHED', { annotation });
}
function apiRequestStarted() {
return { type: actions.API_REQUEST_STARTED };
return makeAction(reducers, 'API_REQUEST_STARTED', undefined);
}
function apiRequestFinished() {
return { type: actions.API_REQUEST_FINISHED };
return makeAction(reducers, 'API_REQUEST_FINISHED', undefined);
}
/** @param {number} resultCount */
function setAnnotationResultCount(resultCount) {
return { type: actions.SET_ANNOTATION_RESULT_COUNT, resultCount };
return makeAction(reducers, 'SET_ANNOTATION_RESULT_COUNT', { resultCount });
}
/** Selectors */
/** @param {State} state */
function annotationResultCount(state) {
return state.annotationResultCount;
}
/** @param {State} state */
function hasFetchedAnnotations(state) {
return state.hasFetchedAnnotations;
}
/**
* Return true when annotations are actively being fetched.
*
* @param {State} state
*/
function isFetchingAnnotations(state) {
return state.activeAnnotationFetches > 0;
......@@ -167,6 +187,8 @@ function isFetchingAnnotations(state) {
/**
* Return true when any activity is happening in the app that needs to complete
* before the UI is ready for interactivity with annotations.
*
* @param {State} state
*/
function isLoading(state) {
return state.activeApiRequests > 0 || !state.hasFetchedAnnotations;
......@@ -177,9 +199,8 @@ function isLoading(state) {
* have in-flight save requests, i.e. the annotation in question is actively
* being saved to a remote service.
*
* @param {object} state
* @param {object} annotation
* @return {boolean}
* @param {State} state
* @param {Annotation} annotation
*/
function isSavingAnnotation(state, annotation) {
if (!annotation.$tag) {
......@@ -188,8 +209,6 @@ function isSavingAnnotation(state, annotation) {
return state.activeAnnotationSaveRequests.includes(annotation.$tag);
}
/** @typedef {import('../../../types/api').Annotation} Annotation */
export const activityModule = createStoreModule(initialState, {
reducers,
namespace: 'activity',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment