sequenceserver 3.1.1 → 3.1.3
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.
- checksums.yaml +4 -4
- data/lib/sequenceserver/api_errors.rb +24 -0
- data/lib/sequenceserver/blast/tasks.rb +1 -1
- data/lib/sequenceserver/blast.rb +6 -0
- data/lib/sequenceserver/database.rb +13 -0
- data/lib/sequenceserver/routes.rb +1 -1
- data/lib/sequenceserver/sequence.rb +1 -2
- data/lib/sequenceserver/version.rb +1 -1
- data/lib/sequenceserver.rb +1 -1
- data/public/css/app.min.css +1 -1
- data/public/css/sequenceserver.css +0 -18
- data/public/css/sequenceserver.min.css +2 -2
- data/public/js/alignment_exporter.js +16 -28
- data/public/js/cloud_share_modal.js +42 -42
- data/public/js/form.js +12 -10
- data/public/js/grapher.js +4 -4
- data/public/js/hit.js +3 -3
- data/public/js/hits.js +276 -0
- data/public/js/jquery_world.js +1 -1
- data/public/js/mailto.js +1 -3
- data/public/js/null_plugins/report_plugins.js +1 -0
- data/public/js/options.js +2 -6
- data/public/js/query.js +1 -1
- data/public/js/report.js +68 -252
- data/public/js/report_root.js +7 -5
- data/public/js/search.js +28 -11
- data/public/js/sequence.js +158 -158
- data/public/js/sequence_modal.js +28 -36
- data/public/js/sidebar.js +7 -6
- data/public/js/tests/alignment_exporter.spec.js +38 -0
- data/public/js/tests/cloud_share_modal.spec.js +75 -0
- data/public/js/tests/report.spec.js +37 -15
- data/public/packages/jquery-ui@1.13.3.js +19070 -0
- data/public/sequenceserver-report.min.js +3 -2481
- data/public/sequenceserver-report.min.js.LICENSE.txt +300 -0
- data/public/sequenceserver-report.min.js.map +1 -0
- data/public/sequenceserver-search.min.js +3 -2382
- data/public/sequenceserver-search.min.js.LICENSE.txt +292 -0
- data/public/sequenceserver-search.min.js.map +1 -0
- data/views/layout.erb +3 -7
- data/views/search.erb +1 -1
- data/views/search_layout.erb +1 -1
- metadata +11 -5
- data/public/config.js +0 -147
- data/public/packages/jquery-ui@1.11.4.js +0 -16624
data/public/js/report.js
CHANGED
@@ -3,10 +3,8 @@ import React, { Component } from 'react';
|
|
3
3
|
import _ from 'underscore';
|
4
4
|
|
5
5
|
import Sidebar from './sidebar';
|
6
|
+
import Hits from './hits';
|
6
7
|
import Circos from './circos';
|
7
|
-
import { ReportQuery } from './query';
|
8
|
-
import Hit from './hit';
|
9
|
-
import HSP from './hsp';
|
10
8
|
import AlignmentExporter from './alignment_exporter';
|
11
9
|
import ReportPlugins from 'report_plugins';
|
12
10
|
|
@@ -21,11 +19,6 @@ class Report extends Component {
|
|
21
19
|
super(props);
|
22
20
|
// Properties below are internal state used to render results in small
|
23
21
|
// slices (see updateState).
|
24
|
-
this.numUpdates = 0;
|
25
|
-
this.nextQuery = 0;
|
26
|
-
this.nextHit = 0;
|
27
|
-
this.nextHSP = 0;
|
28
|
-
this.maxHSPs = 3; // max HSPs to render in a cycle
|
29
22
|
this.state = {
|
30
23
|
user_warning: null,
|
31
24
|
download_links: [],
|
@@ -34,8 +27,8 @@ class Report extends Component {
|
|
34
27
|
program: '',
|
35
28
|
program_version: '',
|
36
29
|
submitted_at: '',
|
37
|
-
queries: [],
|
38
30
|
results: [],
|
31
|
+
queries: [],
|
39
32
|
querydb: [],
|
40
33
|
params: [],
|
41
34
|
stats: [],
|
@@ -43,7 +36,6 @@ class Report extends Component {
|
|
43
36
|
allQueriesLoaded: false,
|
44
37
|
cloud_sharing_enabled: false,
|
45
38
|
};
|
46
|
-
this.prepareAlignmentOfSelectedHits = this.prepareAlignmentOfSelectedHits.bind(this);
|
47
39
|
this.prepareAlignmentOfAllHits = this.prepareAlignmentOfAllHits.bind(this);
|
48
40
|
this.setStateFromJSON = this.setStateFromJSON.bind(this);
|
49
41
|
this.plugins = new ReportPlugins(this);
|
@@ -58,31 +50,66 @@ class Report extends Component {
|
|
58
50
|
}
|
59
51
|
|
60
52
|
pollPeriodically(path, callback, errCallback) {
|
61
|
-
|
53
|
+
var intervals = [200, 400, 800, 1200, 2000, 3000, 5000];
|
62
54
|
function poll() {
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
55
|
+
fetch(path)
|
56
|
+
.then(response => {
|
57
|
+
// Handle HTTP status codes
|
58
|
+
if (!response.ok) throw response;
|
59
|
+
|
60
|
+
return response.text().then(data => {
|
61
|
+
if (data) {
|
62
|
+
data = parseJSON(data);
|
63
|
+
};
|
64
|
+
return { status: response.status, data }
|
65
|
+
});
|
66
|
+
})
|
67
|
+
.then(({ status, data }) => {
|
68
|
+
switch (status) {
|
69
|
+
case 202:
|
70
|
+
var interval;
|
71
|
+
if (intervals.length === 1) {
|
72
|
+
interval = intervals[0];
|
73
|
+
} else {
|
74
|
+
interval = intervals.shift();
|
75
|
+
}
|
76
|
+
setTimeout(poll, interval);
|
77
|
+
break;
|
78
|
+
case 200:
|
79
|
+
callback(data);
|
80
|
+
break;
|
81
|
+
}
|
82
|
+
})
|
83
|
+
.catch(error => {
|
84
|
+
if (error.text) {
|
85
|
+
error.text().then(errData => {
|
86
|
+
errData = parseJSON(errData);
|
87
|
+
switch (error.status) {
|
88
|
+
case 400:
|
89
|
+
case 422:
|
90
|
+
case 500:
|
91
|
+
errCallback(errData);
|
92
|
+
break;
|
93
|
+
default:
|
94
|
+
console.error("Unhandled error:", error.status);
|
95
|
+
}
|
96
|
+
});
|
69
97
|
} else {
|
70
|
-
|
98
|
+
console.error("Network error:", error);
|
71
99
|
}
|
72
|
-
|
73
|
-
break;
|
74
|
-
case 200:
|
75
|
-
callback(jqXHR.responseJSON);
|
76
|
-
break;
|
77
|
-
case 400:
|
78
|
-
case 422:
|
79
|
-
case 500:
|
80
|
-
errCallback(jqXHR.responseJSON);
|
81
|
-
break;
|
82
|
-
}
|
83
|
-
});
|
100
|
+
});
|
84
101
|
}
|
85
102
|
|
103
|
+
function parseJSON(str) {
|
104
|
+
let parsedJson = str;
|
105
|
+
try {
|
106
|
+
parsedJson = JSON.parse(str);
|
107
|
+
} catch (e) {
|
108
|
+
console.error("Error parsing JSON:", e);
|
109
|
+
}
|
110
|
+
|
111
|
+
return parsedJson;
|
112
|
+
}
|
86
113
|
poll();
|
87
114
|
}
|
88
115
|
|
@@ -113,139 +140,6 @@ class Report extends Component {
|
|
113
140
|
this.toggleTable();
|
114
141
|
}
|
115
142
|
|
116
|
-
/**
|
117
|
-
* Called for the first time after as BLAST results have been retrieved from
|
118
|
-
* the server and added to this.state by fetchResults. Only summary overview
|
119
|
-
* and circos would have been rendered at this point. At this stage we kick
|
120
|
-
* start iteratively adding 1 HSP to the page every 25 milli-seconds.
|
121
|
-
*/
|
122
|
-
componentDidUpdate(prevProps, prevState) {
|
123
|
-
// Log to console how long the last update take?
|
124
|
-
// console.log((Date.now() - this.lastTimeStamp) / 1000);
|
125
|
-
|
126
|
-
// Lock sidebar in its position on the first update.
|
127
|
-
if (this.nextQuery == 0 && this.nextHit == 0 && this.nextHSP == 0) {
|
128
|
-
this.affixSidebar();
|
129
|
-
}
|
130
|
-
|
131
|
-
// Queue next update if we have not rendered all results yet.
|
132
|
-
if (this.nextQuery < this.state.queries.length) {
|
133
|
-
// setTimeout is used to clear call stack and space out
|
134
|
-
// the updates giving the browser a chance to respond
|
135
|
-
// to user interactions.
|
136
|
-
setTimeout(() => this.updateState(), 25);
|
137
|
-
} else {
|
138
|
-
this.componentFinishedUpdating();
|
139
|
-
}
|
140
|
-
|
141
|
-
this.plugins.componentDidUpdate(prevProps, prevState);
|
142
|
-
}
|
143
|
-
|
144
|
-
/**
|
145
|
-
* Push next slice of results to React for rendering.
|
146
|
-
*/
|
147
|
-
updateState() {
|
148
|
-
var results = [];
|
149
|
-
var numHSPsProcessed = 0;
|
150
|
-
while (this.nextQuery < this.state.queries.length) {
|
151
|
-
var query = this.state.queries[this.nextQuery];
|
152
|
-
|
153
|
-
// We may see a query multiple times during rendering because only
|
154
|
-
// 3 hsps are rendered in each cycle, but we want to create the
|
155
|
-
// corresponding Query component only the first time we see it.
|
156
|
-
if (this.nextHit == 0 && this.nextHSP == 0) {
|
157
|
-
results.push(
|
158
|
-
<ReportQuery
|
159
|
-
key={'Query_' + query.id}
|
160
|
-
query={query}
|
161
|
-
program={this.state.program}
|
162
|
-
querydb={this.state.querydb}
|
163
|
-
showQueryCrumbs={this.state.queries.length > 1}
|
164
|
-
non_parse_seqids={this.state.non_parse_seqids}
|
165
|
-
imported_xml={this.state.imported_xml}
|
166
|
-
veryBig={this.state.veryBig}
|
167
|
-
/>
|
168
|
-
);
|
169
|
-
|
170
|
-
results.push(...this.plugins.queryResults(query));
|
171
|
-
}
|
172
|
-
|
173
|
-
while (this.nextHit < query.hits.length) {
|
174
|
-
var hit = query.hits[this.nextHit];
|
175
|
-
// We may see a hit multiple times during rendering because only
|
176
|
-
// 10 hsps are rendered in each cycle, but we want to create the
|
177
|
-
// corresponding Hit component only the first time we see it.
|
178
|
-
if (this.nextHSP == 0) {
|
179
|
-
results.push(
|
180
|
-
<Hit
|
181
|
-
key={'Query_' + query.number + '_Hit_' + hit.number}
|
182
|
-
query={query}
|
183
|
-
hit={hit}
|
184
|
-
algorithm={this.state.program}
|
185
|
-
querydb={this.state.querydb}
|
186
|
-
selectHit={this.selectHit}
|
187
|
-
imported_xml={this.state.imported_xml}
|
188
|
-
non_parse_seqids={this.state.non_parse_seqids}
|
189
|
-
showQueryCrumbs={this.state.queries.length > 1}
|
190
|
-
showHitCrumbs={query.hits.length > 1}
|
191
|
-
veryBig={this.state.veryBig}
|
192
|
-
onChange={this.prepareAlignmentOfSelectedHits}
|
193
|
-
{...this.props}
|
194
|
-
/>
|
195
|
-
);
|
196
|
-
}
|
197
|
-
|
198
|
-
while (this.nextHSP < hit.hsps.length) {
|
199
|
-
// Get nextHSP and increment the counter.
|
200
|
-
var hsp = hit.hsps[this.nextHSP++];
|
201
|
-
results.push(
|
202
|
-
<HSP
|
203
|
-
key={
|
204
|
-
'Query_' +
|
205
|
-
query.number +
|
206
|
-
'_Hit_' +
|
207
|
-
hit.number +
|
208
|
-
'_HSP_' +
|
209
|
-
hsp.number
|
210
|
-
}
|
211
|
-
query={query}
|
212
|
-
hit={hit}
|
213
|
-
hsp={hsp}
|
214
|
-
algorithm={this.state.program}
|
215
|
-
showHSPNumbers={hit.hsps.length > 1}
|
216
|
-
{...this.props}
|
217
|
-
/>
|
218
|
-
);
|
219
|
-
numHSPsProcessed++;
|
220
|
-
if (numHSPsProcessed == this.maxHSPs) break;
|
221
|
-
}
|
222
|
-
// Are we here because we have iterated over all hsps of a hit,
|
223
|
-
// or because of the break clause in the inner loop?
|
224
|
-
if (this.nextHSP == hit.hsps.length) {
|
225
|
-
this.nextHit = this.nextHit + 1;
|
226
|
-
this.nextHSP = 0;
|
227
|
-
}
|
228
|
-
if (numHSPsProcessed == this.maxHSPs) break;
|
229
|
-
}
|
230
|
-
|
231
|
-
// Are we here because we have iterated over all hits of a query,
|
232
|
-
// or because of the break clause in the inner loop?
|
233
|
-
if (this.nextHit == query.hits.length) {
|
234
|
-
this.nextQuery = this.nextQuery + 1;
|
235
|
-
this.nextHit = 0;
|
236
|
-
}
|
237
|
-
if (numHSPsProcessed == this.maxHSPs) break;
|
238
|
-
}
|
239
|
-
|
240
|
-
// Push the components to react for rendering.
|
241
|
-
this.numUpdates++;
|
242
|
-
this.lastTimeStamp = Date.now();
|
243
|
-
this.setState({
|
244
|
-
results: this.state.results.concat(results),
|
245
|
-
veryBig: this.numUpdates >= 250,
|
246
|
-
});
|
247
|
-
}
|
248
|
-
|
249
143
|
/**
|
250
144
|
* Called after all results have been rendered.
|
251
145
|
*/
|
@@ -282,6 +176,7 @@ class Report extends Component {
|
|
282
176
|
);
|
283
177
|
}
|
284
178
|
|
179
|
+
/* eslint-disable */
|
285
180
|
/**
|
286
181
|
* Return results JSX.
|
287
182
|
*/
|
@@ -300,12 +195,20 @@ class Report extends Component {
|
|
300
195
|
<div className="col-md-9">
|
301
196
|
{this.overviewJSX()}
|
302
197
|
{this.circosJSX()}
|
303
|
-
{this.plugins.generateStats()}
|
198
|
+
{this.plugins.generateStats(this.state.queries)}
|
304
199
|
{this.state.results}
|
200
|
+
<Hits
|
201
|
+
state={this.state}
|
202
|
+
componentFinishedUpdating={(_) => this.componentFinishedUpdating(_)}
|
203
|
+
populate_hsp_array={this.populate_hsp_array.bind(this)}
|
204
|
+
plugins={this.plugins}
|
205
|
+
{...this.props}
|
206
|
+
/>
|
305
207
|
</div>
|
306
208
|
</div>
|
307
209
|
);
|
308
210
|
}
|
211
|
+
/* eslint-enable */
|
309
212
|
|
310
213
|
|
311
214
|
warningJSX() {
|
@@ -477,20 +380,7 @@ class Report extends Component {
|
|
477
380
|
);
|
478
381
|
}
|
479
382
|
|
480
|
-
|
481
|
-
* Affixes the sidebar.
|
482
|
-
*/
|
483
|
-
affixSidebar() {
|
484
|
-
var $sidebar = $('.sidebar');
|
485
|
-
var sidebarOffset = $sidebar.offset();
|
486
|
-
if (sidebarOffset) {
|
487
|
-
$sidebar.affix({
|
488
|
-
offset: {
|
489
|
-
top: sidebarOffset.top,
|
490
|
-
},
|
491
|
-
});
|
492
|
-
}
|
493
|
-
}
|
383
|
+
|
494
384
|
|
495
385
|
/**
|
496
386
|
* For the query in viewport, highlights corresponding entry in the index.
|
@@ -499,84 +389,10 @@ class Report extends Component {
|
|
499
389
|
$('body').scrollspy({ target: '.sidebar' });
|
500
390
|
}
|
501
391
|
|
502
|
-
/**
|
503
|
-
* Event-handler when hit is selected
|
504
|
-
* Adds glow to hit component.
|
505
|
-
* Updates number of Fasta that can be downloaded
|
506
|
-
*/
|
507
|
-
selectHit(id) {
|
508
|
-
var checkbox = $('#' + id);
|
509
|
-
var num_checked = $('.hit-links :checkbox:checked').length;
|
510
|
-
|
511
|
-
if (!checkbox || !checkbox.val()) {
|
512
|
-
return;
|
513
|
-
}
|
514
|
-
|
515
|
-
var $hit = $(checkbox.data('target'));
|
516
|
-
|
517
|
-
// Highlight selected hit and enable 'Download FASTA/Alignment of
|
518
|
-
// selected' links.
|
519
|
-
if (checkbox.is(':checked')) {
|
520
|
-
$hit.addClass('glow');
|
521
|
-
$hit.next('.hsp').addClass('glow');
|
522
|
-
$('.download-fasta-of-selected').enable();
|
523
|
-
$('.download-alignment-of-selected').enable();
|
524
|
-
} else {
|
525
|
-
$hit.removeClass('glow');
|
526
|
-
$hit.next('.hsp').removeClass('glow');
|
527
|
-
$('.download-fasta-of-selected').attr('href', '#').removeAttr('download');
|
528
|
-
}
|
529
|
-
|
530
|
-
var $a = $('.download-fasta-of-selected');
|
531
|
-
var $b = $('.download-alignment-of-selected');
|
532
|
-
|
533
|
-
if (num_checked >= 1) {
|
534
|
-
$a.find('.text-bold').html(num_checked);
|
535
|
-
$b.find('.text-bold').html(num_checked);
|
536
|
-
}
|
537
|
-
|
538
|
-
if (num_checked == 0) {
|
539
|
-
$a.addClass('disabled').find('.text-bold').html('');
|
540
|
-
$b.addClass('disabled').find('.text-bold').html('');
|
541
|
-
}
|
542
|
-
}
|
543
392
|
populate_hsp_array(hit, query_id){
|
544
393
|
return hit.hsps.map(hsp => Object.assign(hsp, {hit_id: hit.id, query_id}));
|
545
394
|
}
|
546
395
|
|
547
|
-
prepareAlignmentOfSelectedHits() {
|
548
|
-
var sequence_ids = $('.hit-links :checkbox:checked').map(function () {
|
549
|
-
return this.value;
|
550
|
-
}).get();
|
551
|
-
|
552
|
-
if(!sequence_ids.length){
|
553
|
-
// remove attributes from link if sequence_ids array is empty
|
554
|
-
$('.download-alignment-of-selected').attr('href', '#').removeAttr('download');
|
555
|
-
return;
|
556
|
-
|
557
|
-
}
|
558
|
-
if(this.state.alignment_blob_url){
|
559
|
-
// always revoke existing url if any because this method will always create a new url
|
560
|
-
window.URL.revokeObjectURL(this.state.alignment_blob_url);
|
561
|
-
}
|
562
|
-
var hsps_arr = [];
|
563
|
-
var aln_exporter = new AlignmentExporter();
|
564
|
-
const self = this;
|
565
|
-
_.each(this.state.queries, _.bind(function (query) {
|
566
|
-
_.each(query.hits, function (hit) {
|
567
|
-
if (_.indexOf(sequence_ids, hit.id) != -1) {
|
568
|
-
hsps_arr = hsps_arr.concat(self.populate_hsp_array(hit, query.id));
|
569
|
-
}
|
570
|
-
});
|
571
|
-
}, this));
|
572
|
-
const filename = 'alignment-' + sequence_ids.length + '_hits.txt';
|
573
|
-
const blob_url = aln_exporter.prepare_alignments_for_export(hsps_arr, filename);
|
574
|
-
// set required download attributes for link
|
575
|
-
$('.download-alignment-of-selected').attr('href', blob_url).attr('download', filename);
|
576
|
-
// track new url for future removal
|
577
|
-
this.setState({alignment_blob_url: blob_url});
|
578
|
-
}
|
579
|
-
|
580
396
|
prepareAlignmentOfAllHits() {
|
581
397
|
// Get number of hits and array of all hsps.
|
582
398
|
var num_hits = 0;
|
data/public/js/report_root.js
CHANGED
@@ -17,6 +17,8 @@ class Page extends Component {
|
|
17
17
|
this.showErrorModal = this.showErrorModal.bind(this);
|
18
18
|
this.getCharacterWidth = this.getCharacterWidth.bind(this);
|
19
19
|
this.hspChars = createRef();
|
20
|
+
this.sequenceModal = createRef();
|
21
|
+
this.errorModal = createRef();
|
20
22
|
}
|
21
23
|
componentDidMount() {
|
22
24
|
var job_id = location.pathname.split('/').pop();
|
@@ -24,11 +26,11 @@ class Page extends Component {
|
|
24
26
|
}
|
25
27
|
|
26
28
|
showSequenceModal(url) {
|
27
|
-
this.
|
29
|
+
this.sequenceModal.current.show(url);
|
28
30
|
}
|
29
31
|
|
30
32
|
showErrorModal(errorData, beforeShow) {
|
31
|
-
this.
|
33
|
+
this.errorModal.current.show(errorData, beforeShow);
|
32
34
|
}
|
33
35
|
|
34
36
|
getCharacterWidth() {
|
@@ -60,11 +62,11 @@ class Page extends Component {
|
|
60
62
|
<canvas id="png-exporter" hidden></canvas>
|
61
63
|
|
62
64
|
<SequenceModal
|
63
|
-
ref=
|
65
|
+
ref={this.sequenceModal}
|
64
66
|
showErrorModal={(...args) => this.showErrorModal(...args)}
|
65
67
|
/>
|
66
68
|
|
67
|
-
<ErrorModal ref=
|
69
|
+
<ErrorModal ref={this.errorModal} />
|
68
70
|
</div>
|
69
71
|
);
|
70
72
|
}
|
@@ -72,4 +74,4 @@ class Page extends Component {
|
|
72
74
|
|
73
75
|
|
74
76
|
const root = createRoot(document.getElementById('view'));
|
75
|
-
root.render(<Page />);
|
77
|
+
root.render(<Page />);
|
data/public/js/search.js
CHANGED
@@ -1,32 +1,49 @@
|
|
1
|
-
import
|
2
|
-
import React, { Component } from
|
3
|
-
import { createRoot } from
|
4
|
-
import { DnD } from
|
5
|
-
import { Form } from
|
6
|
-
import { SearchHeaderPlugin } from
|
1
|
+
import './jquery_world';
|
2
|
+
import React, { Component } from 'react';
|
3
|
+
import { createRoot } from 'react-dom/client';
|
4
|
+
import { DnD } from './dnd';
|
5
|
+
import { Form } from './form';
|
6
|
+
import { SearchHeaderPlugin } from 'search_header_plugin';
|
7
7
|
|
8
8
|
/**
|
9
9
|
* Clear sessionStorage on reload.
|
10
10
|
*/
|
11
|
-
|
11
|
+
const navigationEntry = performance.getEntriesByType('navigation')[0];
|
12
|
+
if (navigationEntry && navigationEntry.type === 'reload') {
|
12
13
|
sessionStorage.clear();
|
13
14
|
history.replaceState(null, "", location.href.split("?")[0]);
|
14
15
|
}
|
15
16
|
|
16
17
|
class Page extends Component {
|
18
|
+
constructor(props) {
|
19
|
+
super(props);
|
20
|
+
this.dnd = React.createRef();
|
21
|
+
this.form = React.createRef();
|
22
|
+
}
|
23
|
+
|
17
24
|
componentDidMount() {
|
18
|
-
this.
|
25
|
+
this.dnd.current.setState({ query: this.form.current.query.current })
|
19
26
|
}
|
27
|
+
|
20
28
|
render() {
|
21
29
|
return (
|
22
30
|
<div>
|
23
31
|
<SearchHeaderPlugin />
|
24
|
-
<DnD ref=
|
25
|
-
<Form ref=
|
32
|
+
<DnD ref={this.dnd} />
|
33
|
+
<Form ref={this.form} />
|
26
34
|
</div>
|
27
35
|
);
|
28
36
|
}
|
29
37
|
}
|
30
38
|
|
31
|
-
const root = createRoot(document.getElementById(
|
39
|
+
const root = createRoot(document.getElementById('view'));
|
32
40
|
root.render(<Page />);
|
41
|
+
|
42
|
+
document.addEventListener('DOMContentLoaded', () => {
|
43
|
+
const closeButton = document.querySelector('.js--close-help');
|
44
|
+
if (closeButton) {
|
45
|
+
closeButton.addEventListener('click', function() {
|
46
|
+
document.querySelector('[data-help-modal]').classList.add('hidden');
|
47
|
+
});
|
48
|
+
}
|
49
|
+
});
|