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  }