Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/app-framework/redux-hooks.ts
1503 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
8
**IMPORTANT:** TYPED REDUX HOOKS -- If you use
9
10
useTypedRedux('name' | {project_id:'the project id'}, 'one field')
11
12
then you will get good guaranteed typing (unless, of course, the global store
13
hasn't been converted to typescript yet!). If you use plain useRedux, you
14
get a dangerous "any" type out!
15
16
---
17
18
Hook for getting anything from our global redux store, and this should
19
also work fine with computed properties.
20
21
Use it is as follows:
22
23
With a named store, such as "projects", "account", "page", etc.:
24
25
useRedux(['name-of-store', 'path', 'in', 'store'])
26
27
With a specific project:
28
29
useRedux(['path', 'in', 'project store'], 'project-id')
30
31
Or with an editor in a project:
32
33
useRedux(['path', 'in', 'project store'], 'project-id', 'path')
34
35
If you don't know the name of the store initially, you can use a name of '',
36
and you'll always get back undefined.
37
38
useRedux(['', 'other', 'stuff']) === undefined
39
*/
40
41
import { is_valid_uuid_string } from "@cocalc/util/misc";
42
import { redux, ProjectActions, ProjectStore } from "../app-framework";
43
import { ProjectStoreState } from "../project_store";
44
import React, { useEffect, useRef } from "react";
45
import * as types from "./actions-and-stores";
46
47
export function useReduxNamedStore(path: string[]) {
48
const [value, set_value] = React.useState(() => {
49
return redux.getStore(path[0])?.getIn(path.slice(1) as any) as any;
50
});
51
52
useEffect(() => {
53
if (path[0] == "") {
54
// Special case -- we allow passing "" for the name of the store and get out undefined.
55
// This is useful when using the useRedux hook but when the name of the store isn't known initially.
56
return undefined;
57
}
58
const store = redux.getStore(path[0]);
59
if (store == null) {
60
// This could happen if some input is invalid, e.g., trying to create one of these
61
// redux hooks with an invalid project_id. There will be other warnings in the logs
62
// about that. It's better at this point to warn once in the logs, rather than completely
63
// crash the client.
64
console.warn(`store "${path[0]}" must exist; path=`, path);
65
return undefined;
66
}
67
const subpath = path.slice(1);
68
let last_value = value;
69
const f = () => {
70
if (!f.is_mounted) {
71
// CRITICAL: even after removing the change listener, sometimes f gets called;
72
// I don't know why EventEmitter has those semantics, but it definitely does.
73
// That's why we *also* maintain this is_mounted flag.
74
return;
75
}
76
const new_value = store.getIn(subpath as any);
77
if (last_value !== new_value) {
78
/*
79
console.log("useReduxNamedStore change ", {
80
name: path[0],
81
path: JSON.stringify(path),
82
new_value,
83
last_value,
84
});
85
*/
86
last_value = new_value;
87
set_value(new_value);
88
}
89
};
90
f.is_mounted = true;
91
store.on("change", f);
92
f();
93
return () => {
94
f.is_mounted = false;
95
store.removeListener("change", f);
96
};
97
}, path);
98
99
return value;
100
}
101
102
function useReduxProjectStore(path: string[], project_id: string) {
103
const [value, set_value] = React.useState(() =>
104
redux
105
.getProjectStore(project_id)
106
.getIn(path as [string, string, string, string, string]),
107
);
108
109
useEffect(() => {
110
const store = redux.getProjectStore(project_id);
111
let last_value = value;
112
const f = (obj) => {
113
if (obj == null || !f.is_mounted) return; // see comment for useReduxNamedStore
114
const new_value = obj.getIn(path);
115
if (last_value !== new_value) {
116
/*
117
console.log("useReduxProjectStore change ", {
118
path: JSON.stringify(path),
119
new_value,
120
last_value,
121
});
122
*/
123
last_value = new_value;
124
set_value(new_value);
125
}
126
};
127
f.is_mounted = true;
128
store.on("change", f);
129
f(store);
130
return () => {
131
f.is_mounted = false;
132
store.removeListener("change", f);
133
};
134
}, [...path, project_id]);
135
136
return value;
137
}
138
139
function useReduxEditorStore(
140
path: string[],
141
project_id: string,
142
filename: string,
143
) {
144
const [value, set_value] = React.useState(() =>
145
// the editor itself might not be defined hence the ?. below:
146
redux
147
.getEditorStore(project_id, filename)
148
?.getIn(path as [string, string, string, string, string]),
149
);
150
151
useEffect(() => {
152
let store = redux.getEditorStore(project_id, filename);
153
let last_value = value;
154
const f = (obj) => {
155
if (obj == null || !f.is_mounted) return; // see comment for useReduxNamedStore
156
const new_value = obj.getIn(path);
157
if (last_value !== new_value) {
158
last_value = new_value;
159
set_value(new_value);
160
}
161
};
162
f.is_mounted = true;
163
f(store);
164
if (store != null) {
165
store.on("change", f);
166
} else {
167
/* This code is extra complicated since we account for the case
168
when getEditorStore is undefined then becomes defined.
169
Very rarely there are components that useRedux and somehow
170
manage to do so before the editor store gets created.
171
NOTE: I might be able to solve this same problem with
172
simpler code with useAsyncEffect...
173
*/
174
const g = () => {
175
if (!f.is_mounted) {
176
unsubscribe();
177
return;
178
}
179
store = redux.getEditorStore(project_id, filename);
180
if (store != null) {
181
unsubscribe();
182
f(store); // may have missed an initial change
183
store.on("change", f);
184
}
185
};
186
const unsubscribe = redux.reduxStore.subscribe(g);
187
}
188
189
return () => {
190
f.is_mounted = false;
191
store?.removeListener("change", f);
192
};
193
}, [...path, project_id, filename]);
194
195
return value;
196
}
197
198
export interface StoreStates {
199
account: types.AccountState;
200
"admin-site-licenses": types.SiteLicensesState;
201
"admin-users": types.AdminUsersState;
202
billing: types.BillingState;
203
compute_images: types.ComputeImagesState;
204
customize: types.CustomizeState;
205
file_use: types.FileUseState;
206
mentions: types.MentionsState;
207
messages: types.MessagesState;
208
page: types.PageState;
209
projects: types.ProjectsState;
210
users: types.UsersState;
211
news: types.NewsState;
212
}
213
214
export function useTypedRedux<
215
T extends keyof StoreStates,
216
S extends keyof StoreStates[T],
217
>(store: T, field: S): StoreStates[T][S];
218
219
export function useTypedRedux<S extends keyof ProjectStoreState>(
220
project_id: { project_id: string },
221
field: S,
222
): ProjectStoreState[S];
223
224
export function useTypedRedux(
225
a: keyof StoreStates | { project_id: string },
226
field: string,
227
) {
228
if (typeof a == "string") {
229
return useRedux(a, field);
230
}
231
return useRedux(a.project_id, field);
232
}
233
234
export function useEditorRedux<State>(editor: {
235
project_id: string;
236
path: string;
237
}) {
238
function f<S extends keyof State>(field: S): State[S] {
239
return useReduxEditorStore(
240
[field as string],
241
editor.project_id,
242
editor.path,
243
) as any;
244
}
245
return f;
246
}
247
248
/*
249
export function useEditorRedux<State, S extends keyof State>(editor: {
250
project_id: string;
251
path: string;
252
}): State[S] {
253
return useReduxEditorStore(
254
[S as string],
255
editor.project_id,
256
editor.path
257
) as any;
258
}
259
*/
260
/*
261
export function useEditorRedux(
262
editor: { project_id: string; path: string },
263
field
264
): any {
265
return useReduxEditorStore(
266
[field as string],
267
editor.project_id,
268
editor.path
269
) as any;
270
}
271
*/
272
273
export function useRedux(
274
path: string | string[],
275
project_id?: string,
276
filename?: string,
277
) {
278
if (typeof path == "string") {
279
// good typed version!! -- path specifies store
280
if (typeof project_id != "string" || typeof filename != "undefined") {
281
throw Error(
282
"if first argument of useRedux is a string then second argument must also be and no other arguments can be specified",
283
);
284
}
285
if (is_valid_uuid_string(path)) {
286
return useRedux([project_id], path);
287
} else {
288
return useRedux([path, project_id]);
289
}
290
}
291
if (project_id == null) {
292
return useReduxNamedStore(path);
293
}
294
if (filename == null) {
295
if (!is_valid_uuid_string(project_id)) {
296
// this is used a lot by frame-tree editors right now.
297
return useReduxNamedStore([project_id].concat(path));
298
} else {
299
return useReduxProjectStore(path, project_id);
300
}
301
}
302
return useReduxEditorStore(path, project_id, filename);
303
}
304
305
/*
306
Hook to get the actions associated to a named actions/store,
307
a project, or an editor. If the first argument is a uuid,
308
then it's the project actions or editor actions; otherwise,
309
it's one of the other named actions or undefined.
310
*/
311
312
export function useActions(name: "account"): types.AccountActions;
313
export function useActions(
314
name: "admin-site-licenses",
315
): types.SiteLicensesActions;
316
export function useActions(name: "admin-users"): types.AdminUsersActions;
317
export function useActions(name: "billing"): types.BillingActions;
318
export function useActions(name: "file_use"): types.FileUseActions;
319
export function useActions(name: "mentions"): types.MentionsActions;
320
export function useActions(name: "messages"): types.MessagesActions;
321
export function useActions(name: "page"): types.PageActions;
322
export function useActions(name: "projects"): types.ProjectsActions;
323
export function useActions(name: "users"): types.UsersActions;
324
export function useActions(name: "news"): types.NewsActions;
325
export function useActions(name: "customize"): types.CustomizeActions;
326
327
// If it is none of the explicitly named ones... it's a project or just some general actions.
328
// That said *always* use {project_id} as below to get the actions for a project, so you
329
// get proper typing.
330
export function useActions(x: string): any;
331
332
export function useActions<T>(x: { name: string }): T;
333
334
// Return type includes undefined because the actions for a project *do* get
335
// destroyed when closing a project, and rendering can still happen during this
336
// time, so client code must account for this.
337
export function useActions(x: {
338
project_id: string;
339
}): ProjectActions | undefined;
340
341
// Or an editor actions (any for now)
342
export function useActions(x: string, path: string): any;
343
344
export function useActions(x, path?: string) {
345
return React.useMemo(() => {
346
let actions;
347
if (path != null) {
348
actions = redux.getEditorActions(x, path);
349
} else {
350
if (x?.name != null) {
351
actions = redux.getActions(x.name);
352
} else if (x?.project_id != null) {
353
// return here to avoid null check below; it can be null
354
return redux.getProjectActions(x.project_id);
355
} else if (is_valid_uuid_string(x)) {
356
// return here to avoid null check below; it can be null
357
return redux.getProjectActions(x);
358
} else {
359
actions = redux.getActions(x);
360
}
361
}
362
if (actions == null) {
363
throw Error(`BUG: actions for "${path}" must be defined but is not`);
364
}
365
return actions;
366
}, [x, path]);
367
}
368
369
// WARNING: I tried to define this Stores interface
370
// in actions-and-stores.ts but it did NOT work. All
371
// the types just became any or didn't match. Don't
372
// move this unless you also fully test it!!
373
import { Store } from "@cocalc/util/redux/Store";
374
import { isEqual } from "lodash";
375
export interface Stores {
376
account: types.AccountStore;
377
"admin-site-licenses": types.SiteLicensesStore;
378
"admin-users": types.AdminUsersStore;
379
billing: types.BillingStore;
380
compute_images: types.ComputeImagesStore;
381
customize: types.CustomizeStore;
382
file_use: types.FileUseStore;
383
mentions: types.MentionsStore;
384
messages: types.MessagesStore;
385
page: types.PageStore;
386
projects: types.ProjectsStore;
387
users: types.UsersStore;
388
news: types.NewsStore;
389
}
390
391
// If it is none of the explicitly named ones... it's a project.
392
//export function useStore(name: "projects"): types.ProjectsStore;
393
export function useStore<T extends keyof Stores>(name: T): Stores[T];
394
export function useStore(x: { project_id: string }): ProjectStore;
395
export function useStore<T>(x: { name: string }): T;
396
// Or an editor store (any for now):
397
//export function useStore(project_id: string, path: string): Store<any>;
398
export function useStore(x): any {
399
return React.useMemo(() => {
400
let store;
401
if (x?.project_id != null) {
402
store = redux.getProjectStore(x.project_id);
403
} else if (x?.name != null) {
404
store = redux.getStore(x.name);
405
} else if (is_valid_uuid_string(x)) {
406
store = redux.getProjectStore(x);
407
} else {
408
store = redux.getStore(x);
409
}
410
if (store == null) {
411
throw Error("store must be defined");
412
}
413
return store;
414
}, [x]) as Store<any>;
415
}
416
417
// Debug which props changed in a component
418
export function useTraceUpdate(props) {
419
const prev = useRef(props);
420
useEffect(() => {
421
const changedProps = Object.entries(props).reduce((ps, [k, v]) => {
422
if (!isEqual(prev.current[k], v)) {
423
ps[k] = [prev.current[k], v];
424
}
425
return ps;
426
}, {});
427
if (Object.keys(changedProps).length > 0) {
428
console.log("Changed props:", changedProps);
429
}
430
prev.current = props;
431
});
432
}
433
434