gramene-search 2.1.11 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/launch.json +11 -0
- package/.claude/settings.local.json +6 -1
- package/.env.example +6 -0
- package/.parcel-cache/13f2d5707e7af45c-RequestGraph +0 -0
- package/.parcel-cache/5ae0570a78c0dba3-AssetGraph +0 -0
- package/.parcel-cache/9ac092379278e465-BundleGraph +0 -0
- package/.parcel-cache/data.mdb +0 -0
- package/.parcel-cache/lock.mdb +0 -0
- package/.parcel-cache/snapshot-13f2d5707e7af45c.txt +2 -2
- package/dist/index.js +2065 -154
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
- package/src/bundles/api.js +10 -4
- package/src/bundles/exprViz.js +97 -1
- package/src/bundles/index.js +4 -1
- package/src/bundles/ontologyEnrichment.js +14 -1
- package/src/bundles/savedViews.js +335 -0
- package/src/bundles/uiViewState.js +174 -0
- package/src/bundles/viewSnapshot.js +313 -0
- package/src/bundles/views.js +22 -0
- package/src/components/Auth.js +23 -3
- package/src/components/SaveView.js +157 -0
- package/src/components/exprViz/ExprVizView.js +16 -11
- package/src/components/exprViz/ParallelCoordsPlot.js +15 -0
- package/src/components/results/GeneList.js +45 -15
- package/src/components/results/OntologyEnrichment.js +13 -6
- package/src/components/results/details/BAR.js +148 -0
- package/src/components/results/details/Expression.js +43 -11
- package/src/components/results/details/Homology.js +170 -32
- package/src/components/results/details/Pathways.js +4 -2
- package/src/components/results/details/Sequences.js +24 -8
- package/src/demo.js +22 -17
- package/src/index.js +2 -1
- package/src/suppressDevWarnings.js +13 -0
- package/src/utils/bootView.js +38 -0
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gramene-search",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "search wrapper for gramene",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
|
+
"alias": {
|
|
7
|
+
"react": "./node_modules/react",
|
|
8
|
+
"react-dom": "./node_modules/react-dom"
|
|
9
|
+
},
|
|
6
10
|
"targets": {
|
|
7
11
|
"main": {
|
|
8
12
|
"source": "src/index.js"
|
|
@@ -42,7 +46,6 @@
|
|
|
42
46
|
"flat-to-nested": "^1.1.1",
|
|
43
47
|
"gramene-bins-client": "^2.3.3",
|
|
44
48
|
"gramene-dbxrefs": "^3.0.15",
|
|
45
|
-
"gramene-efp-browser": "^1.0.11",
|
|
46
49
|
"gramene-genetree-vis": "^4.2.11",
|
|
47
50
|
"gramene-mdview": "^2.0.8",
|
|
48
51
|
"gramene-search-vis": "^4.2.5",
|
package/src/bundles/api.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { createAsyncResourceBundle, createSelector } from 'redux-bundler'
|
|
2
2
|
import _ from 'lodash'
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
// Import the leaf modules directly (not the package index) so we don't
|
|
4
|
+
// trigger gramene-bins-client/index.js and gramene-trees-client/index.js,
|
|
5
|
+
// each of which eagerly require()s ./src/promise -> gramene-search-client
|
|
6
|
+
// -> grameneSwaggerClient.js, whose top-level IIFE fires a GET to /swagger
|
|
7
|
+
// at module load. We never use the promise path, so the fetch was pure
|
|
8
|
+
// waste — two requests per page load before this change.
|
|
9
|
+
import bins from "gramene-bins-client/src/bins";
|
|
10
|
+
import taxonomy from "gramene-trees-client/src/taxonomy";
|
|
5
11
|
import {build} from "gramene-taxonomy-with-genomes";
|
|
6
12
|
|
|
7
13
|
const facets = [
|
|
@@ -458,8 +464,8 @@ const grameneTaxDist = {
|
|
|
458
464
|
grameneTaxonomy[tid].name = map.display_name;
|
|
459
465
|
});
|
|
460
466
|
const binnedResults = formatFacetCountsForViz(grameneSearch.facet_counts.facet_fields.fixed_1000__bin);
|
|
461
|
-
let speciesTree =
|
|
462
|
-
let binMapper =
|
|
467
|
+
let speciesTree = taxonomy.tree(Object.values(grameneTaxonomy));
|
|
468
|
+
let binMapper = bins(grameneMaps);
|
|
463
469
|
let taxDist = build(binMapper, speciesTree);
|
|
464
470
|
taxDist.setBinType('fixed',1000);
|
|
465
471
|
taxDist.setResults(binnedResults);
|
package/src/bundles/exprViz.js
CHANGED
|
@@ -6,8 +6,12 @@ import { createSelector } from 'redux-bundler';
|
|
|
6
6
|
// activeTaxon: <taxon_id|null>,
|
|
7
7
|
// byTaxon: {
|
|
8
8
|
// [taxon_id]: {
|
|
9
|
-
// selectedFields: [<solr field name>...],
|
|
9
|
+
// selectedFields: [<solr field name>...], // order = column/axis order
|
|
10
10
|
// fieldsModalOpen: <bool>,
|
|
11
|
+
// vizMode: 'heatmap'|'parallel', // persisted view config
|
|
12
|
+
// scale: 'linear'|'log', // persisted view config
|
|
13
|
+
// brushes: { [field]: [lo, hi] }, // persisted parallel-coords brushes
|
|
14
|
+
// pendingLoad: <bool>, // transient: a restored view wants a re-fetch
|
|
11
15
|
// fetch: { status, offset, total, signature, requestId, error },
|
|
12
16
|
// rows: [<doc>...]
|
|
13
17
|
// }
|
|
@@ -54,6 +58,10 @@ const exprViz = {
|
|
|
54
58
|
return {
|
|
55
59
|
selectedFields: [],
|
|
56
60
|
fieldsModalOpen: false,
|
|
61
|
+
vizMode: 'heatmap',
|
|
62
|
+
scale: 'linear',
|
|
63
|
+
brushes: {},
|
|
64
|
+
pendingLoad: false,
|
|
57
65
|
fetch: { status: 'idle', offset: 0, total: 0, signature: null, requestId: 0, error: null },
|
|
58
66
|
rows: [],
|
|
59
67
|
availableAttrs: null,
|
|
@@ -96,12 +104,58 @@ const exprViz = {
|
|
|
96
104
|
[payload.taxon]: {
|
|
97
105
|
...t,
|
|
98
106
|
selectedFields: payload.fields,
|
|
107
|
+
// A new field selection invalidates any prior brushes (they
|
|
108
|
+
// reference axes that may no longer exist) and the loaded rows.
|
|
109
|
+
brushes: {},
|
|
99
110
|
rows: [],
|
|
100
111
|
fetch: { status: 'idle', offset: 0, total: 0, signature: null, requestId: 0, error: null }
|
|
101
112
|
}
|
|
102
113
|
}
|
|
103
114
|
};
|
|
104
115
|
}
|
|
116
|
+
case 'EXPRVIZ_VIZMODE_SET': {
|
|
117
|
+
const t = ensureTaxon(state, payload.taxon);
|
|
118
|
+
return {
|
|
119
|
+
...state,
|
|
120
|
+
byTaxon: { ...state.byTaxon, [payload.taxon]: { ...t, vizMode: payload.vizMode } }
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
case 'EXPRVIZ_SCALE_SET': {
|
|
124
|
+
const t = ensureTaxon(state, payload.taxon);
|
|
125
|
+
return {
|
|
126
|
+
...state,
|
|
127
|
+
byTaxon: { ...state.byTaxon, [payload.taxon]: { ...t, scale: payload.scale } }
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
case 'EXPRVIZ_BRUSHES_SET': {
|
|
131
|
+
const t = ensureTaxon(state, payload.taxon);
|
|
132
|
+
return {
|
|
133
|
+
...state,
|
|
134
|
+
byTaxon: { ...state.byTaxon, [payload.taxon]: { ...t, brushes: payload.brushes || {} } }
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
case 'EXPRVIZ_RESTORED': {
|
|
138
|
+
// Re-apply persisted view config from a saved-view snapshot. Async
|
|
139
|
+
// data (rows) is NOT in the snapshot — instead each taxon with a
|
|
140
|
+
// field selection is flagged pendingLoad so reactExprVizRestoreLoad
|
|
141
|
+
// re-fetches it once the search context is ready.
|
|
142
|
+
const byTaxon = { ...state.byTaxon };
|
|
143
|
+
const cfg = payload.byTaxon || {};
|
|
144
|
+
for (const tid of Object.keys(cfg)) {
|
|
145
|
+
const base = ensureTaxon(state, tid);
|
|
146
|
+
const c = cfg[tid] || {};
|
|
147
|
+
const selectedFields = Array.isArray(c.selectedFields) ? c.selectedFields : [];
|
|
148
|
+
byTaxon[tid] = {
|
|
149
|
+
...base,
|
|
150
|
+
selectedFields,
|
|
151
|
+
vizMode: c.vizMode || 'heatmap',
|
|
152
|
+
scale: c.scale || 'linear',
|
|
153
|
+
brushes: c.brushes || {},
|
|
154
|
+
pendingLoad: selectedFields.length > 0
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return { ...state, activeTaxon: payload.activeTaxon || state.activeTaxon, byTaxon };
|
|
158
|
+
}
|
|
105
159
|
case 'EXPRVIZ_FIELDS_REORDERED': {
|
|
106
160
|
const t = state.byTaxon[payload.taxon];
|
|
107
161
|
if (!t) return state;
|
|
@@ -128,6 +182,7 @@ const exprViz = {
|
|
|
128
182
|
...state.byTaxon,
|
|
129
183
|
[payload.taxon]: {
|
|
130
184
|
...t,
|
|
185
|
+
pendingLoad: false,
|
|
131
186
|
fetch: { ...t.fetch, status: 'loading', signature: payload.signature, requestId: payload.requestId, error: null }
|
|
132
187
|
}
|
|
133
188
|
}
|
|
@@ -309,6 +364,24 @@ const exprViz = {
|
|
|
309
364
|
doReorderExprVizFields: (taxon, fields) => ({ dispatch }) =>
|
|
310
365
|
dispatch({ type: 'EXPRVIZ_FIELDS_REORDERED', payload: { taxon, fields } }),
|
|
311
366
|
|
|
367
|
+
doSetExprVizVizMode: (taxon, vizMode) => ({ dispatch }) =>
|
|
368
|
+
dispatch({ type: 'EXPRVIZ_VIZMODE_SET', payload: { taxon, vizMode } }),
|
|
369
|
+
|
|
370
|
+
doSetExprVizScale: (taxon, scale) => ({ dispatch }) =>
|
|
371
|
+
dispatch({ type: 'EXPRVIZ_SCALE_SET', payload: { taxon, scale } }),
|
|
372
|
+
|
|
373
|
+
doSetExprVizBrushes: (taxon, brushes) => ({ dispatch }) =>
|
|
374
|
+
dispatch({ type: 'EXPRVIZ_BRUSHES_SET', payload: { taxon, brushes } }),
|
|
375
|
+
|
|
376
|
+
// Re-apply persisted exprViz view config from a saved-view snapshot. Called
|
|
377
|
+
// by viewSnapshot's doApplyViewSnapshot after filters/views have been
|
|
378
|
+
// restored, so the re-fetch (driven by reactExprVizRestoreLoad) sees the
|
|
379
|
+
// restored query context.
|
|
380
|
+
doApplyExprVizSnapshot: snap => ({ dispatch }) => {
|
|
381
|
+
if (!snap || typeof snap !== 'object') return;
|
|
382
|
+
dispatch({ type: 'EXPRVIZ_RESTORED', payload: { activeTaxon: snap.activeTaxon || null, byTaxon: snap.byTaxon || {} } });
|
|
383
|
+
},
|
|
384
|
+
|
|
312
385
|
doFetchExprVizData: taxon => ({ dispatch, store }) => {
|
|
313
386
|
const ev = store.selectExprViz();
|
|
314
387
|
const t = ev.byTaxon[taxon];
|
|
@@ -413,6 +486,29 @@ const exprViz = {
|
|
|
413
486
|
}
|
|
414
487
|
),
|
|
415
488
|
|
|
489
|
+
// After a saved view is applied, EXPRVIZ_RESTORED flags each taxon that had
|
|
490
|
+
// a field selection with pendingLoad. Once the search context is live (not
|
|
491
|
+
// 'init') and the view is on, re-fetch that taxon's rows. Fires one taxon at
|
|
492
|
+
// a time; EXPRVIZ_FETCH_STARTED clears the flag so this won't loop. Robust to
|
|
493
|
+
// a GRAMENE_SEARCH_CLEARED that wipes rows mid-restore — the flag persists.
|
|
494
|
+
reactExprVizRestoreLoad: createSelector(
|
|
495
|
+
'selectExprViz',
|
|
496
|
+
'selectGrameneFiltersStatus',
|
|
497
|
+
'selectGrameneViews',
|
|
498
|
+
(ev, filtersStatus, views) => {
|
|
499
|
+
if (!ev || filtersStatus === 'init') return;
|
|
500
|
+
const onView = views && views.options && views.options.find(v => v.id === 'exprViz');
|
|
501
|
+
if (!onView || onView.show !== 'on') return;
|
|
502
|
+
for (const tid of Object.keys(ev.byTaxon || {})) {
|
|
503
|
+
const t = ev.byTaxon[tid];
|
|
504
|
+
if (t && t.pendingLoad && t.selectedFields.length > 0
|
|
505
|
+
&& t.rows.length === 0 && t.fetch.status !== 'loading') {
|
|
506
|
+
return { actionCreator: 'doFetchExprVizData', args: [tid] };
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
),
|
|
511
|
+
|
|
416
512
|
selectExprViz: state => state.exprViz,
|
|
417
513
|
selectExprVizPivot: state => state.exprViz.pivot,
|
|
418
514
|
selectExprVizActiveTaxon: state => state.exprViz.activeTaxon,
|
package/src/bundles/index.js
CHANGED
|
@@ -8,5 +8,8 @@ import exporterBundle from './exporter'
|
|
|
8
8
|
import ontologiesBundle from './ontologies'
|
|
9
9
|
import exprVizBundle from './exprViz'
|
|
10
10
|
import ontologyEnrichmentBundle from './ontologyEnrichment'
|
|
11
|
+
import uiViewStateBundle from './uiViewState'
|
|
12
|
+
import viewSnapshotBundle from './viewSnapshot'
|
|
13
|
+
import savedViewsBundle from './savedViews'
|
|
11
14
|
|
|
12
|
-
export default [...apiBundles, docsBundle, filterBundle, viewsBundle, genomesBundle, fieldCatalogBundle, exporterBundle, ontologiesBundle, exprVizBundle, ontologyEnrichmentBundle];
|
|
15
|
+
export default [...apiBundles, docsBundle, filterBundle, viewsBundle, genomesBundle, fieldCatalogBundle, exporterBundle, ontologiesBundle, exprVizBundle, ontologyEnrichmentBundle, uiViewStateBundle, viewSnapshotBundle, savedViewsBundle];
|
|
@@ -70,7 +70,10 @@ const ontologyEnrichment = {
|
|
|
70
70
|
maxGSSize: 500,
|
|
71
71
|
mostSpecific: false,
|
|
72
72
|
ontology: 'all',
|
|
73
|
-
search: ''
|
|
73
|
+
search: '',
|
|
74
|
+
// Per-section table sort, keyed by ontology section id (e.g.
|
|
75
|
+
// 'GO:biological_process'): { [sectionKey]: { key, dir } }.
|
|
76
|
+
sort: {}
|
|
74
77
|
}
|
|
75
78
|
};
|
|
76
79
|
|
|
@@ -196,6 +199,16 @@ const ontologyEnrichment = {
|
|
|
196
199
|
doSetOntologyEnrichmentUI: patch => ({ dispatch }) =>
|
|
197
200
|
dispatch({ type: 'ONTOLOGY_ENRICHMENT_UI_SET', payload: patch }),
|
|
198
201
|
|
|
202
|
+
// Re-apply persisted view config from a saved-view snapshot. Setting the
|
|
203
|
+
// active taxon (last) lets reactOntologyEnrichmentFetch re-fetch the
|
|
204
|
+
// foreground/background facets for the restored filters — no bulk data is
|
|
205
|
+
// carried in the snapshot.
|
|
206
|
+
doApplyOntologyEnrichmentSnapshot: snap => ({ dispatch }) => {
|
|
207
|
+
if (!snap || typeof snap !== 'object') return;
|
|
208
|
+
if (snap.ui) dispatch({ type: 'ONTOLOGY_ENRICHMENT_UI_SET', payload: snap.ui });
|
|
209
|
+
if (snap.activeTaxon) dispatch({ type: 'ONTOLOGY_ENRICHMENT_ACTIVE_TAXON_SET', payload: snap.activeTaxon });
|
|
210
|
+
},
|
|
211
|
+
|
|
199
212
|
doFetchOntologyEnrichmentForeground: taxon => ({ dispatch, store }) => {
|
|
200
213
|
const q = store.selectGrameneFiltersQueryString();
|
|
201
214
|
const signature = fgSig(q, taxon);
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import bootViewFromUrl from '../utils/bootView';
|
|
2
|
+
|
|
3
|
+
// Client for the saved-views API ({api}/saved_views — see gramene-swagger
|
|
4
|
+
// phase 3b). Mirrors the gene_lists pattern: Firebase Bearer ID token,
|
|
5
|
+
// hash-based addressing, public vs private scope.
|
|
6
|
+
//
|
|
7
|
+
// Action creators all return Promises so calling UI (modal, boot path,
|
|
8
|
+
// list view) can await them and surface success/error inline.
|
|
9
|
+
//
|
|
10
|
+
// Dev-mode mock: set `window.__SAVED_VIEWS_MOCK__ = true` in the console
|
|
11
|
+
// and saves/fetches go through localStorage instead of the network. Lets us
|
|
12
|
+
// drive Phase 4 (UI) without waiting on Phase 3b (server). Production
|
|
13
|
+
// builds never touch the mock unless the flag is set at runtime, so this
|
|
14
|
+
// is safe to ship.
|
|
15
|
+
|
|
16
|
+
const STORAGE_PREFIX = 'gramene_saved_view_mock_v1::';
|
|
17
|
+
|
|
18
|
+
const initialState = {
|
|
19
|
+
saving: false,
|
|
20
|
+
saveError: null,
|
|
21
|
+
lastSavedHash: null,
|
|
22
|
+
|
|
23
|
+
fetching: false,
|
|
24
|
+
fetchError: null,
|
|
25
|
+
lastFetched: null, // { hash, snapshot, meta }
|
|
26
|
+
|
|
27
|
+
privateList: null, // null = not yet fetched
|
|
28
|
+
publicList: null,
|
|
29
|
+
listError: null
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const savedViews = {
|
|
33
|
+
name: 'savedViews',
|
|
34
|
+
|
|
35
|
+
getReducer: () => (state = initialState, {type, payload}) => {
|
|
36
|
+
switch (type) {
|
|
37
|
+
case 'SAVED_VIEW_SAVE_STARTED':
|
|
38
|
+
return {...state, saving: true, saveError: null};
|
|
39
|
+
case 'SAVED_VIEW_SAVE_SUCCEEDED':
|
|
40
|
+
return {...state, saving: false, lastSavedHash: payload.hash};
|
|
41
|
+
case 'SAVED_VIEW_SAVE_FAILED':
|
|
42
|
+
return {...state, saving: false, saveError: payload.error};
|
|
43
|
+
|
|
44
|
+
case 'SAVED_VIEW_FETCH_STARTED':
|
|
45
|
+
return {...state, fetching: true, fetchError: null};
|
|
46
|
+
case 'SAVED_VIEW_FETCH_SUCCEEDED':
|
|
47
|
+
return {...state, fetching: false, lastFetched: payload};
|
|
48
|
+
case 'SAVED_VIEW_FETCH_FAILED':
|
|
49
|
+
return {...state, fetching: false, fetchError: payload.error};
|
|
50
|
+
|
|
51
|
+
case 'SAVED_VIEW_LIST_RECEIVED':
|
|
52
|
+
return {
|
|
53
|
+
...state,
|
|
54
|
+
[payload.kind === 'public' ? 'publicList' : 'privateList']: payload.rows,
|
|
55
|
+
listError: null
|
|
56
|
+
};
|
|
57
|
+
case 'SAVED_VIEW_LIST_FAILED':
|
|
58
|
+
return {...state, listError: payload.error};
|
|
59
|
+
|
|
60
|
+
case 'SAVED_VIEW_RESET':
|
|
61
|
+
return {...initialState};
|
|
62
|
+
|
|
63
|
+
default:
|
|
64
|
+
return state;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
selectSavedViews: state => state.savedViews,
|
|
69
|
+
|
|
70
|
+
// POST a snapshot. Returns Promise<{hash, shareUrl}> on success.
|
|
71
|
+
// `user` is a Firebase user object (must respond to .getIdToken()).
|
|
72
|
+
doSaveView: ({user, label, description, isPublic}) => async ({dispatch, store}) => {
|
|
73
|
+
dispatch({type: 'SAVED_VIEW_SAVE_STARTED'});
|
|
74
|
+
try {
|
|
75
|
+
const snapshot = store.selectViewSnapshot();
|
|
76
|
+
const hash = await computeContentHash(snapshot);
|
|
77
|
+
const site = (store.selectConfiguration && store.selectConfiguration().id) || '';
|
|
78
|
+
const token = (user && user.getIdToken) ? await user.getIdToken() : null;
|
|
79
|
+
if (!token) throw new Error('Not signed in');
|
|
80
|
+
|
|
81
|
+
const meta = {
|
|
82
|
+
hash, label, site, isPublic: !!isPublic,
|
|
83
|
+
description: description || ''
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (useMock()) {
|
|
87
|
+
mockSave({...meta, state: snapshot, uid: user.uid || 'mock', createdAt: new Date().toISOString()});
|
|
88
|
+
} else {
|
|
89
|
+
const api = store.selectGrameneAPI();
|
|
90
|
+
const res = await fetch(`${api}/saved_views`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: {
|
|
93
|
+
'Content-Type': 'application/json',
|
|
94
|
+
Authorization: `Bearer ${token}`
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify({...meta, state: snapshot})
|
|
97
|
+
});
|
|
98
|
+
if (!res.ok) throw new Error(`Save failed (${res.status})`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const shareUrl = buildShareUrl(hash);
|
|
102
|
+
dispatch({type: 'SAVED_VIEW_SAVE_SUCCEEDED', payload: {hash}});
|
|
103
|
+
return {hash, shareUrl};
|
|
104
|
+
} catch (err) {
|
|
105
|
+
dispatch({type: 'SAVED_VIEW_SAVE_FAILED', payload: {error: err.message || String(err)}});
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// GET a snapshot by share hash. Anonymous-OK for public views; token is
|
|
111
|
+
// optional and only sent if the caller provides a user.
|
|
112
|
+
// Returns Promise<{snapshot, meta}>.
|
|
113
|
+
doFetchView: ({hash, user}) => async ({dispatch, store}) => {
|
|
114
|
+
dispatch({type: 'SAVED_VIEW_FETCH_STARTED'});
|
|
115
|
+
try {
|
|
116
|
+
let row;
|
|
117
|
+
if (useMock()) {
|
|
118
|
+
row = mockFetch(hash);
|
|
119
|
+
if (!row) throw new Error(`No saved view with hash ${hash}`);
|
|
120
|
+
} else {
|
|
121
|
+
const api = store.selectGrameneAPI();
|
|
122
|
+
const headers = {Accept: 'application/json'};
|
|
123
|
+
if (user && user.getIdToken) {
|
|
124
|
+
try { headers.Authorization = `Bearer ${await user.getIdToken()}`; }
|
|
125
|
+
catch (_) { /* anonymous fallback */ }
|
|
126
|
+
}
|
|
127
|
+
const res = await fetch(`${api}/saved_views?hash=${encodeURIComponent(hash)}`, {headers});
|
|
128
|
+
if (res.status === 401) throw new Error('Sign in to load this private view.');
|
|
129
|
+
if (res.status === 404) throw new Error('Saved view not found (it may have been deleted).');
|
|
130
|
+
if (!res.ok) throw new Error(`Fetch failed (${res.status})`);
|
|
131
|
+
row = await res.json();
|
|
132
|
+
}
|
|
133
|
+
const out = {
|
|
134
|
+
hash,
|
|
135
|
+
snapshot: row.state,
|
|
136
|
+
meta: {
|
|
137
|
+
label: row.label,
|
|
138
|
+
description: row.description || '',
|
|
139
|
+
site: row.site,
|
|
140
|
+
isPublic: !!row.isPublic,
|
|
141
|
+
owner: row.owner || null,
|
|
142
|
+
createdAt: row.createdAt || null
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
dispatch({type: 'SAVED_VIEW_FETCH_SUCCEEDED', payload: out});
|
|
146
|
+
return out;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
dispatch({type: 'SAVED_VIEW_FETCH_FAILED', payload: {error: err.message || String(err)}});
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
// List views for the current site. `scope` is 'public' (anonymous-OK)
|
|
154
|
+
// or 'private' (requires `user`).
|
|
155
|
+
doListSavedViews: ({scope, user}) => async ({dispatch, store}) => {
|
|
156
|
+
try {
|
|
157
|
+
const site = (store.selectConfiguration && store.selectConfiguration().id) || '';
|
|
158
|
+
let rows;
|
|
159
|
+
if (useMock()) {
|
|
160
|
+
rows = mockList({site, scope, uid: user && user.uid});
|
|
161
|
+
} else {
|
|
162
|
+
const api = store.selectGrameneAPI();
|
|
163
|
+
const headers = {Accept: 'application/json'};
|
|
164
|
+
if (scope === 'private') {
|
|
165
|
+
if (!user || !user.getIdToken) throw new Error('Not signed in');
|
|
166
|
+
headers.Authorization = `Bearer ${await user.getIdToken()}`;
|
|
167
|
+
}
|
|
168
|
+
const url = `${api}/saved_views?site=${encodeURIComponent(site)}&isPublic=${scope === 'public'}`;
|
|
169
|
+
const res = await fetch(url, {headers});
|
|
170
|
+
if (!res.ok) throw new Error(`List failed (${res.status})`);
|
|
171
|
+
rows = await res.json();
|
|
172
|
+
}
|
|
173
|
+
dispatch({type: 'SAVED_VIEW_LIST_RECEIVED', payload: {kind: scope, rows}});
|
|
174
|
+
return rows;
|
|
175
|
+
} catch (err) {
|
|
176
|
+
dispatch({type: 'SAVED_VIEW_LIST_FAILED', payload: {error: err.message || String(err)}});
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
// PATCH label / isPublic on a view I own. Mirrors updateList.
|
|
182
|
+
doUpdateSavedView: ({viewId, user, label, isPublic}) => async ({store}) => {
|
|
183
|
+
const updates = {};
|
|
184
|
+
if (typeof label === 'string') updates.label = label;
|
|
185
|
+
if (typeof isPublic === 'boolean') updates.isPublic = isPublic;
|
|
186
|
+
if (!Object.keys(updates).length) return;
|
|
187
|
+
|
|
188
|
+
if (useMock()) {
|
|
189
|
+
mockUpdate(viewId, updates);
|
|
190
|
+
return {viewId, updates};
|
|
191
|
+
}
|
|
192
|
+
const token = await user.getIdToken();
|
|
193
|
+
const api = store.selectGrameneAPI();
|
|
194
|
+
const res = await fetch(`${api}/saved_views?viewId=${encodeURIComponent(viewId)}`, {
|
|
195
|
+
method: 'PATCH',
|
|
196
|
+
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${token}`},
|
|
197
|
+
body: JSON.stringify(updates)
|
|
198
|
+
});
|
|
199
|
+
if (!res.ok) throw new Error(`Update failed (${res.status})`);
|
|
200
|
+
return res.json();
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
// DELETE one of my saved views. Mirrors deleteList.
|
|
204
|
+
doDeleteSavedView: ({viewId, user}) => async ({store}) => {
|
|
205
|
+
if (useMock()) {
|
|
206
|
+
mockDelete(viewId);
|
|
207
|
+
return {viewId};
|
|
208
|
+
}
|
|
209
|
+
const token = await user.getIdToken();
|
|
210
|
+
const api = store.selectGrameneAPI();
|
|
211
|
+
const res = await fetch(`${api}/saved_views?viewId=${encodeURIComponent(viewId)}`, {
|
|
212
|
+
method: 'DELETE',
|
|
213
|
+
headers: {Authorization: `Bearer ${token}`}
|
|
214
|
+
});
|
|
215
|
+
if (!res.ok) throw new Error(`Delete failed (${res.status})`);
|
|
216
|
+
return res.json();
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
doResetSavedViewState: () => ({dispatch}) => dispatch({type: 'SAVED_VIEW_RESET'}),
|
|
220
|
+
|
|
221
|
+
// Connect-friendly wrapper around bootViewFromUrl. Auth.js calls this on
|
|
222
|
+
// each auth-state emission, passing the current Firebase user (or null);
|
|
223
|
+
// bootView.js no-ops when ?view= isn't in the URL, so this is cheap to
|
|
224
|
+
// call repeatedly.
|
|
225
|
+
doBootSharedView: ({user} = {}) => ({store}) => {
|
|
226
|
+
return bootViewFromUrl(store, {user});
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// ── helpers ─────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
// Test/dev: either set the runtime-only flag (`window.__SAVED_VIEWS_MOCK__ = true`,
|
|
233
|
+
// cleared on reload), or persist via localStorage (`localStorage.setItem(
|
|
234
|
+
// '__SAVED_VIEWS_MOCK__', 'true')`, survives reloads — needed for the
|
|
235
|
+
// share-link round-trip which navigates).
|
|
236
|
+
function useMock() {
|
|
237
|
+
if (typeof window === 'undefined') return false;
|
|
238
|
+
if (window.__SAVED_VIEWS_MOCK__) return true;
|
|
239
|
+
try { return typeof localStorage !== 'undefined' && localStorage.getItem('__SAVED_VIEWS_MOCK__') === 'true'; }
|
|
240
|
+
catch (_) { return false; }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function buildShareUrl(hash) {
|
|
244
|
+
if (typeof window === 'undefined') return `?view=${hash}`;
|
|
245
|
+
const u = new URL(window.location.href);
|
|
246
|
+
u.search = '';
|
|
247
|
+
u.searchParams.set('view', hash);
|
|
248
|
+
return u.toString();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Content-addressable, ~72-bit short hash. The snapshot's `capturedAt` is
|
|
252
|
+
// excluded from the hash input so two saves of the same view by the same
|
|
253
|
+
// user produce the same hash (and the server's $setOnInsert preserves the
|
|
254
|
+
// original createdAt).
|
|
255
|
+
async function computeContentHash(snapshot) {
|
|
256
|
+
const forHash = {...snapshot, capturedAt: undefined};
|
|
257
|
+
const text = canonicalize(forHash);
|
|
258
|
+
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
|
259
|
+
const buf = new TextEncoder().encode(text);
|
|
260
|
+
const digest = await crypto.subtle.digest('SHA-256', buf);
|
|
261
|
+
const bytes = new Uint8Array(digest);
|
|
262
|
+
let b64 = '';
|
|
263
|
+
for (let i = 0; i < bytes.length; i++) b64 += String.fromCharCode(bytes[i]);
|
|
264
|
+
return btoa(b64)
|
|
265
|
+
.replace(/\+/g, '-')
|
|
266
|
+
.replace(/\//g, '_')
|
|
267
|
+
.replace(/=+$/, '')
|
|
268
|
+
.slice(0, 12);
|
|
269
|
+
}
|
|
270
|
+
// Last-resort fallback for environments without WebCrypto.
|
|
271
|
+
let h = 0;
|
|
272
|
+
for (let i = 0; i < text.length; i++) h = ((h << 5) - h + text.charCodeAt(i)) | 0;
|
|
273
|
+
return ('h' + (h >>> 0).toString(36)).slice(0, 12);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Stable stringify: sort object keys recursively so semantically-equivalent
|
|
277
|
+
// snapshots hash identically.
|
|
278
|
+
function canonicalize(v) {
|
|
279
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
|
280
|
+
if (Array.isArray(v)) return '[' + v.map(canonicalize).join(',') + ']';
|
|
281
|
+
const keys = Object.keys(v).filter(k => v[k] !== undefined).sort();
|
|
282
|
+
return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalize(v[k])).join(',') + '}';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── mock backend (localStorage) ─────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
function mockSave(row) {
|
|
288
|
+
if (typeof localStorage === 'undefined') return;
|
|
289
|
+
localStorage.setItem(STORAGE_PREFIX + row.hash, JSON.stringify({...row, _id: row.hash + ' ' + row.uid}));
|
|
290
|
+
}
|
|
291
|
+
function mockFetch(hash) {
|
|
292
|
+
if (typeof localStorage === 'undefined') return null;
|
|
293
|
+
const raw = localStorage.getItem(STORAGE_PREFIX + hash);
|
|
294
|
+
return raw ? JSON.parse(raw) : null;
|
|
295
|
+
}
|
|
296
|
+
function mockList({site, scope, uid}) {
|
|
297
|
+
if (typeof localStorage === 'undefined') return [];
|
|
298
|
+
const out = [];
|
|
299
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
300
|
+
const k = localStorage.key(i);
|
|
301
|
+
if (!k || !k.startsWith(STORAGE_PREFIX)) continue;
|
|
302
|
+
const row = JSON.parse(localStorage.getItem(k));
|
|
303
|
+
if (row.site !== site) continue;
|
|
304
|
+
if (scope === 'public' && !row.isPublic) continue;
|
|
305
|
+
if (scope === 'private' && (row.isPublic || row.uid !== uid)) continue;
|
|
306
|
+
out.push(row);
|
|
307
|
+
}
|
|
308
|
+
return out.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
|
|
309
|
+
}
|
|
310
|
+
function mockUpdate(viewId, updates) {
|
|
311
|
+
if (typeof localStorage === 'undefined') return;
|
|
312
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
313
|
+
const k = localStorage.key(i);
|
|
314
|
+
if (!k || !k.startsWith(STORAGE_PREFIX)) continue;
|
|
315
|
+
const row = JSON.parse(localStorage.getItem(k));
|
|
316
|
+
if (row._id === viewId) {
|
|
317
|
+
localStorage.setItem(k, JSON.stringify({...row, ...updates}));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function mockDelete(viewId) {
|
|
323
|
+
if (typeof localStorage === 'undefined') return;
|
|
324
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
325
|
+
const k = localStorage.key(i);
|
|
326
|
+
if (!k || !k.startsWith(STORAGE_PREFIX)) continue;
|
|
327
|
+
const row = JSON.parse(localStorage.getItem(k));
|
|
328
|
+
if (row._id === viewId) {
|
|
329
|
+
localStorage.removeItem(k);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export default savedViews;
|