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.
Files changed (35) hide show
  1. package/.claude/launch.json +11 -0
  2. package/.claude/settings.local.json +6 -1
  3. package/.env.example +6 -0
  4. package/.parcel-cache/13f2d5707e7af45c-RequestGraph +0 -0
  5. package/.parcel-cache/5ae0570a78c0dba3-AssetGraph +0 -0
  6. package/.parcel-cache/9ac092379278e465-BundleGraph +0 -0
  7. package/.parcel-cache/data.mdb +0 -0
  8. package/.parcel-cache/lock.mdb +0 -0
  9. package/.parcel-cache/snapshot-13f2d5707e7af45c.txt +2 -2
  10. package/dist/index.js +2065 -154
  11. package/dist/index.js.map +1 -1
  12. package/package.json +5 -2
  13. package/src/bundles/api.js +10 -4
  14. package/src/bundles/exprViz.js +97 -1
  15. package/src/bundles/index.js +4 -1
  16. package/src/bundles/ontologyEnrichment.js +14 -1
  17. package/src/bundles/savedViews.js +335 -0
  18. package/src/bundles/uiViewState.js +174 -0
  19. package/src/bundles/viewSnapshot.js +313 -0
  20. package/src/bundles/views.js +22 -0
  21. package/src/components/Auth.js +23 -3
  22. package/src/components/SaveView.js +157 -0
  23. package/src/components/exprViz/ExprVizView.js +16 -11
  24. package/src/components/exprViz/ParallelCoordsPlot.js +15 -0
  25. package/src/components/results/GeneList.js +45 -15
  26. package/src/components/results/OntologyEnrichment.js +13 -6
  27. package/src/components/results/details/BAR.js +148 -0
  28. package/src/components/results/details/Expression.js +43 -11
  29. package/src/components/results/details/Homology.js +170 -32
  30. package/src/components/results/details/Pathways.js +4 -2
  31. package/src/components/results/details/Sequences.js +24 -8
  32. package/src/demo.js +22 -17
  33. package/src/index.js +2 -1
  34. package/src/suppressDevWarnings.js +13 -0
  35. package/src/utils/bootView.js +38 -0
