@opencloning/ui 1.3.3 → 1.4.1
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/CHANGELOG.md +32 -0
- package/package.json +8 -8
- package/src/components/assembler/Assembler.cy.jsx +1 -143
- package/src/components/assembler/Assembler.jsx +4 -83
- package/src/components/assembler/ExistingSyntaxDialog.cy.jsx +52 -28
- package/src/components/assembler/ExistingSyntaxDialog.jsx +39 -8
- package/src/components/assembler/UploadPlasmidsButton.cy.jsx +239 -0
- package/src/components/assembler/UploadPlasmidsButton.jsx +121 -0
- package/src/components/assembler/usePlasmidsLogic.js +9 -5
- package/src/components/form/ServerStaticFileSelect.cy.jsx +466 -0
- package/src/components/form/ServerStaticFileSelect.jsx +134 -0
- package/src/components/sources/Source.jsx +3 -0
- package/src/components/sources/SourceServerStaticFile.jsx +26 -0
- package/src/components/sources/SourceTypeSelector.jsx +4 -1
- package/src/hooks/useRequestForEffect.js +28 -0
- package/src/hooks/useServerStaticFiles.js +56 -0
- package/src/version.js +1 -1
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ConfigProvider } from '../../providers/ConfigProvider';
|
|
3
|
+
import { localFilesHttpClient } from '../../hooks/useServerStaticFiles';
|
|
4
|
+
import ServerStaticFileSelect from './ServerStaticFileSelect';
|
|
5
|
+
|
|
6
|
+
// A common intercept does not work here, because we are requesting
|
|
7
|
+
// static files, and cypress intercepts don't work.
|
|
8
|
+
|
|
9
|
+
const config = {
|
|
10
|
+
backendUrl: 'http://localhost:8000',
|
|
11
|
+
staticContentPath: 'collection',
|
|
12
|
+
showAppBar: false,
|
|
13
|
+
noExternalRequests: false,
|
|
14
|
+
enableAssembler: true,
|
|
15
|
+
enablePlannotate: false,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const dummyIndex = {
|
|
19
|
+
sequences: [
|
|
20
|
+
{
|
|
21
|
+
name: 'Example sequence 1',
|
|
22
|
+
path: 'example.fa',
|
|
23
|
+
categories: ['Test category'],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'Example sequence 2',
|
|
27
|
+
path: 'example2.gb',
|
|
28
|
+
categories: ['Test category2'],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'Example sequence 3',
|
|
32
|
+
path: 'example3.fa',
|
|
33
|
+
categories: ['Test category'],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
syntaxes: [
|
|
37
|
+
{
|
|
38
|
+
name: 'Example syntax 1',
|
|
39
|
+
path: 'example.json',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'Example syntax 2',
|
|
43
|
+
path: 'example2.json',
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe('<ServerStaticFileSelect />', () => {
|
|
49
|
+
it('loads index, fetches file and calls onFileSelected', () => {
|
|
50
|
+
const httpGet = cy.stub(localFilesHttpClient, 'get').callsFake((url) => {
|
|
51
|
+
if (url.endsWith('/index.json')) {
|
|
52
|
+
return Promise.resolve({
|
|
53
|
+
data: dummyIndex,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (url.endsWith('/example.fa')) {
|
|
57
|
+
return Promise.resolve({ data: 'ATGC' });
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`Unexpected URL: ${url}`);
|
|
60
|
+
});
|
|
61
|
+
cy.wrap(httpGet).as('httpGet');
|
|
62
|
+
|
|
63
|
+
const onFileSelected = cy.spy().as('onFileSelected');
|
|
64
|
+
|
|
65
|
+
cy.mount(
|
|
66
|
+
<ConfigProvider config={config}>
|
|
67
|
+
<ServerStaticFileSelect
|
|
68
|
+
onFileSelected={onFileSelected}
|
|
69
|
+
/>
|
|
70
|
+
</ConfigProvider>,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
cy.get('@httpGet').should('have.been.calledWithMatch', 'index.json');
|
|
74
|
+
|
|
75
|
+
// Button is disabled
|
|
76
|
+
cy.contains('button', 'Submit').should('be.disabled');
|
|
77
|
+
|
|
78
|
+
// Select sequence
|
|
79
|
+
cy.get('#option-select').click();
|
|
80
|
+
cy.contains('Example sequence 1').click();
|
|
81
|
+
|
|
82
|
+
// Submit form
|
|
83
|
+
cy.contains('button', 'Submit').click();
|
|
84
|
+
|
|
85
|
+
cy.get('@httpGet').should('have.been.calledWithMatch', 'example.fa');
|
|
86
|
+
|
|
87
|
+
cy.get('@onFileSelected').should('have.been.calledOnce');
|
|
88
|
+
cy.get('@onFileSelected').should((spy) => {
|
|
89
|
+
const [file] = spy.lastCall.args;
|
|
90
|
+
expect(file.name).to.equal('example.fa');
|
|
91
|
+
expect(file.type).to.equal('text/plain');
|
|
92
|
+
expect(file.size).to.equal(4);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('handles index load failure and allows retry', () => {
|
|
97
|
+
let firstCall = true;
|
|
98
|
+
const httpGet = cy.stub(localFilesHttpClient, 'get').callsFake((url) => {
|
|
99
|
+
if (!url.endsWith('/index.json')) {
|
|
100
|
+
throw new Error(`Unexpected URL: ${url}`);
|
|
101
|
+
}
|
|
102
|
+
if (firstCall) {
|
|
103
|
+
firstCall = false;
|
|
104
|
+
return Promise.reject(new Error('Network error'));
|
|
105
|
+
}
|
|
106
|
+
return Promise.resolve({
|
|
107
|
+
data: dummyIndex,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
cy.wrap(httpGet).as('httpGet');
|
|
111
|
+
|
|
112
|
+
cy.mount(
|
|
113
|
+
<ConfigProvider config={config}>
|
|
114
|
+
<ServerStaticFileSelect
|
|
115
|
+
onFileSelected={cy.spy()}
|
|
116
|
+
/>
|
|
117
|
+
</ConfigProvider>,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
cy.get('@httpGet').should('have.been.calledWithMatch', 'index.json');
|
|
121
|
+
|
|
122
|
+
// First request failed, should show retry alert
|
|
123
|
+
cy.get('.MuiAlert-colorError').should('exist');
|
|
124
|
+
cy.contains('Retry').click();
|
|
125
|
+
|
|
126
|
+
// Second request succeeds
|
|
127
|
+
cy.get('@httpGet').should('have.been.calledTwice');
|
|
128
|
+
|
|
129
|
+
// Error alert should disappear and form should be visible
|
|
130
|
+
cy.get('.MuiAlert-colorError').should('not.exist');
|
|
131
|
+
cy.get('#option-select').should('exist');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('handles invalid sequence and file request failure', () => {
|
|
135
|
+
const httpGet = cy.stub(localFilesHttpClient, 'get').callsFake((url) => {
|
|
136
|
+
if (url.endsWith('/index.json')) {
|
|
137
|
+
return Promise.resolve({
|
|
138
|
+
data: {
|
|
139
|
+
sequences: [
|
|
140
|
+
{
|
|
141
|
+
name: 'No path sequence',
|
|
142
|
+
categories: ['Test category'],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'Valid path sequence',
|
|
146
|
+
path: 'valid.fa',
|
|
147
|
+
categories: ['Test category'],
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (url.endsWith('/valid.fa')) {
|
|
154
|
+
return Promise.reject(new Error('Server error'));
|
|
155
|
+
}
|
|
156
|
+
throw new Error(`Unexpected URL: ${url}`);
|
|
157
|
+
});
|
|
158
|
+
cy.wrap(httpGet).as('httpGet');
|
|
159
|
+
|
|
160
|
+
const onFileSelected = cy.spy().as('onFileSelected');
|
|
161
|
+
|
|
162
|
+
cy.mount(
|
|
163
|
+
<ConfigProvider config={config}>
|
|
164
|
+
<ServerStaticFileSelect
|
|
165
|
+
onFileSelected={onFileSelected}
|
|
166
|
+
/>
|
|
167
|
+
</ConfigProvider>,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
cy.get('@httpGet').should('have.been.calledWithMatch', 'index.json');
|
|
171
|
+
|
|
172
|
+
// Select category
|
|
173
|
+
cy.get('#category-select').click();
|
|
174
|
+
cy.contains('Test category').click();
|
|
175
|
+
|
|
176
|
+
// First: select malformed sequence (no path)
|
|
177
|
+
cy.get('#option-select').click();
|
|
178
|
+
cy.contains('No path sequence').click();
|
|
179
|
+
cy.contains('button', 'Submit').click();
|
|
180
|
+
|
|
181
|
+
cy.get('.MuiAlert-colorError')
|
|
182
|
+
.contains('Malformatted option, must have a path')
|
|
183
|
+
.should('exist');
|
|
184
|
+
cy.get('@onFileSelected').should('not.have.been.called');
|
|
185
|
+
|
|
186
|
+
// Then: select valid sequence but make file request fail
|
|
187
|
+
cy.get('#option-select').click();
|
|
188
|
+
cy.contains('Valid path sequence').click();
|
|
189
|
+
cy.contains('button', 'Submit').click();
|
|
190
|
+
|
|
191
|
+
cy.get('.MuiAlert-colorError')
|
|
192
|
+
.contains('Error requesting file')
|
|
193
|
+
.should('exist');
|
|
194
|
+
cy.get('@onFileSelected').should('not.have.been.called');
|
|
195
|
+
});
|
|
196
|
+
it('handles filtering by categories', () => {
|
|
197
|
+
const httpGet = cy.stub(localFilesHttpClient, 'get').callsFake((url) => {
|
|
198
|
+
if (url.endsWith('/index.json')) {
|
|
199
|
+
return Promise.resolve({
|
|
200
|
+
data: dummyIndex,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
cy.wrap(httpGet).as('httpGet');
|
|
205
|
+
|
|
206
|
+
cy.mount(
|
|
207
|
+
<ConfigProvider config={config}>
|
|
208
|
+
<ServerStaticFileSelect
|
|
209
|
+
onFileSelected={cy.spy()}
|
|
210
|
+
/>
|
|
211
|
+
</ConfigProvider>,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
cy.get('@httpGet').should('have.been.calledWithMatch', 'index.json');
|
|
215
|
+
|
|
216
|
+
// Select category
|
|
217
|
+
cy.get('#category-select').click();
|
|
218
|
+
cy.contains('Test category').click();
|
|
219
|
+
|
|
220
|
+
// Only one sequence should be shown
|
|
221
|
+
cy.get('#option-select').click();
|
|
222
|
+
cy.contains('Example sequence 1').should('exist');
|
|
223
|
+
cy.contains('Example sequence 2').should('not.exist');
|
|
224
|
+
cy.contains('Example sequence 3').should('exist');
|
|
225
|
+
// Select sequence
|
|
226
|
+
cy.contains('Example sequence 1').click();
|
|
227
|
+
|
|
228
|
+
// Select All
|
|
229
|
+
cy.get('#category-select').click();
|
|
230
|
+
cy.contains('All').click();
|
|
231
|
+
|
|
232
|
+
// Nothing should be selected
|
|
233
|
+
cy.get('#option-select').should('have.value', '');
|
|
234
|
+
});
|
|
235
|
+
it('works with syntaxes', () => {
|
|
236
|
+
const httpGet = cy.stub(localFilesHttpClient, 'get').callsFake((url) => {
|
|
237
|
+
if (url.endsWith('/index.json')) {
|
|
238
|
+
return Promise.resolve({
|
|
239
|
+
data: dummyIndex,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (url.endsWith('/example.json')) {
|
|
243
|
+
return Promise.resolve({ data: '{"name": "Example syntax 1"}' });
|
|
244
|
+
}
|
|
245
|
+
throw new Error(`Unexpected URL: ${url}`);
|
|
246
|
+
});
|
|
247
|
+
cy.wrap(httpGet).as('httpGet');
|
|
248
|
+
|
|
249
|
+
const onFileSelected = cy.spy().as('onFileSelected');
|
|
250
|
+
cy.mount(
|
|
251
|
+
<ConfigProvider config={config}>
|
|
252
|
+
<ServerStaticFileSelect
|
|
253
|
+
onFileSelected={onFileSelected}
|
|
254
|
+
type="syntax"
|
|
255
|
+
/>
|
|
256
|
+
</ConfigProvider>,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
cy.get('@httpGet').should('have.been.calledWithMatch', 'index.json');
|
|
260
|
+
|
|
261
|
+
// Select syntax
|
|
262
|
+
cy.get('#option-select').click();
|
|
263
|
+
cy.contains('Example syntax 1').click();
|
|
264
|
+
|
|
265
|
+
// Submit form
|
|
266
|
+
cy.contains('button', 'Submit').click();
|
|
267
|
+
cy.get('@httpGet').should('have.been.calledWithMatch', 'example.json');
|
|
268
|
+
|
|
269
|
+
cy.get('@onFileSelected').should('have.been.calledOnce');
|
|
270
|
+
cy.get('@onFileSelected').should((spy) => {
|
|
271
|
+
const [file] = spy.lastCall.args;
|
|
272
|
+
expect(file.name).to.equal('example.json');
|
|
273
|
+
expect(file.type).to.equal('text/plain');
|
|
274
|
+
expect(file.size).to.equal(28);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
it('allows selecting all sequences with the Select all option when multiple', () => {
|
|
278
|
+
const httpGet = cy.stub(localFilesHttpClient, 'get').callsFake((url) => {
|
|
279
|
+
if (url.endsWith('/index.json')) {
|
|
280
|
+
return Promise.resolve({
|
|
281
|
+
data: dummyIndex,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (url.endsWith('/example.fa')) {
|
|
285
|
+
return Promise.resolve({ data: 'ATGC' });
|
|
286
|
+
}
|
|
287
|
+
if (url.endsWith('/example2.gb')) {
|
|
288
|
+
return Promise.resolve({ data: 'ATGCA' });
|
|
289
|
+
}
|
|
290
|
+
if (url.endsWith('/example3.fa')) {
|
|
291
|
+
return Promise.resolve({ data: 'ATGCG' });
|
|
292
|
+
}
|
|
293
|
+
throw new Error(`Unexpected URL: ${url}`);
|
|
294
|
+
});
|
|
295
|
+
cy.wrap(httpGet).as('httpGet');
|
|
296
|
+
|
|
297
|
+
const onFileSelected = cy.spy().as('onFileSelected');
|
|
298
|
+
cy.mount(
|
|
299
|
+
<ConfigProvider config={config}>
|
|
300
|
+
<ServerStaticFileSelect
|
|
301
|
+
onFileSelected={onFileSelected}
|
|
302
|
+
multiple
|
|
303
|
+
/>
|
|
304
|
+
</ConfigProvider>,
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
cy.get('@httpGet').should('have.been.calledWithMatch', 'index.json');
|
|
308
|
+
|
|
309
|
+
// Use the Select all option to select all sequences
|
|
310
|
+
cy.get('#option-select').click();
|
|
311
|
+
cy.contains('Select all').click();
|
|
312
|
+
cy.get('body').click(0, 0);
|
|
313
|
+
|
|
314
|
+
// Submit form
|
|
315
|
+
cy.contains('button', 'Submit').click();
|
|
316
|
+
|
|
317
|
+
cy.get('@httpGet').should('have.been.calledWithMatch', 'example.fa');
|
|
318
|
+
cy.get('@httpGet').should('have.been.calledWithMatch', 'example2.gb');
|
|
319
|
+
cy.get('@httpGet').should('have.been.calledWithMatch', 'example3.fa');
|
|
320
|
+
|
|
321
|
+
cy.get('@onFileSelected').should('have.been.calledOnce');
|
|
322
|
+
cy.get('@onFileSelected').should((spy) => {
|
|
323
|
+
const [files] = spy.lastCall.args;
|
|
324
|
+
expect(files).to.have.length(3);
|
|
325
|
+
expect(files[0].name).to.equal('example.fa');
|
|
326
|
+
expect(files[0].type).to.equal('text/plain');
|
|
327
|
+
expect(files[1].name).to.equal('example2.gb');
|
|
328
|
+
expect(files[1].type).to.equal('text/plain');
|
|
329
|
+
expect(files[2].name).to.equal('example3.fa');
|
|
330
|
+
expect(files[2].type).to.equal('text/plain');
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
it('clicking select all only selects the sequences that were filtered by category', () => {
|
|
334
|
+
const httpGet = cy.stub(localFilesHttpClient, 'get').callsFake((url) => {
|
|
335
|
+
if (url.endsWith('/index.json')) {
|
|
336
|
+
return Promise.resolve({
|
|
337
|
+
data: dummyIndex,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
if (url.endsWith('/example.fa')) {
|
|
341
|
+
return Promise.resolve({ data: 'ATGC' });
|
|
342
|
+
}
|
|
343
|
+
if (url.endsWith('/example3.fa')) {
|
|
344
|
+
return Promise.resolve({ data: 'ATGCG' });
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
cy.wrap(httpGet).as('httpGet');
|
|
348
|
+
const onFileSelected = cy.spy().as('onFileSelected');
|
|
349
|
+
cy.mount(
|
|
350
|
+
<ConfigProvider config={config}>
|
|
351
|
+
<ServerStaticFileSelect
|
|
352
|
+
onFileSelected={onFileSelected}
|
|
353
|
+
multiple
|
|
354
|
+
/>
|
|
355
|
+
</ConfigProvider>,
|
|
356
|
+
);
|
|
357
|
+
cy.get('@httpGet').should('have.been.calledWithMatch', 'index.json');
|
|
358
|
+
|
|
359
|
+
// Select category
|
|
360
|
+
cy.get('#category-select').click();
|
|
361
|
+
cy.contains('Test category').click();
|
|
362
|
+
|
|
363
|
+
// Select all
|
|
364
|
+
cy.get('#option-select').click();
|
|
365
|
+
cy.contains('Select all').click();
|
|
366
|
+
|
|
367
|
+
// Only one sequence should be shown
|
|
368
|
+
cy.get('#option-select').click();
|
|
369
|
+
cy.contains('Example sequence 1').should('exist');
|
|
370
|
+
cy.contains('Example sequence 2').should('not.exist');
|
|
371
|
+
cy.contains('Example sequence 3').should('exist');
|
|
372
|
+
|
|
373
|
+
// Click outside to close select element
|
|
374
|
+
cy.get('body').click(0, 0);
|
|
375
|
+
cy.contains('button', 'Submit').click();
|
|
376
|
+
|
|
377
|
+
cy.get('@httpGet').should('have.been.calledThrice');
|
|
378
|
+
cy.get('@onFileSelected').should('have.been.calledOnce');
|
|
379
|
+
cy.get('@onFileSelected').should((spy) => {
|
|
380
|
+
const [files] = spy.lastCall.args;
|
|
381
|
+
expect(files).to.have.length(2);
|
|
382
|
+
expect(files[0].name).to.equal('example.fa');
|
|
383
|
+
expect(files[1].name).to.equal('example3.fa');
|
|
384
|
+
});
|
|
385
|
+
})
|
|
386
|
+
it('works with multiple', () => {
|
|
387
|
+
const httpGet = cy.stub(localFilesHttpClient, 'get').callsFake((url) => {
|
|
388
|
+
if (url.endsWith('/index.json')) {
|
|
389
|
+
return Promise.resolve({
|
|
390
|
+
data: dummyIndex,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
if (url.endsWith('/example.fa')) {
|
|
394
|
+
return Promise.resolve({ data: 'ATGC' });
|
|
395
|
+
}
|
|
396
|
+
if (url.endsWith('/example2.gb')) {
|
|
397
|
+
return Promise.resolve({ data: 'ATGCA' });
|
|
398
|
+
}
|
|
399
|
+
throw new Error(`Unexpected URL: ${url}`);
|
|
400
|
+
});
|
|
401
|
+
cy.wrap(httpGet).as('httpGet');
|
|
402
|
+
const onFileSelected = cy.spy().as('onFileSelected');
|
|
403
|
+
cy.mount(
|
|
404
|
+
<ConfigProvider config={config}>
|
|
405
|
+
<ServerStaticFileSelect
|
|
406
|
+
onFileSelected={onFileSelected}
|
|
407
|
+
multiple
|
|
408
|
+
/>
|
|
409
|
+
</ConfigProvider>,
|
|
410
|
+
);
|
|
411
|
+
cy.get('@httpGet').should('have.been.calledWithMatch', 'index.json');
|
|
412
|
+
|
|
413
|
+
// Select sequence
|
|
414
|
+
cy.get('#option-select').click();
|
|
415
|
+
cy.contains('Example sequence 1').click();
|
|
416
|
+
cy.contains('Example sequence 2').click();
|
|
417
|
+
// Click outside to close select element
|
|
418
|
+
cy.get('body').click(0, 0);
|
|
419
|
+
cy.contains('button', 'Submit').click();
|
|
420
|
+
|
|
421
|
+
cy.get('@httpGet').should('have.been.calledThrice');
|
|
422
|
+
cy.get('@onFileSelected').should('have.been.calledOnce');
|
|
423
|
+
cy.get('@onFileSelected').should((spy) => {
|
|
424
|
+
const [files] = spy.lastCall.args;
|
|
425
|
+
expect(files).to.have.length(2);
|
|
426
|
+
expect(files[0].name).to.equal('example.fa');
|
|
427
|
+
expect(files[0].type).to.equal('text/plain');
|
|
428
|
+
expect(files[0].size).to.equal(4);
|
|
429
|
+
expect(files[1].name).to.equal('example2.gb');
|
|
430
|
+
expect(files[1].type).to.equal('text/plain');
|
|
431
|
+
expect(files[1].size).to.equal(5);
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('can filter with multiple', () => {
|
|
436
|
+
const httpGet = cy.stub(localFilesHttpClient, 'get').callsFake((url) => {
|
|
437
|
+
if (url.endsWith('/index.json')) {
|
|
438
|
+
return Promise.resolve({
|
|
439
|
+
data: dummyIndex,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
cy.wrap(httpGet).as('httpGet');
|
|
445
|
+
cy.mount(
|
|
446
|
+
<ConfigProvider config={config}>
|
|
447
|
+
<ServerStaticFileSelect
|
|
448
|
+
onFileSelected={cy.spy()}
|
|
449
|
+
multiple
|
|
450
|
+
/>
|
|
451
|
+
</ConfigProvider>,
|
|
452
|
+
);
|
|
453
|
+
cy.get('@httpGet').should('have.been.calledWithMatch', 'index.json');
|
|
454
|
+
|
|
455
|
+
// Select category
|
|
456
|
+
cy.get('#category-select').click();
|
|
457
|
+
cy.contains('Test category').click();
|
|
458
|
+
|
|
459
|
+
// Only one sequence should be shown
|
|
460
|
+
cy.get('#option-select').click();
|
|
461
|
+
cy.contains('Example sequence 1').should('exist');
|
|
462
|
+
cy.contains('Example sequence 2').should('not.exist');
|
|
463
|
+
cy.contains('Example sequence 3').should('exist');
|
|
464
|
+
|
|
465
|
+
});
|
|
466
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import useServerStaticFiles from '../../hooks/useServerStaticFiles';
|
|
3
|
+
import RequestStatusWrapper from './RequestStatusWrapper';
|
|
4
|
+
import { Alert, Autocomplete, Button, FormControl, TextField } from '@mui/material';
|
|
5
|
+
|
|
6
|
+
function ServerStaticFileSelect({ onFileSelected, multiple = false, type = 'sequence' }) {
|
|
7
|
+
const [selectedCategory, setSelectedCategory] = React.useState('');
|
|
8
|
+
const [selectedOptions, setSelectedOptions] = React.useState(multiple ? [] : null);
|
|
9
|
+
const [error, setError] = React.useState(null);
|
|
10
|
+
|
|
11
|
+
const localFiles = useServerStaticFiles();
|
|
12
|
+
const{ index, indexRequestStatus, indexRetry, requestFile } = localFiles;
|
|
13
|
+
|
|
14
|
+
const options = React.useMemo(() => {
|
|
15
|
+
if (index === null) return [];
|
|
16
|
+
if (type !== 'sequence') {
|
|
17
|
+
return index.syntaxes;
|
|
18
|
+
}
|
|
19
|
+
const prePendArray = multiple ? ['__all__'] : [];
|
|
20
|
+
if (selectedCategory === '') {
|
|
21
|
+
return [...prePendArray, ...index.sequences];
|
|
22
|
+
}
|
|
23
|
+
return [...prePendArray, ...index.sequences.filter((sequence) => sequence.categories?.includes(selectedCategory))];
|
|
24
|
+
}, [type, index, selectedCategory, multiple]);
|
|
25
|
+
|
|
26
|
+
const categoryOptions = React.useMemo(() => {
|
|
27
|
+
if (!index?.categories) return [];
|
|
28
|
+
return ['All', ...index.categories];
|
|
29
|
+
}, [index]);
|
|
30
|
+
|
|
31
|
+
const optionToFile = React.useCallback(async (option) => {
|
|
32
|
+
if (!option.path) {
|
|
33
|
+
throw new Error('Malformatted option, must have a path');
|
|
34
|
+
}
|
|
35
|
+
let fileContent = null;
|
|
36
|
+
try {
|
|
37
|
+
fileContent = await requestFile(option.path);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new Error(`Error requesting file: ${error.message}`);
|
|
40
|
+
}
|
|
41
|
+
if (typeof fileContent !== 'string') {
|
|
42
|
+
fileContent = JSON.stringify(fileContent);
|
|
43
|
+
}
|
|
44
|
+
const file = new File([fileContent], option.path, { type: 'text/plain' });
|
|
45
|
+
return file;
|
|
46
|
+
}, [requestFile]);
|
|
47
|
+
|
|
48
|
+
const onSubmit = React.useCallback(async (e) => {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
setError(null);
|
|
51
|
+
try {
|
|
52
|
+
if (!multiple) {
|
|
53
|
+
const file = await optionToFile(selectedOptions);
|
|
54
|
+
onFileSelected(file);
|
|
55
|
+
} else {
|
|
56
|
+
const files = await Promise.all(selectedOptions.map((option) => optionToFile(option)));
|
|
57
|
+
onFileSelected(files);
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
setError(error.message);
|
|
61
|
+
}
|
|
62
|
+
}, [multiple, selectedOptions, onFileSelected, optionToFile]);
|
|
63
|
+
|
|
64
|
+
const onCategoryChange = React.useCallback((event, newValue) => {
|
|
65
|
+
setSelectedOptions(multiple ? [] : null);
|
|
66
|
+
if (!newValue || newValue === 'All') {
|
|
67
|
+
setSelectedCategory('');
|
|
68
|
+
} else {
|
|
69
|
+
setSelectedCategory(newValue);
|
|
70
|
+
}
|
|
71
|
+
}, [multiple]);
|
|
72
|
+
|
|
73
|
+
const buttonDisabled = multiple ? selectedOptions.length === 0 : !selectedOptions;
|
|
74
|
+
|
|
75
|
+
const label = type === 'sequence' ? 'Sequence' : 'Syntax';
|
|
76
|
+
|
|
77
|
+
const onOptionsChange = React.useCallback((event, value) => {
|
|
78
|
+
console.log('onOptionsChange', value);
|
|
79
|
+
if (multiple && type === 'sequence') {
|
|
80
|
+
if (value.includes('__all__')) {
|
|
81
|
+
const allSequences = options.filter((option) => option !== '__all__');
|
|
82
|
+
setSelectedOptions(allSequences);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
setSelectedOptions(value);
|
|
87
|
+
}, [multiple, type, options]);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<RequestStatusWrapper requestStatus={indexRequestStatus} retry={indexRetry}>
|
|
91
|
+
{error && <Alert severity="error">{error}</Alert>}
|
|
92
|
+
<form onSubmit={onSubmit}>
|
|
93
|
+
{type === 'sequence' && (
|
|
94
|
+
<FormControl fullWidth sx={{ my: 1 }}>
|
|
95
|
+
<Autocomplete
|
|
96
|
+
id="category-select"
|
|
97
|
+
options={categoryOptions}
|
|
98
|
+
value={selectedCategory === '' ? 'All' : selectedCategory}
|
|
99
|
+
onChange={onCategoryChange}
|
|
100
|
+
renderInput={(params) => (
|
|
101
|
+
<TextField
|
|
102
|
+
{...params}
|
|
103
|
+
label="Category"
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
/>
|
|
107
|
+
</FormControl>
|
|
108
|
+
)}
|
|
109
|
+
<FormControl fullWidth sx={{ my: 1 }}>
|
|
110
|
+
<Autocomplete
|
|
111
|
+
id="option-select"
|
|
112
|
+
multiple={multiple}
|
|
113
|
+
options={options}
|
|
114
|
+
value={selectedOptions}
|
|
115
|
+
onChange={onOptionsChange}
|
|
116
|
+
getOptionLabel={(option) => (option === '__all__' ? 'Select all' : option?.name || option?.path || '')}
|
|
117
|
+
disableCloseOnSelect={multiple}
|
|
118
|
+
renderInput={(params) => (
|
|
119
|
+
<TextField
|
|
120
|
+
{...params}
|
|
121
|
+
label={label}
|
|
122
|
+
/>
|
|
123
|
+
)}
|
|
124
|
+
/>
|
|
125
|
+
</FormControl>
|
|
126
|
+
<FormControl fullWidth>
|
|
127
|
+
<Button disabled={buttonDisabled} type="submit" variant="contained" color="primary">Submit</Button>
|
|
128
|
+
</FormControl>
|
|
129
|
+
</form>
|
|
130
|
+
</RequestStatusWrapper>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export default ServerStaticFileSelect
|
|
@@ -21,6 +21,7 @@ import SourceCopySequence from './SourceCopySequence';
|
|
|
21
21
|
import SourceReverseComplement from './SourceReverseComplement';
|
|
22
22
|
import SourceKnownGenomeRegion from './SourceKnownGenomeRegion';
|
|
23
23
|
import { doesSourceHaveOutput } from '@opencloning/store/cloning_utils';
|
|
24
|
+
import SourceServerStaticFile from './SourceServerStaticFile';
|
|
24
25
|
|
|
25
26
|
// There are several types of source, this components holds the common part,
|
|
26
27
|
// which for now is a select element to pick which kind of source is created
|
|
@@ -49,6 +50,8 @@ function Source({ sourceId }) {
|
|
|
49
50
|
/* eslint-disable */
|
|
50
51
|
case 'UploadedFileSource':
|
|
51
52
|
specificSource = <SourceFile {...{ source, requestStatus, sendPostRequest }} />; break;
|
|
53
|
+
case 'LocalFileSource':
|
|
54
|
+
specificSource = <SourceServerStaticFile {...{ source, requestStatus, sendPostRequest }} />; break;
|
|
52
55
|
case 'RestrictionEnzymeDigestionSource':
|
|
53
56
|
specificSource = <SourceRestriction {...{ source, requestStatus, sendPostRequest }} />; break;
|
|
54
57
|
case 'RepositoryIdSource':
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import ServerStaticFileSelect from '../form/ServerStaticFileSelect';
|
|
3
|
+
import { Alert, LinearProgress } from '@mui/material';
|
|
4
|
+
|
|
5
|
+
function SourceServerStaticFile({ source, requestStatus, sendPostRequest }) {
|
|
6
|
+
const onFileSelected = React.useCallback((file) => {
|
|
7
|
+
const requestData = new FormData();
|
|
8
|
+
requestData.append('file', file);
|
|
9
|
+
const config = {
|
|
10
|
+
headers: {
|
|
11
|
+
'content-type': 'multipart/form-data',
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
sendPostRequest({ endpoint: 'read_from_file', requestData, config, source });
|
|
15
|
+
}, [sendPostRequest, source]);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<ServerStaticFileSelect onFileSelected={onFileSelected} />
|
|
20
|
+
{ requestStatus.status === 'loading' && <LinearProgress /> }
|
|
21
|
+
{ requestStatus.status === 'error' && <Alert severity="error">{requestStatus.message}</Alert> }
|
|
22
|
+
</>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default SourceServerStaticFile
|
|
@@ -13,7 +13,7 @@ function SourceTypeSelector({ source }) {
|
|
|
13
13
|
const dispatch = useDispatch();
|
|
14
14
|
const database = useDatabase();
|
|
15
15
|
const sourceIsPrimerDesign = useSelector((state) => Boolean(state.cloning.sequences.find((e) => e.id === source.id)?.primer_design));
|
|
16
|
-
const { noExternalRequests, enablePlannotate } = useConfig();
|
|
16
|
+
const { noExternalRequests, enablePlannotate, staticContentPath } = useConfig();
|
|
17
17
|
|
|
18
18
|
const onChange = (event) => {
|
|
19
19
|
// Clear the source other than these fields
|
|
@@ -28,6 +28,9 @@ function SourceTypeSelector({ source }) {
|
|
|
28
28
|
const options = [];
|
|
29
29
|
if (inputSequences.length === 0) {
|
|
30
30
|
options.push(<MenuItem key="UploadedFileSource" value="UploadedFileSource">Submit file</MenuItem>);
|
|
31
|
+
if (staticContentPath) {
|
|
32
|
+
options.push(<MenuItem key="LocalFileSource" value="LocalFileSource">Local server file</MenuItem>);
|
|
33
|
+
}
|
|
31
34
|
if (!noExternalRequests) {
|
|
32
35
|
options.push(<MenuItem key="RepositoryIdSource" value="RepositoryIdSource">Repository</MenuItem>);
|
|
33
36
|
options.push(<MenuItem key="GenomeCoordinatesSource" value="GenomeCoordinatesSource">Genome region</MenuItem>);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
function useRequestForEffect({ requestFunction, onSuccess }) {
|
|
4
|
+
const [requestStatus, setRequestStatus] = React.useState({ status: 'loading', message: '' });
|
|
5
|
+
const [connectAttempt, setConnectAttempt] = React.useState(0);
|
|
6
|
+
|
|
7
|
+
const retry = React.useCallback(() => {
|
|
8
|
+
setConnectAttempt((prev) => prev + 1);
|
|
9
|
+
}, []);
|
|
10
|
+
|
|
11
|
+
React.useEffect(() => {
|
|
12
|
+
const fetchData = async () => {
|
|
13
|
+
setRequestStatus({ status: 'loading', message: '' });
|
|
14
|
+
try {
|
|
15
|
+
const resp = await requestFunction();
|
|
16
|
+
setRequestStatus({ status: 'success', message: '' });
|
|
17
|
+
onSuccess(resp);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
setRequestStatus({ status: 'error', message: error.message });
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
fetchData();
|
|
23
|
+
}, [connectAttempt, requestFunction, onSuccess]);
|
|
24
|
+
|
|
25
|
+
return React.useMemo(() => ({ requestStatus, retry }), [requestStatus, retry]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default useRequestForEffect
|