github.com/cockroachdb/cockroach@v20.2.0-alpha.1+incompatible/pkg/ui/src/redux/cachedDataReducer.ts (about) 1 // Copyright 2018 The Cockroach Authors. 2 // 3 // Use of this software is governed by the Business Source License 4 // included in the file licenses/BSL.txt. 5 // 6 // As of the Change Date specified in that file, in accordance with 7 // the Business Source License, use of this software will be governed 8 // by the Apache License, Version 2.0, included in the file 9 // licenses/APL.txt. 10 11 /** 12 * This module maintains the state of read-only data fetched from the cluster. 13 * Data is fetched from an API endpoint in either 'util/api' or 14 * 'util/cockroachlabsAPI' 15 */ 16 17 import _ from "lodash"; 18 import { Action, Dispatch } from "redux"; 19 import assert from "assert"; 20 import moment from "moment"; 21 import { push } from "connected-react-router"; 22 import { ThunkAction } from "redux-thunk"; 23 24 import { createHashHistory } from "history"; 25 import { getLoginPage } from "src/redux/login"; 26 import { APIRequestFn } from "src/util/api"; 27 28 import { PayloadAction, WithRequest } from "src/interfaces/action"; 29 30 // CachedDataReducerState is used to track the state of the cached data. 31 export class CachedDataReducerState<TResponseMessage> { 32 data?: TResponseMessage; // the latest data received 33 inFlight = false; // true if a request is in flight 34 valid = false; // true if data has been received and has not been invalidated 35 requestedAt?: moment.Moment; // Timestamp when data was last requested. 36 setAt?: moment.Moment; // Timestamp when this data was last updated. 37 lastError?: Error; // populated with the most recent error, if the last request failed 38 } 39 40 // KeyedCachedDataReducerState is used to track the state of the cached data 41 // that is associated with a key. 42 export class KeyedCachedDataReducerState<TResponseMessage> { 43 [id: string]: CachedDataReducerState<TResponseMessage>; 44 } 45 46 /** 47 * CachedDataReducer is a wrapper object that contains a redux reducer and a 48 * number of redux actions. The reducer method is the reducer and the refresh 49 * method is the main action creator that refreshes the data when dispatched. 50 * 51 * Each instance of this class is instantiated with an api endpoint with request 52 * type TRequest and response type Promise<TResponseMessage>. 53 */ 54 export class CachedDataReducer<TRequest, TResponseMessage, TActionNamespace extends string = string> { 55 // Track all the currently seen namespaces, to ensure there isn't a conflict 56 private static namespaces: { [actionNamespace: string]: boolean } = {}; 57 58 // Actions 59 REQUEST: string; // make a new request 60 RECEIVE: string; // receive new data 61 ERROR: string; // request encountered an error 62 INVALIDATE: string; // invalidate data 63 64 /** 65 * apiEndpoint - The API endpoint used to refresh data. 66 * actionNamespace - A unique namespace for the redux actions. 67 * invalidationPeriod (optional) - The duration after 68 * data is received after which it will be invalidated. 69 * requestTimeout (optional) 70 */ 71 constructor( 72 protected apiEndpoint: APIRequestFn<TRequest, TResponseMessage>, 73 public actionNamespace: TActionNamespace, 74 protected invalidationPeriod?: moment.Duration, 75 protected requestTimeout?: moment.Duration, 76 ) { 77 // check actionNamespace 78 assert(!CachedDataReducer.namespaces.hasOwnProperty(actionNamespace), "Expected actionNamespace to be unique."); 79 CachedDataReducer.namespaces[actionNamespace] = true; 80 81 this.REQUEST = `cockroachui/CachedDataReducer/${actionNamespace}/REQUEST`; 82 this.RECEIVE = `cockroachui/CachedDataReducer/${actionNamespace}/RECEIVE`; 83 this.ERROR = `cockroachui/CachedDataReducer/${actionNamespace}/ERROR`; 84 this.INVALIDATE = `cockroachui/CachedDataReducer/${actionNamespace}/INVALIDATE`; 85 } 86 87 /** 88 * setTimeSource overrides the source of timestamps used by this component. 89 * Intended for use in tests only. 90 */ 91 setTimeSource(timeSource: { (): moment.Moment }) { 92 this.timeSource = timeSource; 93 } 94 95 /** 96 * Redux reducer which processes actions related to the api endpoint query. 97 */ 98 reducer = (state = new CachedDataReducerState<TResponseMessage>(), action: Action): CachedDataReducerState<TResponseMessage> => { 99 if (_.isNil(action)) { 100 return state; 101 } 102 103 switch (action.type) { 104 case this.REQUEST: 105 // A request is in progress. 106 state = _.clone(state); 107 state.requestedAt = this.timeSource(); 108 state.inFlight = true; 109 return state; 110 case this.RECEIVE: 111 // The results of a request have been received. 112 const { payload } = action as PayloadAction<WithRequest<TResponseMessage, TRequest>>; 113 state = _.clone(state); 114 state.inFlight = false; 115 state.data = payload.data; 116 state.setAt = this.timeSource(); 117 state.valid = true; 118 state.lastError = null; 119 return state; 120 case this.ERROR: 121 // A request failed. 122 const { payload: error } = action as PayloadAction<WithRequest<Error, TRequest>>; 123 state = _.clone(state); 124 state.inFlight = false; 125 state.lastError = error.data; 126 state.valid = false; 127 return state; 128 case this.INVALIDATE: 129 // The data is invalidated. 130 state = _.clone(state); 131 state.valid = false; 132 return state; 133 default: 134 return state; 135 } 136 } 137 138 // requestData is the REQUEST action creator. 139 requestData = (request?: TRequest): PayloadAction<WithRequest<void, TRequest>> => { 140 return { 141 type: this.REQUEST, 142 payload: { request }, 143 }; 144 } 145 146 // receiveData is the RECEIVE action creator. 147 receiveData = (data: TResponseMessage, request?: TRequest): PayloadAction<WithRequest<TResponseMessage, TRequest>> => { 148 return { 149 type: this.RECEIVE, 150 payload: { request, data }, 151 }; 152 } 153 154 // errorData is the ERROR action creator. 155 errorData = (error: Error, request?: TRequest): PayloadAction<WithRequest<Error, TRequest>> => { 156 return { 157 type: this.ERROR, 158 payload: { request, data: error }, 159 }; 160 } 161 162 // invalidateData is the INVALIDATE action creator. 163 invalidateData = (request?: TRequest): PayloadAction<WithRequest<void, TRequest>> => { 164 return { 165 type: this.INVALIDATE, 166 payload: { request }, 167 }; 168 } 169 170 /** 171 * refresh is the primary action creator that should be used to refresh the 172 * cached data. Dispatching it will attempt to asynchronously refresh the 173 * cached data if and only if: 174 * - a request is not in flight AND 175 * - its results are not considered valid OR 176 * - it has no invalidation period 177 * 178 * req - the request associated with this call to refresh. It includes any 179 * parameters passed to the API call. 180 * stateAccessor (optional) - a helper function that accesses this reducer's 181 * state given the global state object 182 */ 183 refresh = <S>( 184 req?: TRequest, 185 stateAccessor = (state: any, _req: TRequest) => state.cachedData[this.actionNamespace], 186 ): ThunkAction<any, S, any> => { 187 return (dispatch: Dispatch<Action, TResponseMessage>, getState: () => S) => { 188 const state: CachedDataReducerState<TResponseMessage> = stateAccessor(getState(), req); 189 190 if (state && (state.inFlight || (this.invalidationPeriod && state.valid))) { 191 return; 192 } 193 194 // Note that after dispatching requestData, state.inFlight is true 195 dispatch(this.requestData(req)); 196 // Fetch data from the servers. Return the promise for use in tests. 197 return this.apiEndpoint(req, this.requestTimeout).then( 198 (data) => { 199 // Dispatch the results to the store. 200 dispatch(this.receiveData(data, req)); 201 }, 202 (error: Error) => { 203 // TODO(couchand): This is a really myopic way to check for HTTP 204 // codes. However, at the moment that's all that the underlying 205 // timeoutFetch offers. Major changes to this plumbing are warranted. 206 if (error.message === "Unauthorized") { 207 // TODO(couchand): This is an unpleasant dependency snuck in here... 208 const { location } = createHashHistory(); 209 if (location && !location.pathname.startsWith("/login")) { 210 dispatch(push(getLoginPage(location))); 211 } 212 } 213 214 // If an error occurred during the fetch, add it to the store. 215 // Wait 1s to record the error to avoid spamming errors. 216 // TODO(maxlang): Fix error handling more comprehensively. 217 // Tracked in #8699 218 setTimeout(() => dispatch(this.errorData(error, req)), 1000); 219 }, 220 ).then(() => { 221 // Invalidate data after the invalidation period if one exists. 222 if (this.invalidationPeriod) { 223 setTimeout(() => dispatch(this.invalidateData(req)), this.invalidationPeriod.asMilliseconds()); 224 } 225 }); 226 }; 227 } 228 229 private timeSource: { (): moment.Moment } = () => moment(); 230 } 231 232 /** 233 * KeyedCachedDataReducer is a wrapper object that contains a redux reducer and 234 * an instance of CachedDataReducer. The reducer method is the reducer and the 235 * refresh method is the main action creator that refreshes the data when 236 * dispatched. All action creators and the basic reducer are from the 237 * CachedDataReducer instance. 238 * 239 * Each instance of this class is instantiated with an api endpoint with request 240 * type TRequest and response type Promise<TResponseMessage>. 241 */ 242 export class KeyedCachedDataReducer<TRequest, TResponseMessage, TActionNamespace extends string = string> { 243 cachedDataReducer: CachedDataReducer<TRequest, TResponseMessage, TActionNamespace>; 244 245 /** 246 * apiEndpoint - The API endpoint used to refresh data. 247 * actionNamespace - A unique namespace for the redux actions. 248 * requestToID - A function that takes a TRequest and returns a string. Used 249 * as a key to store data returned from that request 250 * invalidationPeriod (optional) - The duration after 251 * data is received after which it will be invalidated. 252 * requestTimeout (optional) 253 * apiEndpoint, actionNamespace, invalidationPeriod and requestTimeout are all 254 * passed to the CachedDataReducer constructor 255 */ 256 constructor( 257 protected apiEndpoint: (req: TRequest) => Promise<TResponseMessage>, 258 public actionNamespace: TActionNamespace, 259 private requestToID: (req: TRequest) => string, 260 protected invalidationPeriod?: moment.Duration, 261 protected requestTimeout?: moment.Duration, 262 ) { 263 this.cachedDataReducer = new CachedDataReducer<TRequest, TResponseMessage, TActionNamespace>( 264 apiEndpoint, actionNamespace, invalidationPeriod, requestTimeout, 265 ); 266 } 267 268 /** 269 * setTimeSource overrides the source of timestamps used by this component. 270 * Intended for use in tests only. 271 */ 272 setTimeSource(timeSource: { (): moment.Moment }) { 273 this.cachedDataReducer.setTimeSource(timeSource); 274 } 275 276 /** 277 * refresh calls the internal CachedDataReducer's refresh function using a 278 * default stateAccessor that indexes in to the state based on a key generated 279 * from the request. 280 */ 281 refresh = (req?: TRequest, stateAccessor = (state: any, r: TRequest) => state.cachedData[this.cachedDataReducer.actionNamespace][this.requestToID(r)]) => this.cachedDataReducer.refresh(req, stateAccessor); 282 283 /** 284 * Keyed redux reducer which pulls out the id from the action payload and then 285 * runs the CachedDataReducer reducer on the action. 286 */ 287 reducer = (state = new KeyedCachedDataReducerState<TResponseMessage>(), action: Action): KeyedCachedDataReducerState<TResponseMessage> => { 288 if (_.isNil(action)) { 289 return state; 290 } 291 292 switch (action.type) { 293 case this.cachedDataReducer.REQUEST: 294 case this.cachedDataReducer.RECEIVE: 295 case this.cachedDataReducer.ERROR: 296 case this.cachedDataReducer.INVALIDATE: 297 const { request } = (action as PayloadAction<WithRequest<TResponseMessage | Error | void, TRequest>>).payload; 298 const id = this.requestToID(request); 299 state = _.clone(state); 300 state[id] = this.cachedDataReducer.reducer(state[id], action); 301 return state; 302 default: 303 return state; 304 } 305 } 306 }