Commit 987bcfa5 authored by Robert Knight's avatar Robert Knight

Infer store module types automatically

Remove the need to define the type of store created by each module
manually by adding a `StoreFromModule` helper in `create-store.js` which
can infer the store type from a store module configuration. Using this
the type of a store composed from several modules can then be created
with:

```
import fooModule from './modules/foo';
import barModule from './modules/bar';

// Define type of store returned by `createStore([fooModule, barModule])`
/** @typedef {StoreType<fooModule> & StoreType<barModule>} AppStore */
```

Ideally `createStore` would just infer the type based upon its
arguments. I haven't worked out how to do that yet. Nevertheless, this
still removes the need for a lot of manually defined types.

To ensure more useful error messages from TS if a store module's configuration
has the wrong shape a `storeModule` helper has been added. This wraps
the configuration for each module to check its shape before the
individual modules are combined into one type for the store. This helper could
also perform runtime validation in future.

 - Add `StoreFromModule` type in `create-store.js` and several helpers
   to support it
 - Modify each store module to wrap the export in `storeModule` and
   remove any manually defined store types
parent 43d1d1d1
......@@ -7,6 +7,85 @@ import immutable from '../util/immutable';
import { createReducer, bindSelectors } from './util';
/**
* Helper that strips the first argument from a function type.
*
* @template F
* @typedef {F extends (x: any, ...args: infer P) => infer R ? (...args: P) => R : never} OmitFirstArg
*/
/**
* Helper that converts an object of selector functions, which take a `state`
* parameter plus zero or more arguments, into selector methods, with no `state` parameter.
*
* @template T
* @typedef {{ [K in keyof T]: OmitFirstArg<T[K]> }} SelectorMethods
*/
/**
* Map of action name to reducer function.
*
* @template State
* @typedef {{ [action: string]: (s: State, action: any) => Partial<State> }} Reducers
*/
/**
* Configuration for a store module.
*
* @template State
* @template {object} Actions
* @template {object} Selectors
* @template {object} RootSelectors
* @typedef Module
* @prop {(...args: any[]) => State} init -
* Function that returns the initial state for the module
* @prop {string} namespace -
* The key under which this module's state will live in the store's root state
* @prop {Reducers<State>} update -
* Map of action types to "reducer" functions that process an action and return
* the changes to the state
* @prop {Actions} actions
* Object containing action creator functions
* @prop {Selectors} selectors
* Object containing selector functions
* @prop {RootSelectors} [rootSelectors]
*/
/**
* Replace a type `T` with `Fallback` if `T` is `any`.
*
* Based on https://stackoverflow.com/a/61626123/434243.
*
* @template T
* @template Fallback
* @typedef {0 extends (1 & T) ? Fallback : T} DefaultIfAny
*/
/**
* Helper for getting the type of store produced by `createStore` when
* passed a given module.
*
* To get the type for a store created from several modules, use `&`:
*
* `StoreFromModule<firstModule> & StoreFromModule<secondModule>`
*
* @template T
* @typedef {T extends Module<any, infer Actions, infer Selectors, infer RootSelectors> ?
* Store<Actions,Selectors,DefaultIfAny<RootSelectors,{}>> : never} StoreFromModule
*/
/**
* Redux store augmented with methods to dispatch actions and select state.
*
* @template {object} Actions
* @template {object} Selectors
* @template {object} RootSelectors
* @typedef {redux.Store &
* Actions &
* SelectorMethods<Selectors> &
* SelectorMethods<RootSelectors>} Store
*/
/**
* Create a Redux store from a set of _modules_.
*
......@@ -22,9 +101,10 @@ import { createReducer, bindSelectors } from './util';
* each action and selector from the input modules as a method which operates on
* the store.
*
* @param {Object[]} modules
* @param {Module<any,any,any,any>[]} modules
* @param {any[]} [initArgs] - Arguments to pass to each state module's `init` function
* @param {any[]} [middleware] - List of additional Redux middlewares to use
* @return Store<any,any,any>
*/
export default function createStore(modules, initArgs = [], middleware = []) {
// Create the initial state and state update function.
......@@ -90,3 +170,21 @@ export default function createStore(modules, initArgs = [], middleware = []) {
return store;
}
/**
* Helper to validate a store module configuration before it is passed to
* `createStore`.
*
* @template State
* @template Actions
* @template Selectors
* @template RootSelectors
* @param {Module<State,Actions,Selectors,RootSelectors>} config
* @return {Module<State,Actions,Selectors,RootSelectors>}
*/
export function storeModule(config) {
// This helper doesn't currently do anything at runtime. It does ensure more
// helpful error messages when typechecking if there is something incorrect
// in the configuration.
return config;
}
......@@ -49,46 +49,28 @@ import toastMessages from './modules/toast-messages';
import viewer from './modules/viewer';
/**
* // Base redux store
* @typedef {import("redux").Store} ReduxStore
*
* // Custom stores
* @typedef {import("./modules/activity").ActivityStore} ActivityStore
* @typedef {import("./modules/annotations").AnnotationsStore} AnnotationsStore
* @typedef {import("./modules/defaults").DefaultsStore} DefaultsStore
* @typedef {import("./modules/direct-linked").DirectLinkedStore} DirectLinkedStore
* @typedef {import("./modules/drafts").DraftsStore} DraftsStore
* @typedef {import("./modules/filters").FiltersStore} FiltersStore
* @typedef {import("./modules/frames").FramesStore} FramesStore
* @typedef {import("./modules/groups").GroupsStore} GroupsStore
* @typedef {import("./modules/links").LinksStore} LinksStore
* @typedef {import("./modules/real-time-updates").RealTimeUpdatesStore} RealTimeUpdatesStore
* @typedef {import("./modules/route").RouteStore} RouteStore
* @typedef {import("./modules/selection").SelectionStore} SelectionStore
* @typedef {import("./modules/session").SessionStore} SessionStore
* @typedef {import("./modules/sidebar-panels").SidebarPanelsStore} SidebarPanelsStore
* @typedef {import("./modules/toast-messages").ToastMessagesStore} ToastMessagesStore
* @typedef {import("./modules/viewer").ViewerStore} ViewerStore
* // TODO: add more stores
*
* // Combine all stores
* @typedef {ReduxStore &
* ActivityStore &
* AnnotationsStore &
* DefaultsStore &
* DirectLinkedStore &
* DraftsStore &
* FiltersStore &
* FramesStore &
* GroupsStore &
* LinksStore &
* RealTimeUpdatesStore &
* RouteStore &
* SelectionStore &
* SessionStore &
* SidebarPanelsStore &
* ToastMessagesStore &
* ViewerStore} SidebarStore
* @template M
* @typedef {import('./create-store').StoreFromModule<M>} StoreFromModule
*/
/**
* @typedef {StoreFromModule<activity> &
* StoreFromModule<annotations> &
* StoreFromModule<defaults> &
* StoreFromModule<directLinked> &
* StoreFromModule<drafts> &
* StoreFromModule<filters> &
* StoreFromModule<frames> &
* StoreFromModule<groups> &
* StoreFromModule<links> &
* StoreFromModule<realTimeUpdates> &
* StoreFromModule<route> &
* StoreFromModule<selection> &
* StoreFromModule<session> &
* StoreFromModule<sidebarPanels> &
* StoreFromModule<toastMessages> &
* StoreFromModule<viewer>
* } SidebarStore
*/
/**
......
......@@ -4,6 +4,7 @@
*/
import { actionTypes } from '../util';
import { storeModule } from '../create-store';
function init() {
return {
......@@ -169,25 +170,7 @@ function isSavingAnnotation(state, annotation) {
/** @typedef {import('../../../types/api').Annotation} Annotation */
/**
* @typedef ActivityStore
*
* // Actions
* @prop {typeof annotationFetchStarted} annotationFetchStarted
* @prop {typeof annotationFetchFinished} annotationFetchFinished
* @prop {typeof annotationSaveStarted} annotationSaveStarted
* @prop {typeof annotationSaveFinished} annotationSaveFinished
* @prop {typeof apiRequestStarted} apiRequestStarted
* @prop {typeof apiRequestFinished} apiRequestFinished
*
* // Selectors
* @prop {() => boolean} hasFetchedAnnotations
* @prop {() => boolean} isLoading
* @prop {() => boolean} isFetchingAnnotations
* @prop {(a: Annotation) => boolean} isSavingAnnotation
*/
export default {
export default storeModule({
init,
update,
namespace: 'activity',
......@@ -207,4 +190,4 @@ export default {
isFetchingAnnotations,
isSavingAnnotation,
},
};
});
......@@ -17,6 +17,7 @@ import { createSelector } from 'reselect';
import * as metadata from '../../util/annotation-metadata';
import { countIf, toTrueMap, trueKeys } from '../../util/collections';
import * as util from '../util';
import { storeModule } from '../create-store';
import route from './route';
......@@ -90,6 +91,7 @@ function initializeAnnotation(annotation, tag) {
function init() {
return {
/** @type {Annotation[]} */
annotations: [],
// A set of annotations that are currently "focused" — e.g. hovered over in
// the UI
......@@ -344,7 +346,7 @@ function highlightAnnotations(ids) {
* Annotations to remove. These may be complete annotations or stubs which
* only contain an `id` property.
*/
function removeAnnotations(annotations) {
export function removeAnnotations(annotations) {
return (dispatch, getState) => {
const remainingAnnotations = excludeAnnotations(
getState().annotations.annotations,
......@@ -552,39 +554,7 @@ function savedAnnotations(state) {
});
}
/**
* @typedef AnnotationsStore
*
* // Actions
* @prop {typeof addAnnotations} addAnnotations
* @prop {typeof clearAnnotations} clearAnnotations
* @prop {typeof focusAnnotations} focusAnnotations
* @prop {typeof hideAnnotation} hideAnnotation
* @prop {typeof highlightAnnotations} highlightAnnotations
* @prop {typeof removeAnnotations} removeAnnotations
* @prop {typeof unhideAnnotation} unhideAnnotation
* @prop {typeof updateAnchorStatus} updateAnchorStatus
* @prop {typeof updateFlagStatus} updateFlagStatus
*
* // Selectors
* @prop {() => Annotation[]} allAnnotations
* @prop {() => number} annotationCount
* @prop {(id: string) => boolean} annotationExists
* @prop {(id: string) => Annotation} findAnnotationByID
* @prop {(tags: string[]) => string[]} findIDsForTags
* @prop {() => string[]} focusedAnnotations
* @prop {() => string[]} highlightedAnnotations
* @prop {(tag: string) => boolean} isAnnotationFocused
* @prop {() => boolean} isWaitingToAnchorAnnotations
* @prop {() => Annotation[]} newAnnotations
* @prop {() => Annotation[]} newHighlights
* @prop {() => number} noteCount
* @prop {() => number} orphanCount
* @prop {() => Annotation[]} savedAnnotations
*/
export default {
export default storeModule({
init: init,
namespace: 'annotations',
update: update,
......@@ -599,7 +569,6 @@ export default {
updateAnchorStatus,
updateFlagStatus,
},
selectors: {
allAnnotations,
annotationCount,
......@@ -616,4 +585,4 @@ export default {
orphanCount,
savedAnnotations,
},
};
});
import * as util from '../util';
import { storeModule } from '../create-store';
/**
* A store module for managing client-side user-convenience defaults.
*
......@@ -55,18 +57,7 @@ function getDefaults(state) {
return state;
}
/**
* @typedef DefaultsStore
*
* // Actions
* @prop {typeof setDefault} setDefault
*
* // Selectors
* @prop {(key: string) => string|null} getDefault
* @prop {() => Object.<string,string|null>} getDefaults
*/
export default {
export default storeModule({
init,
namespace: 'defaults',
update,
......@@ -77,4 +68,4 @@ export default {
getDefault,
getDefaults,
},
};
});
import * as util from '../util';
import { storeModule } from '../create-store';
function init(settings) {
return {
/**
......@@ -11,7 +13,7 @@ function init(settings) {
* from the group or clears the selection, the direct link is "consumed"
* and no longer used.
*
* @type {string}
* @type {string|null}
*/
directLinkedGroupId: settings.group || null,
......@@ -24,7 +26,7 @@ function init(settings) {
* switches to a different group manually, the direct link is "consumed"
* and no longer used.
*
* @type {string}
* @type {string|null}
*/
directLinkedAnnotationId: settings.annotations || null,
......@@ -140,23 +142,7 @@ function directLinkedGroupFetchFailed(state) {
return state.directLinkedGroupFetchFailed;
}
/**
* @typedef DirectLinkedStore
*
* // Actions
* @prop {typeof setDirectLinkedGroupFetchFailed} setDirectLinkedGroupFetchFailed
* @prop {typeof setDirectLinkedGroupId} setDirectLinkedGroupId
* @prop {typeof setDirectLinkedAnnotationId} setDirectLinkedAnnotationId
* @prop {typeof clearDirectLinkedGroupFetchFailed} clearDirectLinkedGroupFetchFailed
* @prop {typeof clearDirectLinkedIds} clearDirectLinkedIds
*
* // Selectors
* @prop {() => string|null} directLinkedAnnotationId
* @prop {() => boolean} directLinkedGroupFetchFailed
* @prop {() => string|null} directLinkedGroupId
*/
export default {
export default storeModule({
init,
namespace: 'directLinked',
update,
......@@ -172,4 +158,4 @@ export default {
directLinkedGroupFetchFailed,
directLinkedGroupId,
},
};
});
......@@ -2,6 +2,7 @@ import { createSelector } from 'reselect';
import * as metadata from '../../util/annotation-metadata';
import * as util from '../util';
import { storeModule } from '../create-store';
/** @typedef {import('../../../types/api').Annotation} Annotation */
......@@ -98,7 +99,7 @@ function createDraft(annotation, changes) {
*/
function deleteNewAndEmptyDrafts() {
const { default: annotations } = require('./annotations');
const { removeAnnotations } = require('./annotations');
return (dispatch, getState) => {
const newDrafts = getState().drafts.filter(draft => {
......@@ -111,7 +112,7 @@ function deleteNewAndEmptyDrafts() {
dispatch(removeDraft(draft.annotation));
return draft.annotation;
});
dispatch(annotations.actions.removeAnnotations(removedAnnotations));
dispatch(removeAnnotations(removedAnnotations));
};
}
......@@ -187,23 +188,7 @@ const unsavedAnnotations = createSelector(
drafts => drafts.filter(d => !d.annotation.id).map(d => d.annotation)
);
/**
* @typedef DraftsStore
*
* // Actions
* @prop {typeof createDraft} createDraft
* @prop {typeof deleteNewAndEmptyDrafts} deleteNewAndEmptyDrafts
* @prop {typeof discardAllDrafts} discardAllDrafts
* @prop {typeof removeDraft} removeDraft
*
* // Selectors
* @prop {() => number} countDrafts
* @prop {(a: Annotation) => Draft|null} getDraft
* @prop {(a: Annotation) => Draft|null} getDraftIfNotEmpty
* @prop {() => Annotation[]} unsavedAnnotations
*/
export default {
export default storeModule({
init,
namespace: 'drafts',
update,
......@@ -220,4 +205,4 @@ export default {
getDraftIfNotEmpty,
unsavedAnnotations,
},
};
});
import { createSelector } from 'reselect';
import { actionTypes } from '../util';
import { storeModule } from '../create-store';
/**
* Manage state pertaining to the filtering of annotations in the UI.
......@@ -270,25 +271,7 @@ function hasAppliedFilter(state) {
return !!(state.query || Object.keys(getFilters(state)).length);
}
/**
* @typedef FiltersStore
*
* // Actions
* @prop {typeof changeFocusModeUser} changeFocusModeUser
* @prop {typeof setFilter} setFilter
* @prop {typeof setFilterQuery} setFilterQuery
* @prop {typeof toggleFocusMode} toggleFocusMode
*
* // Selectors
* @prop {() => string|null} filterQuery
* @prop {() => FocusState} focusState
* @prop {(filterName: string) => FilterOption|undefined} getFilter
* @prop {() => Object<string,FilterOption>} getFilters
* @prop {() => Object<string,string>} getFilterValues
* @prop {() => boolean} hasAppliedFilter
*/
export default {
export default storeModule({
init,
namespace: 'filters',
update,
......@@ -306,4 +289,4 @@ export default {
getFilterValues,
hasAppliedFilter,
},
};
});
......@@ -6,6 +6,7 @@ import {
import shallowEqual from 'shallowequal';
import * as util from '../util';
import { storeModule } from '../create-store';
/**
* @typedef {import('../../../types/annotator').DocumentMetadata} DocumentMetadata
......@@ -154,21 +155,7 @@ const searchUris = createShallowEqualSelector(
uris => uris
);
/**
* @typedef FramesStore
*
* // Actions
* @prop {typeof connectFrame} connectFrame
* @prop {typeof destroyFrame} destroyFrame
* @prop {typeof updateFrameAnnotationFetchStatus} updateFrameAnnotationFetchStatus
*
* // Selectors
* @prop {() => Frame[]} frames
* @prop {() => Frame|null} mainFrame
* @prop {() => string[]} searchUris
*/
export default {
export default storeModule({
init: init,
namespace: 'frames',
update: update,
......@@ -184,4 +171,4 @@ export default {
mainFrame,
searchUris,
},
};
});
import { createSelector } from 'reselect';
import * as util from '../util';
import { storeModule } from '../create-store';
import session from './session';
......@@ -196,28 +197,7 @@ const getCurrentlyViewingGroups = createSelector(
}
);
/**
* @typedef GroupsStore
*
* // Actions
* @prop {typeof focusGroup} focusGroup
* @prop {typeof loadGroups} loadGroups
* @prop {typeof clearGroups} clearGroups
*
* // Selectors
* @prop {() => Group[]} allGroups
* @prop {() => Group|undefined|null} focusedGroup
* @prop {() => string|null} focusedGroupId
* @prop {() => Group[]} getFeaturedGroups
* @prop {(id: string) => Group|undefined} getGroup
* @prop {() => Group[]} getInScopeGroups
*
* // Root selectors
* @prop {() => Group[]} getCurrentlyViewingGroups,
* @prop {() => Group[]} getMyGroups,
*/
export default {
export default storeModule({
init,
namespace: 'groups',
update,
......@@ -238,4 +218,4 @@ export default {
getCurrentlyViewingGroups,
getMyGroups,
},
};
});
import { storeModule } from '../create-store';
import { actionTypes } from '../util';
/**
......@@ -32,19 +34,12 @@ function updateLinks(newLinks) {
};
}
/**
* @typedef LinksStore
*
* // Actions
* @prop {typeof updateLinks} updateLinks
*/
export default {
init: init,
export default storeModule({
init,
namespace: 'links',
update,
actions: {
updateLinks,
},
selectors: {},
};
});
......@@ -9,6 +9,7 @@
import { createSelector } from 'reselect';
import { storeModule } from '../create-store';
import { actionTypes } from '../util';
import annotations from './annotations';
......@@ -182,21 +183,7 @@ function hasPendingDeletion(state, id) {
return state.pendingDeletions.hasOwnProperty(id);
}
/**
* @typedef RealTimeUpdatesStore
*
* // Actions
* @prop {typeof receiveRealTimeUpdates} receiveRealTimeUpdates
* @prop {typeof clearPendingUpdates} clearPendingUpdates
*
* // Selectors
* @prop {() => boolean} hasPendingDeletion
* @prop {() => Object.<string, boolean>} pendingDeletions
* @prop {() => Object.<string, Annotation>} pendingUpdates
* @prop {() => number} pendingUpdateCount
*/
export default {
export default storeModule({
init,
namespace: 'realTimeUpdates',
update,
......@@ -210,4 +197,4 @@ export default {
pendingUpdates,
pendingUpdateCount,
},
};
});
import { actionTypes } from '../util';
import { storeModule } from '../create-store';
function init() {
return {
/**
......@@ -56,18 +58,7 @@ function routeParams(state) {
return state.params;
}
/**
* @typedef RouteStore
*
* // Actions
* @prop {typeof changeRoute} changeRoute
*
* // Selectors
* @prop {() => string|null} route
* @prop {() => Object.<string,string>} routeParams
*/
export default {
export default storeModule({
init,
namespace: 'route',
update,
......@@ -78,4 +69,4 @@ export default {
route,
routeParams,
},
};
});
......@@ -22,6 +22,7 @@ import uiConstants from '../../ui-constants';
import * as metadata from '../../util/annotation-metadata';
import { countIf, trueKeys, toTrueMap } from '../../util/collections';
import * as util from '../util';
import { storeModule } from '../create-store';
/**
* Default sort keys for each tab.
......@@ -376,31 +377,7 @@ const sortKeys = createSelector(
}
);
/**
* @typedef SelectionStore
*
* // Actions
* @prop {typeof clearSelection} clearSelection
* @prop {typeof selectAnnotations} selectAnnotations
* @prop {typeof selectTab} selectTab
* @prop {typeof setExpanded} setExpanded
* @prop {typeof setForcedVisible} setForcedVisible
* @prop {typeof setSortKey} setSortKey
* @prop {typeof toggleSelectedAnnotations} toggleSelectedAnnotations
*
* // Selectors
* @prop {() => Object<string,boolean>} expandedMap
* @prop {() => string[]} forcedVisibleAnnotations
* @prop {() => boolean} hasSelectedAnnotations
* @prop {() => string[]} selectedAnnotations
* @prop {() => string} selectedTab
* @prop {() => SelectionState} selectionState
* @prop {() => string} sortKey
* @prop {() => string[]} sortKeys
*
*/
export default {
export default storeModule({
init: init,
namespace: 'selection',
update: update,
......@@ -425,4 +402,4 @@ export default {
sortKey,
sortKeys,
},
};
});
import * as util from '../util';
import { storeModule } from '../create-store';
/**
* @typedef {import('../../../types/api').Profile} Profile
*/
......@@ -91,20 +93,7 @@ function profile(state) {
return state.profile;
}
/**
* @typedef SessionStore
*
* // Actions
* @prop {typeof hasFetchedProfile} hasFetchedProfile
*
* // Selectors
* @prop {() => boolean} hasFetchedProfile
* @prop {(feature: string) => boolean} isFeatureEnabled
* @prop {() => boolean} isLoggedIn
* @prop {() => Profile} profile
*/
export default {
export default storeModule({
init,
namespace: 'session',
update,
......@@ -119,4 +108,4 @@ export default {
isLoggedIn,
profile,
},
};
});
......@@ -10,6 +10,8 @@
import * as util from '../util';
import { storeModule } from '../create-store';
function init() {
return {
/*
......@@ -112,19 +114,7 @@ function isSidebarPanelOpen(state, panelName) {
return state.activePanelName === panelName;
}
/**
* @typedef SidebarPanelsStore
*
* // Actions
* @prop {typeof openSidebarPanel} openSidebarPanel
* @prop {typeof closeSidebarPanel} closeSidebarPanel
* @prop {typeof toggleSidebarPanel} toggleSidebarPanel
*
* // Selectors
* @prop {(name: string) => boolean} isSidebarPanelOpen
*/
export default {
export default storeModule({
namespace: 'sidebarPanels',
init: init,
update: update,
......@@ -138,4 +128,4 @@ export default {
selectors: {
isSidebarPanelOpen,
},
};
});
import { storeModule } from '../create-store';
import * as util from '../util';
/**
......@@ -110,21 +112,7 @@ function hasMessage(state, type, text) {
});
}
/**
* @typedef ToastMessagesStore
*
* // Actions
* @prop {typeof addMessage} addToastMessage
* @prop {typeof removeMessage} removeToastMessage
* @prop {typeof updateMessage} updateToastMessage
*
* // Selectors
* @prop {(id: string) => (ToastMessage|undefined)} getToastMessage
* @prop {() => ToastMessage[]} getToastMessages
* @prop {(type: string, text: string) => boolean} hasToastMessage
*/
export default {
export default storeModule({
init,
namespace: 'toastMessages',
update,
......@@ -138,4 +126,4 @@ export default {
getToastMessages: getMessages,
hasToastMessage: hasMessage,
},
};
});
import * as util from '../util';
import { storeModule } from '../create-store';
/**
* This module defines actions and state related to the display mode of the
* sidebar.
......@@ -53,18 +55,7 @@ function hasSidebarOpened(state) {
return state.sidebarHasOpened;
}
/**
* @typedef ViewerStore
*
* // Actions
* @prop {typeof setShowHighlights} setShowHighlights
* @prop {typeof setSidebarOpened} setSidebarOpened
*
* // Selectors
* @prop {() => boolean} hasSidebarOpened
*/
export default {
export default storeModule({
init: init,
namespace: 'viewer',
update: update,
......@@ -75,4 +66,4 @@ export default {
selectors: {
hasSidebarOpened,
},
};
});
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