@@ -0,0 +1,174 @@
1
+ // Per-gene UI state lifted out of <Gene> and <Homology> class components.
2
+ // Keyed by geneId so it survives unmount/remount (e.g. scrolling a row out
3
+ // of view and back), and so a snapshot serializer can later round-trip it
4
+ // for the shareable-views feature.
5
+ //
6
+ // What lives here:
7
+ // - expandedDetail: which detail tab is open in the gene-list card
8
+ // - fullscreen: whether the expanded detail is full-screened
9
+ // - homology.viewer: 'treevis' | 'tbrowse'
10
+ // - homology.height: drag-resize height of the homology detail pane
11
+ // - homology.tbrowse: the tbrowse ViewState (collapsedNodeIds, prunedNodeIds,
12
+ // swappedNodeIds, compressedNodeIds, nodeOfInterestId, zones, zoneStates,
13
+ // search, selectedNodeId). Tbrowse is driven in controlled mode from this.
14
+ // - sequences: the Sequences detail's internal state — tab ('dna'|'rna'|'pep'),
15
+ // tid (selected transcript id), upstream/downstream (flanking bp). Driven in
16
+ // controlled mode from this so a saved view restores the chosen sub-tab and
17
+ // isoform.
18
+ // - expression: the Expression detail's internal state — activeTab
19
+ // ('gene'|'paralogs'|'eFP'), atlasExperiment (selected GXA experiment id),
20
+ // barStudy (selected eFP/BAR study). Driven in controlled mode from this so a
21
+ // saved view restores the chosen sub-tab and study.
22
+ //
23
+ // What does NOT live here:
24
+ // - derived/computed state like `details` (per-render config from
25
+ // props.config.details + capabilities)
26
+ // - async-fetched data caches in <Homology> (neighborhood, geneStructures)
27
+
28
+ const initialState = {
29
+ byGene: {}
30
+ };
31
+
32
+ const ensureGene = (state, geneId) => {
33
+ if (state.byGene[geneId]) return state;
34
+ return {
35
+ ...state,
36
+ byGene: {
37
+ ...state.byGene,
38
+ [geneId]: { homology: {} }
39
+ }
40
+ };
41
+ };
42
+
43
+ const setGene = (state, geneId, patch) => {
44
+ const next = ensureGene(state, geneId);
45
+ return {
46
+ ...next,
47
+ byGene: {
48
+ ...next.byGene,
49
+ [geneId]: { ...next.byGene[geneId], ...patch }
50
+ }
51
+ };
52
+ };
53
+
54
+ const setHomology = (state, geneId, patch) => {
55
+ const next = ensureGene(state, geneId);
56
+ const prev = next.byGene[geneId];
57
+ return {
58
+ ...next,
59
+ byGene: {
60
+ ...next.byGene,
61
+ [geneId]: {
62
+ ...prev,
63
+ homology: { ...(prev.homology || {}), ...patch }
64
+ }
65
+ }
66
+ };
67
+ };
68
+
69
+ const setSequences = (state, geneId, patch) => {
70
+ const next = ensureGene(state, geneId);
71
+ const prev = next.byGene[geneId];
72
+ return {
73
+ ...next,
74
+ byGene: {
75
+ ...next.byGene,
76
+ [geneId]: {
77
+ ...prev,
78
+ sequences: { ...(prev.sequences || {}), ...patch }
79
+ }
80
+ }
81
+ };
82
+ };
83
+
84
+ const setExpression = (state, geneId, patch) => {
85
+ const next = ensureGene(state, geneId);
86
+ const prev = next.byGene[geneId];
87
+ return {
88
+ ...next,
89
+ byGene: {
90
+ ...next.byGene,
91
+ [geneId]: {
92
+ ...prev,
93
+ expression: { ...(prev.expression || {}), ...patch }
94
+ }
95
+ }
96
+ };
97
+ };
98
+
99
+ const uiViewState = {
100
+ name: 'uiViewState',
101
+ getReducer: () => (state = initialState, { type, payload }) => {
102
+ switch (type) {
103
+ case 'UI_GENE_DETAIL_EXPANDED':
104
+ // payload: { geneId, detail } (detail = null to collapse)
105
+ return setGene(state, payload.geneId, {
106
+ expandedDetail: payload.detail,
107
+ // collapsing also exits fullscreen
108
+ fullscreen: payload.detail === null ? false : state.byGene[payload.geneId]?.fullscreen || false
109
+ });
110
+
111
+ case 'UI_GENE_FULLSCREEN_SET':
112
+ // payload: { geneId, fullscreen }
113
+ return setGene(state, payload.geneId, { fullscreen: !!payload.fullscreen });
114
+
115
+ case 'UI_HOMOLOGY_VIEWER_SET':
116
+ // payload: { geneId, viewer }
117
+ return setHomology(state, payload.geneId, { viewer: payload.viewer });
118
+
119
+ case 'UI_HOMOLOGY_HEIGHT_SET':
120
+ // payload: { geneId, height }
121
+ return setHomology(state, payload.geneId, { height: payload.height });
122
+
123
+ case 'UI_HOMOLOGY_TBROWSE_SET':
124
+ // payload: { geneId, tbrowse } -- a full ViewState object
125
+ return setHomology(state, payload.geneId, { tbrowse: payload.tbrowse });
126
+
127
+ case 'UI_SEQUENCES_SET':
128
+ // payload: { geneId, patch } -- merges into the sequences slice
129
+ // (e.g. { tab }, { tid }, { upstream }, { downstream })
130
+ return setSequences(state, payload.geneId, payload.patch);
131
+
132
+ case 'UI_EXPRESSION_SET':
133
+ // payload: { geneId, patch } -- merges into the expression slice
134
+ // (e.g. { activeTab }, { atlasExperiment }, { barStudy })
135
+ return setExpression(state, payload.geneId, payload.patch);
136
+
137
+ case 'UI_VIEW_STATE_REPLACED':
138
+ // payload: { byGene } -- used by the snapshot loader in a later phase
139
+ return { byGene: payload.byGene || {} };
140
+
141
+ default:
142
+ return state;
143
+ }
144
+ },
145
+
146
+ selectUiViewState: state => state.uiViewState,
147
+
148
+ doExpandGeneDetail: ({ geneId, detail }) => ({ dispatch }) => {
149
+ dispatch({ type: 'UI_GENE_DETAIL_EXPANDED', payload: { geneId, detail } });
150
+ },
151
+ doSetGeneFullscreen: ({ geneId, fullscreen }) => ({ dispatch }) => {
152
+ dispatch({ type: 'UI_GENE_FULLSCREEN_SET', payload: { geneId, fullscreen } });
153
+ },
154
+ doSetHomologyViewer: ({ geneId, viewer }) => ({ dispatch }) => {
155
+ dispatch({ type: 'UI_HOMOLOGY_VIEWER_SET', payload: { geneId, viewer } });
156
+ },
157
+ doSetHomologyHeight: ({ geneId, height }) => ({ dispatch }) => {
158
+ dispatch({ type: 'UI_HOMOLOGY_HEIGHT_SET', payload: { geneId, height } });
159
+ },
160
+ doSetHomologyTbrowseViewState: ({ geneId, tbrowse }) => ({ dispatch }) => {
161
+ dispatch({ type: 'UI_HOMOLOGY_TBROWSE_SET', payload: { geneId, tbrowse } });
162
+ },
163
+ doSetSequencesState: ({ geneId, patch }) => ({ dispatch }) => {
164
+ dispatch({ type: 'UI_SEQUENCES_SET', payload: { geneId, patch } });
165
+ },
166
+ doSetExpressionState: ({ geneId, patch }) => ({ dispatch }) => {
167
+ dispatch({ type: 'UI_EXPRESSION_SET', payload: { geneId, patch } });
168
+ },
169
+ doReplaceUiViewState: byGene => ({ dispatch }) => {
170
+ dispatch({ type: 'UI_VIEW_STATE_REPLACED', payload: { byGene } });
171
+ }
172
+ };
173
+
174
+ export default uiViewState;
@@ -0,0 +1,313 @@
1
+ // Serializer/deserializer for shareable gene-search views.
2
+ //
3
+ // `selectViewSnapshot` produces a versioned JSON-safe blob from the current
4
+ // store state. `doApplyViewSnapshot` validates a blob and restores it via
5
+ // existing action creators. Persistence + UI live in later phases — this
6
+ // bundle is the pure state-shape layer they build on.
7
+ //
8
+ // Snapshot schema, v1:
9
+ // {
10
+ // v: 1,
11
+ // capturedAt: <ISO 8601 string>,
12
+ // site: <subsite id>,
13
+ // filters: <grameneFilters tree, stripped of ephemeral UI flags>,
14
+ // views: { on: [id,...], touched: {id: true, ...} },
15
+ // genomeSubset: { taxonId: true, ... } | null,
16
+ // searchPage: { offset: number, rows: number } | null,
17
+ // exprViz: { // Expression-visualization view
18
+ // activeTaxon: <taxon_id|null>,
19
+ // byTaxon: { [taxon_id]: { selectedFields: [field,...], vizMode, scale, brushes } }
20
+ // } | undefined, // loaded rows re-fetch on apply
21
+ // ontologyEnrichment: { // Ontology Enrichment view
22
+ // activeTaxon: <taxon_id|null>,
23
+ // ui: { pAdjCutoff, minGSSize, maxGSSize, mostSpecific, ontology, search, sort }
24
+ // } | undefined, // facets re-fetch on apply
25
+ // expandedDetails: [
26
+ // { geneId,
27
+ // expandedDetail: string|null,
28
+ // fullscreen: boolean,
29
+ // homology: { viewer, height, tbrowse: <ViewState> } | undefined,
30
+ // sequences: { tab, tid, upstream, downstream } | undefined,
31
+ // expression: { activeTab, atlasExperiment, barStudy } | undefined
32
+ // }, ...
33
+ // ]
34
+ // }
35
+ //
36
+ // Anything not in this schema is intentionally NOT persisted — async fetch
37
+ // caches, search-results, pagination cursors mid-flight, etc. all rehydrate
38
+ // from the API once the snapshot is applied.
39
+
40
+ const SCHEMA_VERSION = 1;
41
+
42
+ // Keys on a filter node we want to keep. Everything else (showMenu, marked,
43
+ // status flags from the root, etc.) is ephemeral UI and not meaningful in a
44
+ // shared view.
45
+ const FILTER_NODE_KEEP = [
46
+ 'leftIdx', 'rightIdx', 'operation', 'negate',
47
+ 'fq_field', 'fq_value', 'name', 'category',
48
+ 'warning'
49
+ ];
50
+ const FILTER_ROOT_KEEP = [
51
+ 'operation', 'negate', 'leftIdx', 'rightIdx',
52
+ 'children', 'searchOffset', 'rows'
53
+ ];
54
+
55
+ const cleanFilterNode = (node) => {
56
+ const out = {};
57
+ for (const k of FILTER_NODE_KEEP) {
58
+ if (node[k] !== undefined) out[k] = node[k];
59
+ }
60
+ if (Array.isArray(node.children)) {
61
+ out.children = node.children.map(cleanFilterNode);
62
+ }
63
+ return out;
64
+ };
65
+
66
+ const cleanFilters = (filters) => {
67
+ const out = {};
68
+ for (const k of FILTER_ROOT_KEEP) {
69
+ if (filters[k] !== undefined) out[k] = filters[k];
70
+ }
71
+ if (Array.isArray(filters.children)) {
72
+ out.children = filters.children.map(cleanFilterNode);
73
+ }
74
+ return out;
75
+ };
76
+
77
+ // Drop bundle entries that hold no meaningful state (e.g. a row the user
78
+ // scrolled past but never opened). Otherwise snapshots bloat with empties.
79
+ const isMeaningfulGene = (entry) => {
80
+ if (!entry) return false;
81
+ if (entry.expandedDetail) return true;
82
+ if (entry.fullscreen) return true;
83
+ if (entry.homology && (
84
+ entry.homology.viewer !== undefined ||
85
+ entry.homology.height !== undefined ||
86
+ entry.homology.tbrowse !== undefined
87
+ )) return true;
88
+ if (entry.sequences && Object.keys(entry.sequences).length) return true;
89
+ if (entry.expression && Object.keys(entry.expression).length) return true;
90
+ return false;
91
+ };
92
+
93
+ const viewSnapshot = {
94
+ name: 'viewSnapshot',
95
+ // No reducer — this bundle is pure derivation + dispatch orchestration.
96
+ getReducer: () => (state = {}) => state,
97
+
98
+ selectViewSnapshot: createSnapshotSelector(),
99
+
100
+ // Apply a snapshot to the store. Returns an array of unresolved-id warnings
101
+ // so the caller can surface a non-blocking notice. The action creators we
102
+ // delegate to are existing ones; nothing here knows about API/UI/share-link
103
+ // — that's later phases.
104
+ doApplyViewSnapshot: (snapshot) => ({dispatch, store}) => {
105
+ const warnings = validateSnapshot(snapshot);
106
+ if (warnings.fatal) {
107
+ console.warn('viewSnapshot: refusing to apply', warnings.fatal);
108
+ return { applied: false, warnings };
109
+ }
110
+
111
+ // 1. Filters (also clears search; replays a fresh fetch).
112
+ if (snapshot.filters) {
113
+ dispatch({
114
+ type: 'BATCH_ACTIONS', actions: [
115
+ {type: 'GRAMENE_SEARCH_CLEARED'},
116
+ {type: 'GRAMENE_FILTERS_REPLACED', payload: cloneForDispatch(snapshot.filters)}
117
+ ]
118
+ });
119
+ }
120
+
121
+ // 2. Views.
122
+ if (snapshot.views) {
123
+ dispatch({
124
+ type: 'GRAMENE_VIEWS_REPLACED',
125
+ payload: {
126
+ on: snapshot.views.on || [],
127
+ touched: snapshot.views.touched || {}
128
+ }
129
+ });
130
+ }
131
+
132
+ // 3. Genome subset, if present.
133
+ if (snapshot.genomeSubset && Object.keys(snapshot.genomeSubset).length) {
134
+ dispatch({type: 'GRAMENE_GENOMES_UPDATED', payload: {...snapshot.genomeSubset}});
135
+ }
136
+
137
+ // 4. Per-gene UI state (expandedDetail, fullscreen, homology viewer/
138
+ // height, tbrowse view state). Replace wholesale via the uiViewState
139
+ // bundle's bulk action.
140
+ const byGene = {};
141
+ if (Array.isArray(snapshot.expandedDetails)) {
142
+ for (const e of snapshot.expandedDetails) {
143
+ if (!e || !e.geneId) continue;
144
+ byGene[e.geneId] = {
145
+ expandedDetail: e.expandedDetail || null,
146
+ fullscreen: !!e.fullscreen,
147
+ homology: e.homology ? {...e.homology} : {},
148
+ sequences: e.sequences ? {...e.sequences} : {},
149
+ expression: e.expression ? {...e.expression} : {}
150
+ };
151
+ }
152
+ }
153
+ dispatch({type: 'UI_VIEW_STATE_REPLACED', payload: {byGene}});
154
+
155
+ // 5. Expression-visualization view config. Delegate to the exprViz bundle,
156
+ // which flags each taxon for a re-fetch now that the restored filters/
157
+ // genomes are in place. Done last so the re-fetch sees the live query.
158
+ if (snapshot.exprViz && store.doApplyExprVizSnapshot) {
159
+ store.doApplyExprVizSnapshot(snapshot.exprViz);
160
+ }
161
+
162
+ // 6. Ontology Enrichment view config. Setting the active taxon lets that
163
+ // bundle's fetch reactor re-derive the enrichment for the restored
164
+ // filters/genomes.
165
+ if (snapshot.ontologyEnrichment && store.doApplyOntologyEnrichmentSnapshot) {
166
+ store.doApplyOntologyEnrichmentSnapshot(snapshot.ontologyEnrichment);
167
+ }
168
+
169
+ return { applied: true, warnings };
170
+ },
171
+
172
+ // Helper exposed for the bootstrap path that wants to read warnings without
173
+ // dispatching. Returns { fatal: <string|null>, unresolved: { ids/taxa/views/genes lists } }.
174
+ doValidateViewSnapshot: (snapshot) => () => validateSnapshot(snapshot)
175
+ };
176
+
177
+ function createSnapshotSelector() {
178
+ // We deliberately don't memoize with createSelector — snapshots are taken
179
+ // on demand (Save button click, link generation) rather than on every
180
+ // store tick, so a fresh build is fine and avoids stale-dep bugs.
181
+ return (state) => buildSnapshot(state);
182
+ }
183
+
184
+ function buildSnapshot(state) {
185
+ const snap = {
186
+ v: SCHEMA_VERSION,
187
+ capturedAt: new Date().toISOString(),
188
+ site: (state.config && state.config.id) || null,
189
+ };
190
+
191
+ if (state.grameneFilters) {
192
+ snap.filters = cleanFilters(state.grameneFilters);
193
+ snap.searchPage = {
194
+ offset: state.grameneFilters.searchOffset || 0,
195
+ rows: state.grameneFilters.rows || 20
196
+ };
197
+ }
198
+
199
+ if (state.grameneViews && Array.isArray(state.grameneViews.options)) {
200
+ snap.views = {
201
+ on: state.grameneViews.options
202
+ .filter(v => v && v.show === 'on')
203
+ .map(v => v.id),
204
+ touched: {...(state.grameneViews.touched || {})}
205
+ };
206
+ }
207
+
208
+ if (state.grameneGenomes && state.grameneGenomes.active) {
209
+ const keys = Object.keys(state.grameneGenomes.active);
210
+ if (keys.length) {
211
+ snap.genomeSubset = {};
212
+ for (const k of keys) snap.genomeSubset[k] = true;
213
+ } else {
214
+ snap.genomeSubset = null;
215
+ }
216
+ }
217
+
218
+ if (state.uiViewState && state.uiViewState.byGene) {
219
+ snap.expandedDetails = Object.entries(state.uiViewState.byGene)
220
+ .filter(([, e]) => isMeaningfulGene(e))
221
+ .map(([geneId, e]) => ({
222
+ geneId,
223
+ expandedDetail: e.expandedDetail || null,
224
+ fullscreen: !!e.fullscreen,
225
+ homology: e.homology && Object.keys(e.homology).length ? {...e.homology} : undefined,
226
+ sequences: e.sequences && Object.keys(e.sequences).length ? {...e.sequences} : undefined,
227
+ expression: e.expression && Object.keys(e.expression).length ? {...e.expression} : undefined
228
+ }));
229
+ } else {
230
+ snap.expandedDetails = [];
231
+ }
232
+
233
+ // Expression-visualization view config. Capture only the selection/config
234
+ // (active genome tab, per-taxon selected fields + column order, viz mode,
235
+ // scale, brush ranges) — never the bulk fetched rows, which rehydrate via
236
+ // the view's re-fetch on apply.
237
+ if (state.exprViz) {
238
+ const ev = state.exprViz;
239
+ const byTaxon = {};
240
+ for (const tid of Object.keys(ev.byTaxon || {})) {
241
+ const t = ev.byTaxon[tid];
242
+ if (!t) continue;
243
+ const selectedFields = Array.isArray(t.selectedFields) ? t.selectedFields : [];
244
+ const brushes = t.brushes && Object.keys(t.brushes).length ? {...t.brushes} : undefined;
245
+ const nonDefaultMode = t.vizMode && t.vizMode !== 'heatmap';
246
+ const nonDefaultScale = t.scale && t.scale !== 'linear';
247
+ if (selectedFields.length || brushes || nonDefaultMode || nonDefaultScale) {
248
+ byTaxon[tid] = {
249
+ selectedFields,
250
+ vizMode: t.vizMode || 'heatmap',
251
+ scale: t.scale || 'linear',
252
+ ...(brushes ? { brushes } : {})
253
+ };
254
+ }
255
+ }
256
+ if (ev.activeTaxon || Object.keys(byTaxon).length) {
257
+ snap.exprViz = { activeTaxon: ev.activeTaxon || null, byTaxon };
258
+ }
259
+ }
260
+
261
+ // Ontology Enrichment view config: active genome + the analysis knobs
262
+ // (significance cutoff, gene-set-size bounds, most-specific, ontology
263
+ // section, term search, per-section sort). The enrichment facets re-fetch
264
+ // on apply, so no bulk term data is carried.
265
+ if (state.ontologyEnrichment) {
266
+ const oe = state.ontologyEnrichment;
267
+ const ui = oe.ui || {};
268
+ const DEF = { pAdjCutoff: 0.05, minGSSize: 10, maxGSSize: 500, mostSpecific: false, ontology: 'all', search: '' };
269
+ const hasSort = ui.sort && Object.keys(ui.sort).length;
270
+ const uiChanged = Object.keys(DEF).some(k => ui[k] !== DEF[k]) || hasSort;
271
+ if (oe.activeTaxon || uiChanged) {
272
+ snap.ontologyEnrichment = {
273
+ activeTaxon: oe.activeTaxon || null,
274
+ ui: {
275
+ pAdjCutoff: ui.pAdjCutoff != null ? ui.pAdjCutoff : DEF.pAdjCutoff,
276
+ minGSSize: ui.minGSSize != null ? ui.minGSSize : DEF.minGSSize,
277
+ maxGSSize: ui.maxGSSize != null ? ui.maxGSSize : DEF.maxGSSize,
278
+ mostSpecific: !!ui.mostSpecific,
279
+ ontology: ui.ontology || DEF.ontology,
280
+ search: ui.search || DEF.search,
281
+ ...(hasSort ? { sort: {...ui.sort} } : {})
282
+ }
283
+ };
284
+ }
285
+ }
286
+
287
+ return snap;
288
+ }
289
+
290
+ function validateSnapshot(snapshot) {
291
+ const warnings = { fatal: null, unresolved: { views: [], genes: [], taxa: [] } };
292
+ if (!snapshot || typeof snapshot !== 'object') {
293
+ warnings.fatal = 'snapshot is not an object';
294
+ return warnings;
295
+ }
296
+ if (snapshot.v !== SCHEMA_VERSION) {
297
+ warnings.fatal = `unsupported snapshot version ${snapshot.v} (expected ${SCHEMA_VERSION})`;
298
+ return warnings;
299
+ }
300
+ // Strict-id checks happen later — they need the store's grameneMaps,
301
+ // grameneTaxonomy, etc. which are async-loaded. The boot path will call
302
+ // doValidateViewSnapshot again after those land. For now we just
303
+ // structurally validate.
304
+ return warnings;
305
+ }
306
+
307
+ // JSON.parse(JSON.stringify(x)) — bundles see a wholly fresh object so
308
+ // reducers can't accidentally mutate the snapshot we're holding for retry.
309
+ function cloneForDispatch(x) {
310
+ return JSON.parse(JSON.stringify(x));
311
+ }
312
+
313
+ export default viewSnapshot;
@@ -102,6 +102,21 @@ const grameneViews = {
102
102
  newState = Object.assign({}, state);
103
103
  newState.options.forEach(v => v.shouldScroll = false)
104
104
  return newState;
105
+ case 'GRAMENE_VIEWS_REPLACED': {
106
+ // Snapshot restore. payload: { on: Set|Array<id>, touched: {id: true} }
107
+ // Sets options[].show directly to 'on' or 'off' based on membership in
108
+ // `on`. Leaves 'disabled' entries untouched (site config wins).
109
+ const onSet = payload.on instanceof Set
110
+ ? payload.on
111
+ : new Set(payload.on || []);
112
+ newState = Object.assign({}, state);
113
+ newState.touched = { ...(payload.touched || {}) };
114
+ newState.options = state.options.map(v => {
115
+ if (v.show === 'disabled') return v;
116
+ return { ...v, show: onSet.has(v.id) ? 'on' : 'off', shouldScroll: false };
117
+ });
118
+ return newState;
119
+ }
105
120
  default:
106
121
  return state;
107
122
  }
