CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/app-framework/redux-hooks.ts
Views: 687
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
import useDeepCompareEffect from "use-deep-compare-effect";
47
48
export function useReduxNamedStore(path: string[]) {
49
const [value, set_value] = React.useState(() => {
50
return redux.getStore(path[0])?.getIn(path.slice(1) as any) as any;
51
});
52
53
useDeepCompareEffect(() => {
54
if (path[0] == "") {
55
// Special case -- we allow passing "" for the name of the store and get out undefined.
56
// This is useful when using the useRedux hook but when the name of the store isn't known initially.
57
return undefined;
58
}
59
const store = redux.getStore(path[0]);
60
if (store == null) {
61
// This could happen if some input is invalid, e.g., trying to create one of these
62
// redux hooks with an invalid project_id. There will be other warnings in the logs
63
// about that. It's better at this point to warn once in the logs, rather than completely
64
// crash the client.
65
console.warn(`store "${path[0]}" must exist; path=`, path);
66
return undefined;
67
}
68
const subpath = path.slice(1);
69
let last_value = value;
70
const f = () => {
71
if (!f.is_mounted) {
72
// CRITICAL: even after removing the change listener, sometimes f gets called;
73
// I don't know why EventEmitter has those semantics, but it definitely does.
74
// That's why we *also* maintain this is_mounted flag.
75
return;
76
}
77
const new_value = store.getIn(subpath as any);
78
if (last_value !== new_value) {
79
/*
80
console.log("useReduxNamedStore change ", {
81
name: path[0],
82
path: JSON.stringify(path),
83
new_value,
84
last_value,
85
});
86
*/
87
last_value = new_value;
88
set_value(new_value);
89
}
90
};
91
f.is_mounted = true;
92
store.on("change", f);
93
f();
94
return () => {
95
f.is_mounted = false;
96
store.removeListener("change", f);
97
};
98
}, [path]);
99
100
return value;
101
}
102
103
function useReduxProjectStore(path: string[], project_id: string) {
104
const [value, set_value] = React.useState(() =>
105
redux
106
.getProjectStore(project_id)
107
.getIn(path as [string, string, string, string, string]),
108
);
109
110
useDeepCompareEffect(() => {
111
const store = redux.getProjectStore(project_id);
112
let last_value = value;
113
const f = (obj) => {
114
if (obj == null || !f.is_mounted) return; // see comment for useReduxNamedStore
115
const new_value = obj.getIn(path);
116
if (last_value !== new_value) {
117
/*
118
console.log("useReduxProjectStore change ", {
119
path: JSON.stringify(path),
120
new_value,
121
last_value,
122
});
123
*/
124
last_value = new_value;
125
set_value(new_value);
126
}
127
};
128
f.is_mounted = true;
129
store.on("change", f);
130
f(store);
131
return () => {
132
f.is_mounted = false;
133
store.removeListener("change", f);
134
};
135
}, [path, project_id]);
136
137
return value;
138
}
139
140
function useReduxEditorStore(
141
path: string[],
142
project_id: string,
143
filename: string,
144
) {
145
const [value, set_value] = React.useState(() =>
146
// the editor itself might not be defined hence the ?. below:
147
redux
148
.getEditorStore(project_id, filename)
149
?.getIn(path as [string, string, string, string, string]),
150
);
151
152
useDeepCompareEffect(() => {
153
let store = redux.getEditorStore(project_id, filename);
154
let last_value = value;
155
const f = (obj) => {
156
if (obj == null || !f.is_mounted) return; // see comment for useReduxNamedStore
157
const new_value = obj.getIn(path);
158
if (last_value !== new_value) {
159
last_value = new_value;
160
set_value(new_value);
161
}
162
};
163
f.is_mounted = true;
164
f(store);
165
if (store != null) {
166
store.on("change", f);
167
} else {
168
/* This code is extra complicated since we account for the case
169
when getEditorStore is undefined then becomes defined.
170
Very rarely there are components that useRedux and somehow
171
manage to do so before the editor store gets created.
172
NOTE: I might be able to solve this same problem with
173
simpler code with useAsyncEffect...
174
*/
175
const g = () => {
176
if (!f.is_mounted) {
177
unsubscribe();
178
return;
179
}
180
store = redux.getEditorStore(project_id, filename);
181
if (store != null) {
182
unsubscribe();
183
f(store); // may have missed an initial change
184
store.on("change", f);
185
}
186
};
187
const unsubscribe = redux.reduxStore.subscribe(g);
188
}
189
190
return () => {
191
f.is_mounted = false;
192
store?.removeListener("change", f);
193
};
194
}, [path, project_id, filename]);
195
196
return value;
197
}
198
199
export interface StoreStates {
200
account: types.AccountState;
201
"admin-site-licenses": types.SiteLicensesState;
202
"admin-users": types.AdminUsersState;
203
billing: types.BillingState;
204
compute_images: types.ComputeImagesState;
205
customize: types.CustomizeState;
206
file_use: types.FileUseState;
207
mentions: types.MentionsState;
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
// TODO: very incomplete -- might not even work.
313
/*
314
export interface ActionsTypes {
315
account: types.AccountActions;
316
"admin-site-licenses": types.SiteLicensesActions;
317
"admin-users": types.AdminUsersActions;
318
billing: types.BillingActions;
319
compute_images: types.ComputeImagesActions;
320
customize: types.CustomizeActions;
321
file_use: types.FileUseActions;
322
mentions: types.MentionsActions;
323
page: types.PageActions;
324
projects: types.ProjectsActions;
325
users: types.UsersActions;
326
}
327
328
*/
329
330
export function useActions(name: "account"): types.AccountActions;
331
export function useActions(
332
name: "admin-site-licenses",
333
): types.SiteLicensesActions;
334
export function useActions(name: "admin-users"): types.AdminUsersActions;
335
export function useActions(name: "billing"): types.BillingActions;
336
export function useActions(name: "file_use"): types.FileUseActions;
337
export function useActions(name: "mentions"): types.MentionsActions;
338
export function useActions(name: "page"): types.PageActions;
339
export function useActions(name: "projects"): types.ProjectsActions;
340
export function useActions(name: "users"): types.UsersActions;
341
export function useActions(name: "news"): types.NewsActions;
342
export function useActions(name: "customize"): types.CustomizeActions;
343
344
// If it is none of the explicitly named ones... it's a project or just some general actions.
345
// That said *always* use {project_id} as below to get the actions for a project, so you
346
// get proper typing.
347
export function useActions(x: string): any;
348
349
export function useActions<T>(x: { name: string }): T;
350
351
// Return type includes undefined because the actions for a project *do* get
352
// destroyed when closing a project, and rendering can still happen during this
353
// time, so client code must account for this.
354
export function useActions(x: {
355
project_id: string;
356
}): ProjectActions | undefined;
357
358
// Or an editor actions (any for now)
359
export function useActions(x: string, path: string): any;
360
361
export function useActions(x, path?: string) {
362
return React.useMemo(() => {
363
let actions;
364
if (path != null) {
365
actions = redux.getEditorActions(x, path);
366
} else {
367
if (x?.name != null) {
368
actions = redux.getActions(x.name);
369
} else if (x?.project_id != null) {
370
// return here to avoid null check below; it can be null
371
return redux.getProjectActions(x.project_id);
372
} else if (is_valid_uuid_string(x)) {
373
// return here to avoid null check below; it can be null
374
return redux.getProjectActions(x);
375
} else {
376
actions = redux.getActions(x);
377
}
378
}
379
if (actions == null) {
380
throw Error(`BUG: actions for "${path}" must be defined but is not`);
381
}
382
return actions;
383
}, [x, path]);
384
}
385
386
// WARNING: I tried to define this Stores interface
387
// in actions-and-stores.ts but it did NOT work. All
388
// the types just became any or didn't match. Don't
389
// move this unless you also fully test it!!
390
import { Store } from "@cocalc/util/redux/Store";
391
import { isEqual } from "lodash";
392
export interface Stores {
393
account: types.AccountStore;
394
"admin-site-licenses": types.SiteLicensesStore;
395
"admin-users": types.AdminUsersStore;
396
billing: types.BillingStore;
397
compute_images: types.ComputeImagesStore;
398
customize: types.CustomizeStore;
399
file_use: types.FileUseStore;
400
mentions: types.MentionsStore;
401
page: types.PageStore;
402
projects: types.ProjectsStore;
403
users: types.UsersStore;
404
news: types.NewsStore;
405
}
406
407
// If it is none of the explicitly named ones... it's a project.
408
//export function useStore(name: "projects"): types.ProjectsStore;
409
export function useStore<T extends keyof Stores>(name: T): Stores[T];
410
export function useStore(x: { project_id: string }): ProjectStore;
411
export function useStore<T>(x: { name: string }): T;
412
// Or an editor store (any for now):
413
//export function useStore(project_id: string, path: string): Store<any>;
414
export function useStore(x): any {
415
return React.useMemo(() => {
416
let store;
417
if (x?.project_id != null) {
418
store = redux.getProjectStore(x.project_id);
419
} else if (x?.name != null) {
420
store = redux.getStore(x.name);
421
} else if (is_valid_uuid_string(x)) {
422
store = redux.getProjectStore(x);
423
} else {
424
store = redux.getStore(x);
425
}
426
if (store == null) {
427
throw Error("store must be defined");
428
}
429
return store;
430
}, [x]) as Store<any>;
431
}
432
433
// Debug which props changed in a component
434
export function useTraceUpdate(props) {
435
const prev = useRef(props);
436
useEffect(() => {
437
const changedProps = Object.entries(props).reduce((ps, [k, v]) => {
438
if (!isEqual(prev.current[k], v)) {
439
ps[k] = [prev.current[k], v];
440
}
441
return ps;
442
}, {});
443
if (Object.keys(changedProps).length > 0) {
444
console.log("Changed props:", changedProps);
445
}
446
prev.current = props;
447
});
448
}
449
450