sequenceserver 3.1.1 → 3.1.3
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
});
|