@@ -116,6 +131,13 @@ const grameneViews = {
116
131
  doCancelShouldScroll: () => ({dispatch, getState}) => {
117
132
  dispatch({type: 'GRAMENE_VIEW_SCROLLED', payload: null})
118
133
  },
134
+ // Snapshot restore. `on` is an array of view ids to switch on; everything
135
+ // else (except disabled views) goes off. `touched` is the same map the
136
+ // user-toggled view bundle maintains, so the auto-default logic in
137
+ // selectGrameneViews respects what the user has explicitly set.
138
+ doReplaceGrameneViews: ({on, touched}) => ({dispatch}) => {
139
+ dispatch({type: 'GRAMENE_VIEWS_REPLACED', payload: {on, touched}});
140
+ },
119
141
  selectRawGrameneViews: state => state.grameneViews,
120
142
  selectGrameneViews: createSelector(
121
143
  'selectRawGrameneViews',
@@ -1,9 +1,10 @@
1
- import React, { useMemo, useState } from 'react'
1
+ import React, { useMemo, useState, useEffect } from 'react'
2
2
  import { Button, Modal } from 'react-bootstrap'
3
3
  import { connect } from "redux-bundler-react";
4
4
  import { getFirebaseApp } from "./utils";
5
5
  import { getAuth, onAuthStateChanged, signOut, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
6
6
  import { BsChevronDown, BsChevronRight } from 'react-icons/bs';
7
+ import SaveViewButton from './SaveView';
7
8
 
8
9
  const provider = new GoogleAuthProvider();
9
10
 
@@ -13,10 +14,23 @@ const Auth = props => {
13
14
  const app = getFirebaseApp(firebaseConfig);
14
15
  return app ? getAuth(app) : null;
15
16
  }, [firebaseConfig]);
16
- const [user, setUser] = useState({});
17
+ const [user, setUser] = useState(null);
17
18
  const [open, setOpen] = useState(true);
19
+
20
+ // Subscribe once, unsubscribe on unmount. On each auth-state emission,
21
+ // also poke the shared-view boot path so a `?view=<hash>` for a private
22
+ // view gets retried with the now-available Bearer token. bootViewFromUrl
23
+ // no-ops when the param isn't present, so this is cheap.
24
+ useEffect(() => {
25
+ if (!auth) return undefined;
26
+ const unsub = onAuthStateChanged(auth, (u) => {
27
+ setUser(u);
28
+ props.doBootSharedView({user: u});
29
+ });
30
+ return unsub;
31
+ }, [auth]);
32
+
18
33
  if (!auth) return null;
19
- onAuthStateChanged(auth, (user) => setUser(user));
20
34
 
21
35
  function handleLogin() {
22
36
  signInWithPopup(auth, provider)
@@ -52,6 +66,11 @@ const Auth = props => {
52
66
  : <Button size="sm" variant="success" onClick={handleLogin}>Login</Button>
53
67
  }
54
68
  </div>
69
+ {user && user.uid && (
70
+ <div>
71
+ <SaveViewButton user={user}/>
72
+ </div>
73
+ )}
55
74
  </div>}
56
75
  </div>
57
76
  </div>
@@ -60,5 +79,6 @@ const Auth = props => {
60
79
 
61
80
  export default connect(
62
81
  'selectConfiguration',
82
+ 'doBootSharedView',
63
83
  Auth
64
84
  )