Commit 7bc88d9c authored by Robert Knight's avatar Robert Knight

Specify type of `state` arguments to `createSelector` calls

The newest version of the `createSelector` typings require the type of
the input argument to be determined in order for the whole call to
typecheck. This commit implements a pattern where:

- A `State` type is added in various store modules, typically defined as
  `typeof initialState`, that defines the state of that store module's
  type.
- The type of selector function params in `createSelector` calls is
  specified with `@param {State} state` and the return type is inferred

The upside of these changes is that types of selector functions are
determined much more robustly - previously it was possible to have
incorrect types specified via JSDoc.

In the process an existing minor bug was determined where an
`isHighlight` call in `ThreadList` was passed an unexpected type of
object (the `annotation` property of a `Draft`) for which `isHighlight`
did not crash but would always return false. I have simply suppressed
this existing issue for the moment, but it will need to be fixed
separately.
parent c3436b27
...@@ -96,6 +96,8 @@ function ThreadList({ threads }) { ...@@ -96,6 +96,8 @@ function ThreadList({ threads }) {
// list is the most recently created one. // list is the most recently created one.
const newAnnotations = store const newAnnotations = store
.unsavedAnnotations() .unsavedAnnotations()
// @ts-expect-error - FIXME: The `isHighlight` check here will always fail
// because `unsavedAnnotations` returns objects that do not have a `$highlight` property.
.filter(ann => !ann.id && !isHighlight(ann)); .filter(ann => !ann.id && !isHighlight(ann));
if (!newAnnotations.length) { if (!newAnnotations.length) {
return null; return null;
......
...@@ -114,6 +114,8 @@ const initialState = { ...@@ -114,6 +114,8 @@ const initialState = {
nextTag: 1, nextTag: 1,
}; };
/** @typedef {typeof initialState} State */
const reducers = { const reducers = {
ADD_ANNOTATIONS: function (state, action) { ADD_ANNOTATIONS: function (state, action) {
const updatedIDs = {}; const updatedIDs = {};
...@@ -415,10 +417,9 @@ function updateFlagStatus(id, isFlagged) { ...@@ -415,10 +417,9 @@ function updateFlagStatus(id, isFlagged) {
/** /**
* Count the number of annotations (as opposed to notes or orphans) * Count the number of annotations (as opposed to notes or orphans)
*
* @type {(state: any) => number}
*/ */
const annotationCount = createSelector( const annotationCount = createSelector(
/** @param {State} state */
state => state.annotations, state => state.annotations,
annotations => countIf(annotations, metadata.isAnnotation) annotations => countIf(annotations, metadata.isAnnotation)
); );
...@@ -470,20 +471,18 @@ function findIDsForTags(state, tags) { ...@@ -470,20 +471,18 @@ function findIDsForTags(state, tags) {
/** /**
* Retrieve currently-focused annotation identifiers * Retrieve currently-focused annotation identifiers
*
* @type {(state: any) => string[]}
*/ */
const focusedAnnotations = createSelector( const focusedAnnotations = createSelector(
/** @param {State} state */
state => state.focused, state => state.focused,
focused => trueKeys(focused) focused => trueKeys(focused)
); );
/** /**
* Retrieve currently-highlighted annotation identifiers * Retrieve currently-highlighted annotation identifiers
*
* @type {(state: any) => string[]}
*/ */
const highlightedAnnotations = createSelector( const highlightedAnnotations = createSelector(
/** @param {State} state */
state => state.highlighted, state => state.highlighted,
highlighted => trueKeys(highlighted) highlighted => trueKeys(highlighted)
); );
...@@ -504,6 +503,7 @@ function isAnnotationFocused(state, $tag) { ...@@ -504,6 +503,7 @@ function isAnnotationFocused(state, $tag) {
* @type {(state: any) => boolean} * @type {(state: any) => boolean}
*/ */
const isWaitingToAnchorAnnotations = createSelector( const isWaitingToAnchorAnnotations = createSelector(
/** @param {State} state */
state => state.annotations, state => state.annotations,
annotations => annotations.some(metadata.isWaitingToAnchor) annotations => annotations.some(metadata.isWaitingToAnchor)
); );
...@@ -511,10 +511,9 @@ const isWaitingToAnchorAnnotations = createSelector( ...@@ -511,10 +511,9 @@ const isWaitingToAnchorAnnotations = createSelector(
/** /**
* Return all loaded annotations that are not highlights and have not been saved * Return all loaded annotations that are not highlights and have not been saved
* to the server * to the server
*
* @type {(state: any) => Annotation[]}
*/ */
const newAnnotations = createSelector( const newAnnotations = createSelector(
/** @param {State} state */
state => state.annotations, state => state.annotations,
annotations => annotations =>
annotations.filter(ann => metadata.isNew(ann) && !metadata.isHighlight(ann)) annotations.filter(ann => metadata.isNew(ann) && !metadata.isHighlight(ann))
...@@ -523,10 +522,9 @@ const newAnnotations = createSelector( ...@@ -523,10 +522,9 @@ const newAnnotations = createSelector(
/** /**
* Return all loaded annotations that are highlights and have not been saved * Return all loaded annotations that are highlights and have not been saved
* to the server * to the server
*
* @type {(state: any) => Annotation[]}
*/ */
const newHighlights = createSelector( const newHighlights = createSelector(
/** @param {State} state */
state => state.annotations, state => state.annotations,
annotations => annotations =>
annotations.filter(ann => metadata.isNew(ann) && metadata.isHighlight(ann)) annotations.filter(ann => metadata.isNew(ann) && metadata.isHighlight(ann))
...@@ -534,20 +532,18 @@ const newHighlights = createSelector( ...@@ -534,20 +532,18 @@ const newHighlights = createSelector(
/** /**
* Count the number of page notes currently in the collection * Count the number of page notes currently in the collection
*
@type {(state: any) => number}
*/ */
const noteCount = createSelector( const noteCount = createSelector(
/** @param {State} state */
state => state.annotations, state => state.annotations,
annotations => countIf(annotations, metadata.isPageNote) annotations => countIf(annotations, metadata.isPageNote)
); );
/** /**
* Count the number of orphans currently in the collection * Count the number of orphans currently in the collection
*
* @type {(state: any) => number}
*/ */
const orphanCount = createSelector( const orphanCount = createSelector(
/** @param {State} state */
state => state.annotations, state => state.annotations,
annotations => countIf(annotations, metadata.isOrphan) annotations => countIf(annotations, metadata.isOrphan)
); );
......
...@@ -15,6 +15,8 @@ import { removeAnnotations } from './annotations'; ...@@ -15,6 +15,8 @@ import { removeAnnotations } from './annotations';
/** @type {Draft[]} */ /** @type {Draft[]} */
const initialState = []; const initialState = [];
/** @typedef {typeof initialState} State */
/** /**
* Helper class to encapsulate the draft properties and a few simple methods. * Helper class to encapsulate the draft properties and a few simple methods.
* *
...@@ -177,10 +179,9 @@ function getDraftIfNotEmpty(state, annotation) { ...@@ -177,10 +179,9 @@ function getDraftIfNotEmpty(state, annotation) {
/** /**
* Returns a list of draft annotations which have no id. * Returns a list of draft annotations which have no id.
*
* @type {(state: any) => Annotation[]}
*/ */
const unsavedAnnotations = createSelector( const unsavedAnnotations = createSelector(
/** @param {State} state */
state => state, state => state,
drafts => drafts.filter(d => !d.annotation.id).map(d => d.annotation) drafts => drafts.filter(d => !d.annotation.id).map(d => d.annotation)
); );
......
...@@ -46,7 +46,7 @@ import { createStoreModule } from '../create-store'; ...@@ -46,7 +46,7 @@ import { createStoreModule } from '../create-store';
*/ */
/** /**
* @typedef {Record<FilterKey, FilterOption>|{}} Filters * @typedef {Record<FilterKey, FilterOption>} Filters
*/ */
/** /**
...@@ -59,10 +59,7 @@ import { createStoreModule } from '../create-store'; ...@@ -59,10 +59,7 @@ import { createStoreModule } from '../create-store';
function initialState(settings) { function initialState(settings) {
const focusConfig = settings.focus || {}; const focusConfig = settings.focus || {};
return { return {
/** filters: /** @type {Filters} */ ({}),
* @type {Filters}
*/
filters: {},
// immediately activate focus mode if there is a valid config // immediately activate focus mode if there is a valid config
focusActive: isValidFocusConfig(focusConfig), focusActive: isValidFocusConfig(focusConfig),
...@@ -73,6 +70,8 @@ function initialState(settings) { ...@@ -73,6 +70,8 @@ function initialState(settings) {
}; };
} }
/** @typedef {ReturnType<typeof initialState>} State */
/** /**
* Given the provided focusConfig: is it a valid configuration for focus? * Given the provided focusConfig: is it a valid configuration for focus?
* At this time, a `user` filter is required. * At this time, a `user` filter is required.
...@@ -93,7 +92,7 @@ function isValidFocusConfig(focusConfig) { ...@@ -93,7 +92,7 @@ function isValidFocusConfig(focusConfig) {
*/ */
function focusFiltersFromConfig(focusConfig) { function focusFiltersFromConfig(focusConfig) {
if (!isValidFocusConfig(focusConfig)) { if (!isValidFocusConfig(focusConfig)) {
return {}; return /** @type {Filters} */ ({});
} }
const userFilterValue = const userFilterValue =
focusConfig.user.username || focusConfig.user.userid || ''; focusConfig.user.username || focusConfig.user.userid || '';
...@@ -145,7 +144,7 @@ const reducers = { ...@@ -145,7 +144,7 @@ const reducers = {
CLEAR_SELECTION: function () { CLEAR_SELECTION: function () {
return { return {
filters: {}, filters: /** @type {Filters} */ ({}),
focusActive: false, focusActive: false,
query: null, query: null,
}; };
...@@ -221,7 +220,9 @@ function filterQuery(state) { ...@@ -221,7 +220,9 @@ function filterQuery(state) {
* @type {(state: any) => FocusState} * @type {(state: any) => FocusState}
*/ */
const focusState = createSelector( const focusState = createSelector(
/** @param {State} state */
state => state.focusActive, state => state.focusActive,
/** @param {State} state */
state => state.focusFilters, state => state.focusFilters,
(focusActive, focusFilters) => { (focusActive, focusFilters) => {
return { return {
......
...@@ -23,6 +23,8 @@ import { createStoreModule } from '../create-store'; ...@@ -23,6 +23,8 @@ import { createStoreModule } from '../create-store';
/** @type {Frame[]} */ /** @type {Frame[]} */
const initialState = []; const initialState = [];
/** @typedef {typeof initialState} State */
const reducers = { const reducers = {
CONNECT_FRAME: function (state, action) { CONNECT_FRAME: function (state, action) {
return [...state, action.frame]; return [...state, action.frame];
...@@ -99,10 +101,9 @@ function frames(state) { ...@@ -99,10 +101,9 @@ function frames(state) {
* for that purpose. * for that purpose.
* *
* This may be `null` during startup. * This may be `null` during startup.
*
* @type {(state: any) => Frame|null}
*/ */
const mainFrame = createSelector( const mainFrame = createSelector(
/** @param {State} state */
state => state, state => state,
// Sub-frames will all have a "frame identifier" set. The main frame is the // Sub-frames will all have a "frame identifier" set. The main frame is the
...@@ -142,16 +143,14 @@ const createShallowEqualSelector = createSelectorCreator( ...@@ -142,16 +143,14 @@ const createShallowEqualSelector = createSelectorCreator(
/** /**
* Memoized selector will return the same array (of URIs) reference unless the * Memoized selector will return the same array (of URIs) reference unless the
* values of the array change (are not shallow-equal). * values of the array change (are not shallow-equal).
*
* @type {(state: any) => string[]}
*/ */
const searchUris = createShallowEqualSelector( const searchUris = createShallowEqualSelector(
frames => { /** @param {State} frames */
return frames.reduce( frames =>
frames.reduce(
(uris, frame) => uris.concat(searchUrisForFrame(frame)), (uris, frame) => uris.concat(searchUrisForFrame(frame)),
[] /** @type {string[]} */ ([])
); ),
},
uris => uris uris => uris
); );
......
...@@ -7,6 +7,7 @@ import session from './session'; ...@@ -7,6 +7,7 @@ import session from './session';
/** /**
* @typedef {import('../../../types/api').Group} Group * @typedef {import('../../../types/api').Group} Group
* @typedef {import('./session').State} SessionState
*/ */
const initialState = { const initialState = {
...@@ -23,6 +24,8 @@ const initialState = { ...@@ -23,6 +24,8 @@ const initialState = {
focusedGroupId: null, focusedGroupId: null,
}; };
/** @typedef {typeof initialState} State */
const reducers = { const reducers = {
FOCUS_GROUP(state, action) { FOCUS_GROUP(state, action) {
const group = state.groups.find(g => g.id === action.id); const group = state.groups.find(g => g.id === action.id);
...@@ -139,10 +142,9 @@ function getGroup(state, id) { ...@@ -139,10 +142,9 @@ function getGroup(state, id) {
/** /**
* Return groups the user isn't a member of that are scoped to the URI. * Return groups the user isn't a member of that are scoped to the URI.
*
* @type {(state: any) => Group[]}
*/ */
const getFeaturedGroups = createSelector( const getFeaturedGroups = createSelector(
/** @param {State} state */
state => state.groups, state => state.groups,
groups => groups.filter(group => !group.isMember && group.isScopedToUri) groups => groups.filter(group => !group.isMember && group.isScopedToUri)
); );
...@@ -151,10 +153,9 @@ const getFeaturedGroups = createSelector( ...@@ -151,10 +153,9 @@ const getFeaturedGroups = createSelector(
* Return groups that are scoped to the uri. This is used to return the groups * Return groups that are scoped to the uri. This is used to return the groups
* that show up in the old groups menu. This should be removed once the new groups * that show up in the old groups menu. This should be removed once the new groups
* menu is permanent. * menu is permanent.
*
* @type {(state: any) => Group[]}
*/ */
const getInScopeGroups = createSelector( const getInScopeGroups = createSelector(
/** @param {State} state */
state => state.groups, state => state.groups,
groups => groups.filter(g => g.isScopedToUri) groups => groups.filter(g => g.isScopedToUri)
); );
...@@ -163,10 +164,9 @@ const getInScopeGroups = createSelector( ...@@ -163,10 +164,9 @@ const getInScopeGroups = createSelector(
/** /**
* Return groups the logged in user is a member of. * Return groups the logged in user is a member of.
*
* @type {(state: any) => Group[]}
*/ */
const getMyGroups = createSelector( const getMyGroups = createSelector(
/** @param {{ groups: State, session: SessionState }} rootState */
rootState => rootState.groups.groups, rootState => rootState.groups.groups,
rootState => session.selectors.isLoggedIn(rootState.session), rootState => session.selectors.isLoggedIn(rootState.session),
(groups, loggedIn) => { (groups, loggedIn) => {
...@@ -181,10 +181,9 @@ const getMyGroups = createSelector( ...@@ -181,10 +181,9 @@ const getMyGroups = createSelector(
/** /**
* Return groups that don't show up in Featured and My Groups. * Return groups that don't show up in Featured and My Groups.
*
* @type {(state: any) => Group[]}
*/ */
const getCurrentlyViewingGroups = createSelector( const getCurrentlyViewingGroups = createSelector(
/** @param {{ groups: State, session: SessionState }} rootState */
rootState => allGroups(rootState.groups), rootState => allGroups(rootState.groups),
rootState => getMyGroups(rootState), rootState => getMyGroups(rootState),
rootState => getFeaturedGroups(rootState.groups), rootState => getFeaturedGroups(rootState.groups),
......
...@@ -35,6 +35,8 @@ const initialState = { ...@@ -35,6 +35,8 @@ const initialState = {
pendingDeletions: {}, pendingDeletions: {},
}; };
/** @typedef {typeof initialState} State */
const reducers = { const reducers = {
RECEIVE_REAL_TIME_UPDATES(state, action) { RECEIVE_REAL_TIME_UPDATES(state, action) {
return { return {
...@@ -175,6 +177,7 @@ function pendingDeletions(state) { ...@@ -175,6 +177,7 @@ function pendingDeletions(state) {
* @type {(state: any) => number} * @type {(state: any) => number}
*/ */
const pendingUpdateCount = createSelector( const pendingUpdateCount = createSelector(
/** @param {State} state */
state => [state.pendingUpdates, state.pendingDeletions], state => [state.pendingUpdates, state.pendingDeletions],
([pendingUpdates, pendingDeletions]) => ([pendingUpdates, pendingDeletions]) =>
Object.keys(pendingUpdates).length + Object.keys(pendingDeletions).length Object.keys(pendingUpdates).length + Object.keys(pendingDeletions).length
......
...@@ -9,10 +9,10 @@ ...@@ -9,10 +9,10 @@
*/ */
/** /**
* @typedef SelectionState * @typedef State
* @prop {Record<string,boolean>} expanded * @prop {Record<string, boolean>} expanded
* @prop {string[]} forcedVisible * @prop {Record<string, boolean>} forcedVisible
* @prop {string[]} selected * @prop {Record<string, boolean>} selected
* @prop {string} sortKey * @prop {string} sortKey
* @prop {'annotation'|'note'|'orphan'} selectedTab * @prop {'annotation'|'note'|'orphan'} selectedTab
*/ */
...@@ -34,6 +34,7 @@ const TAB_SORTKEY_DEFAULT = { ...@@ -34,6 +34,7 @@ const TAB_SORTKEY_DEFAULT = {
}; };
function initialSelection(settings) { function initialSelection(settings) {
/** @type {Record<string, boolean>} */
const selection = {}; const selection = {};
// TODO: Do not take into account existence of `settings.query` here // TODO: Do not take into account existence of `settings.query` here
// once root-thread-building is fully updated: the decision of whether // once root-thread-building is fully updated: the decision of whether
...@@ -44,6 +45,7 @@ function initialSelection(settings) { ...@@ -44,6 +45,7 @@ function initialSelection(settings) {
return selection; return selection;
} }
/** @return {State} */
function initialState(settings) { function initialState(settings) {
return { return {
/** /**
...@@ -309,28 +311,23 @@ function expandedMap(state) { ...@@ -309,28 +311,23 @@ function expandedMap(state) {
return state.expanded; return state.expanded;
} }
/**
* @type {(state: any) => string[]}
*/
const forcedVisibleThreads = createSelector( const forcedVisibleThreads = createSelector(
/** @param {State} state */
state => state.forcedVisible, state => state.forcedVisible,
forcedVisible => trueKeys(forcedVisible) forcedVisible => trueKeys(forcedVisible)
); );
/** /**
* Are any annotations currently selected? * Are any annotations currently selected?
*
* @type {(state: any) => boolean}
*/ */
const hasSelectedAnnotations = createSelector( const hasSelectedAnnotations = createSelector(
/** @param {State} state */
state => state.selected, state => state.selected,
selection => trueKeys(selection).length > 0 selection => trueKeys(selection).length > 0
); );
/**
* @type {(state: any) => string[]}
*/
const selectedAnnotations = createSelector( const selectedAnnotations = createSelector(
/** @param {State} state */
state => state.selected, state => state.selected,
selection => trueKeys(selection) selection => trueKeys(selection)
); );
...@@ -344,10 +341,8 @@ function selectedTab(state) { ...@@ -344,10 +341,8 @@ function selectedTab(state) {
return state.selectedTab; return state.selectedTab;
} }
/**
* @return {SelectionState}
*/
const selectionState = createSelector( const selectionState = createSelector(
/** @param {State} state */
state => state, state => state,
selection => { selection => {
return { return {
...@@ -372,10 +367,9 @@ function sortKey(state) { ...@@ -372,10 +367,9 @@ function sortKey(state) {
/** /**
* Retrieve applicable sort options for the currently-selected tab. * Retrieve applicable sort options for the currently-selected tab.
*
* @type {(state: any) => string[]}
*/ */
const sortKeys = createSelector( const sortKeys = createSelector(
/** @param {State} state */
state => state.selectedTab, state => state.selectedTab,
selectedTab => { selectedTab => {
const sortKeysForTab = ['Newest', 'Oldest']; const sortKeysForTab = ['Newest', 'Oldest'];
......
...@@ -23,6 +23,13 @@ const initialProfile = { ...@@ -23,6 +23,13 @@ const initialProfile = {
userid: null, userid: null,
}; };
/**
* @typedef State
* @prop {string} defaultAuthority
* @prop {Profile} profile
*/
/** @return {State} */
function initialState(settings) { function initialState(settings) {
return { return {
/** /**
......
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