github.com/cockroachdb/cockroach@v20.2.0-alpha.1+incompatible/pkg/ui/src/redux/uiData.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 import _ from "lodash"; 12 import { Action, Dispatch } from "redux"; 13 import * as protobuf from "protobufjs/minimal"; 14 15 import * as protos from "src/js/protos"; 16 import { PayloadAction } from "src/interfaces/action"; 17 import { getUIData, setUIData } from "src/util/api"; 18 import { AdminUIState } from "./state"; 19 20 export const SET = "cockroachui/uidata/SET_OPTIN"; 21 export const LOAD_ERROR = "cockroachui/uidata/LOAD_ERROR"; 22 export const SAVE_ERROR = "cockroachui/uidata/SAVE_ERROR"; 23 export const LOAD = "cockroachui/uidata/LOAD"; 24 export const LOAD_COMPLETE = "cockroachui/uidata/LOAD_COMPLETE"; 25 export const SAVE = "cockroachui/uidata/SAVE"; 26 export const SAVE_COMPLETE = "cockroachui/uidata/SAVE_COMPLETE"; 27 28 // Opt In Attribute Keys 29 export const KEY_HELPUS: string = "helpus"; 30 // The "server." prefix denotes that this key is shared with the server, so 31 // changes to this key must be synchronized with the server code. 32 export const KEY_OPTIN: string = "server.optin-reporting"; 33 // Tracks whether the latest registration data has been synchronized with the 34 // Cockroach Labs servers. 35 export const KEY_REGISTRATION_SYNCHRONIZED = "registration_synchronized"; 36 37 /** 38 * OptInAttributes tracks the values the user has provided when opting in to usage reporting 39 */ 40 export class OptInAttributes { 41 email: string = ""; 42 optin: boolean = null; // Did the user opt in/out of reporting usage 43 /** 44 * Number of times the user has dismissed the opt-in banner. This was made a 45 * number instead of a boolean for a feature that was not implemented, and is 46 * currently only ever set to null or 1. 47 */ 48 dismissed: number = null; 49 firstname: string = ""; 50 lastname: string = ""; 51 company: string = ""; 52 updates: boolean = null; // Did the user sign up for product/feature updates 53 } 54 55 // VERSION_DISMISSED_KEY is the uiData key on the server that tracks when the outdated banner 56 // was last dismissed. 57 export const VERSION_DISMISSED_KEY = "version_dismissed"; 58 59 // INSTRUCTIONS_BOX_COLLAPSED_KEY is the uiData key on the server that tracks whether the 60 // instructions box on the cluster viz has been collapsed or not. 61 export const INSTRUCTIONS_BOX_COLLAPSED_KEY = "clusterviz_instructions_box_collapsed"; 62 63 // RELEASE_NOTES_SIGNUP_DISMISSED_KEY is the uiData key on the server that tracks when the user 64 // dismisses Release Nodes signup form. 65 export const RELEASE_NOTES_SIGNUP_DISMISSED_KEY = "release_notes_signup_dismissed"; 66 67 export enum UIDataStatus { 68 UNINITIALIZED, // Data has not been loaded yet. 69 LOADING, 70 LOADING_LOAD_ERROR, // Loading with an existing load error 71 SAVING, 72 SAVE_ERROR, 73 LOAD_ERROR, 74 VALID, // Data isn't loading/saving and has been successfully loaded/saved. 75 } 76 77 export class UIData { 78 status: UIDataStatus = UIDataStatus.UNINITIALIZED; 79 error: Error; 80 data: any; 81 } 82 83 /** 84 * UIDataState maintains the current values of fields that are persisted to the 85 * server as UIData. Fields are maintained in this collection as untyped 86 * objects. 87 */ 88 export class UIDataState { 89 [key: string]: UIData; 90 } 91 92 /** 93 * Reducer which modifies a UIDataState. 94 */ 95 export function uiDataReducer(state = new UIDataState(), action: Action): UIDataState { 96 if (_.isNil(action)) { 97 return state; 98 } 99 100 switch (action.type) { 101 case SET: { 102 const { key, value } = (action as PayloadAction<KeyValue>).payload; 103 state = _.clone(state); 104 state[key] = _.clone(state[key]) || new UIData(); 105 state[key].status = UIDataStatus.VALID; 106 state[key].data = value; 107 state[key].error = null; 108 return state; 109 } 110 case SAVE: { 111 const keys = (action as PayloadAction<string[]>).payload; 112 state = _.clone(state); 113 _.each(keys, (k) => { 114 state[k] = _.clone(state[k]) || new UIData(); 115 state[k].status = UIDataStatus.SAVING; 116 }); 117 return state; 118 } 119 case SAVE_ERROR: { 120 // TODO(tamird): https://github.com/palantir/tslint/issues/2551 121 // 122 // tslint:disable-next-line:no-use-before-declare 123 const { key: saveErrorKey, error: saveError } = (action as PayloadAction<KeyedError>).payload; 124 state = _.clone(state); 125 state[saveErrorKey] = _.clone(state[saveErrorKey]) || new UIData(); 126 state[saveErrorKey].status = UIDataStatus.SAVE_ERROR; 127 state[saveErrorKey].error = saveError; 128 return state; 129 } 130 case LOAD: { 131 const keys = (action as PayloadAction<string[]>).payload; 132 state = _.clone(state); 133 _.each(keys, (k) => { 134 state[k] = _.clone(state[k]) || new UIData(); 135 state[k].status = UIDataStatus.LOADING; 136 }); 137 return state; 138 } 139 case LOAD_ERROR: { 140 // TODO(tamird): https://github.com/palantir/tslint/issues/2551 141 // 142 // tslint:disable-next-line:no-use-before-declare 143 const { key: loadErrorKey, error: loadError } = (action as PayloadAction<KeyedError>).payload; 144 state = _.clone(state); 145 state[loadErrorKey] = _.clone(state[loadErrorKey]) || new UIData(); 146 state[loadErrorKey].status = UIDataStatus.LOAD_ERROR; 147 state[loadErrorKey].error = loadError; 148 return state; 149 } 150 default: 151 return state; 152 } 153 } 154 155 /** 156 * setUIDataKey sets the value of the given UIData key. 157 */ 158 export function setUIDataKey(key: string, value: Object): PayloadAction<KeyValue> { 159 return { 160 type: SET, 161 payload: { key, value }, 162 }; 163 } 164 165 /** 166 * errorUIData occurs when an asynchronous function related to UIData encounters 167 * an error. 168 */ 169 export function loadErrorUIData(key: string, error: Error): PayloadAction<KeyedError> { 170 return { 171 type: LOAD_ERROR, 172 payload: { key, error }, 173 }; 174 } 175 176 /** 177 * errorUIData occurs when an asynchronous function related to UIData encounters 178 * an error. 179 */ 180 export function saveErrorUIData(key: string, error: Error): PayloadAction<KeyedError> { 181 return { 182 type: SAVE_ERROR, 183 payload: { key, error }, 184 }; 185 } 186 187 /** 188 * loadUIData occurs when an asynchronous request to load UIData begins. 189 */ 190 export function beginLoadUIData(keys: string[]): PayloadAction<string[]> { 191 return { 192 type: LOAD, 193 payload: keys, 194 }; 195 } 196 197 /** 198 * saveUIData occurs when an asynchronous request for UIData begins. 199 */ 200 export function beginSaveUIData(keys: string[]): PayloadAction<string[]> { 201 return { 202 type: SAVE, 203 payload: keys, 204 }; 205 } 206 207 /** 208 * A generic KeyValue type used for convenience when calling saveUIData. 209 */ 210 export interface KeyValue { 211 key: string; 212 value: Object; 213 } 214 215 /** 216 * KeyedError associates an error with a key to use as an action payload. 217 */ 218 export interface KeyedError { 219 key: string; 220 error: Error; 221 } 222 223 // HELPER FUNCTIONS 224 225 // Returns true if the key exists and the data is valid. 226 export function isValid(state: AdminUIState, key: string) { 227 return state.uiData[key] && (state.uiData[key].status === UIDataStatus.VALID) || false; 228 } 229 230 // Returns contents of the data field if the key is valid, undefined otherwise. 231 export function getData(state: AdminUIState, key: string) { 232 return isValid(state, key) ? state.uiData[key].data : undefined; 233 } 234 235 // Returns true if the given key exists and is in the SAVING state. 236 export function isSaving(state: AdminUIState, key: string) { 237 return state.uiData[key] && (state.uiData[key].status === UIDataStatus.SAVING) || false; 238 } 239 240 // Returns true if the given key exists and is in the SAVING state. 241 export function isLoading(state: AdminUIState, key: string) { 242 return state.uiData[key] && (state.uiData[key].status === UIDataStatus.LOADING) || false; 243 } 244 245 // Returns true if the key exists and is in either the SAVING or LOADING state. 246 export function isInFlight(state: AdminUIState, key: string) { 247 return state.uiData[key] && ((state.uiData[key].status === UIDataStatus.SAVING) || (state.uiData[key].status === UIDataStatus.LOADING)) || false; 248 } 249 250 // Returns the error field if the key exists and is in the SAVE_ERROR state. 251 // Returns null otherwise. 252 export function getSaveError(state: AdminUIState, key: string): Error { 253 return (state.uiData[key] && (state.uiData[key].status === UIDataStatus.SAVE_ERROR || state.uiData[key].status === UIDataStatus.SAVING)) ? state.uiData[key].error : null; 254 } 255 256 // Returns the error field if the key exists and is in the LOAD_ERROR state. 257 // Returns null otherwise. 258 export function getLoadError(state: AdminUIState, key: string): Error { 259 return (state.uiData[key] && (state.uiData[key].status === UIDataStatus.LOAD_ERROR || state.uiData[key].status === UIDataStatus.LOADING)) ? state.uiData[key].error : null; 260 } 261 262 /** 263 * saveUIData saves the value one (or more) UIData objects to the server. After 264 * the values have been successfully persisted to the server, they are updated 265 * in the local UIDataState store. 266 */ 267 export function saveUIData(...values: KeyValue[]) { 268 return (dispatch: Dispatch<Action, AdminUIState>, getState: () => AdminUIState): Promise<void> => { 269 const state = getState(); 270 values = _.filter(values, (kv) => !isInFlight(state, kv.key)); 271 if (values.length === 0) { 272 return; 273 } 274 dispatch(beginSaveUIData(_.map(values, (kv) => kv.key))); 275 276 // Encode data for each UIData key. 277 const request = new protos.cockroach.server.serverpb.SetUIDataRequest(); 278 _.each(values, (kv) => { 279 const stringifiedValue = JSON.stringify(kv.value); 280 const buffer = new Uint8Array(protobuf.util.utf8.length(stringifiedValue)); 281 protobuf.util.utf8.write(stringifiedValue, buffer, 0); 282 request.key_values[kv.key] = buffer; 283 }); 284 285 return setUIData(request).then((_response) => { 286 // SetUIDataResponse is empty. A positive return indicates success. 287 _.each(values, (kv) => dispatch(setUIDataKey(kv.key, kv.value))); 288 }).catch((error) => { 289 // TODO(maxlang): Fix error handling more comprehensively. 290 // Tracked in #8699 291 setTimeout(() => _.each(values, (kv) => dispatch(saveErrorUIData(kv.key, error))), 1000); 292 }); 293 }; 294 } 295 296 /** 297 * loadUIData loads the values of the give UIData keys from the server. 298 */ 299 export function loadUIData(...keys: string[]) { 300 return (dispatch: Dispatch<Action, AdminUIState>, getState: () => AdminUIState): Promise<void> => { 301 const state = getState(); 302 keys = _.filter(keys, (k) => !isInFlight(state, k)); 303 if (keys.length === 0) { 304 return; 305 } 306 dispatch(beginLoadUIData(keys)); 307 308 return getUIData(new protos.cockroach.server.serverpb.GetUIDataRequest({ keys })).then((response) => { 309 // Decode data for each UIData key. 310 _.each(keys, (key) => { 311 if (_.has(response.key_values, key)) { 312 const buffer = response.key_values[key].value; 313 dispatch(setUIDataKey(key, JSON.parse(protobuf.util.utf8.read(buffer, 0, buffer.byteLength)))); 314 } else { 315 dispatch(setUIDataKey(key, undefined)); 316 } 317 }); 318 }).catch((error) => { 319 // TODO(maxlang): Fix error handling more comprehensively. 320 // Tracked in #8699 321 setTimeout(() => _.each(keys, (key) => dispatch(loadErrorUIData(key, error))), 1000); 322 }); 323 }; 324 }