@opencloning/opencloningdb 1.9.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/package.json +8 -7
- package/src/GetSequenceFileAndDatabaseIdComponent.cy.jsx +0 -1
- package/src/GetSequencesInBulkComponent.cy.jsx +55 -5
- package/src/GetSequencesInBulkComponent.jsx +21 -4
- package/src/LoadHistoryComponent.cy.jsx +25 -23
- package/src/OpenCloningDBInterface.test.js +3 -1
- package/src/SequenceTypeSelect.cy.jsx +52 -0
- package/src/SequenceTypeSelect.jsx +56 -0
- package/src/SubmitCloningStrategiesInBulkComponent.cy.jsx +2 -1
- package/src/bulk_upload/BulkUploadPreview.jsx +1 -1
- package/src/bulk_upload/common.jsx +26 -10
- package/src/endpoints.js +2 -0
- package/src/index.js +4 -0
- package/src/sequenceTypes.js +10 -0
- package/src/sequencing_check/SequencingAlignmentViewer.jsx +49 -0
- package/src/sequencing_check/SequencingCheckModal.cy.jsx +258 -0
- package/src/sequencing_check/SequencingCheckModal.jsx +90 -0
- package/src/sequencing_check/SequencingCheckTable.jsx +112 -0
- package/src/sequencing_check/sequencingCheckUtils.js +134 -0
- package/src/sequencing_check/sequencingCheckUtils.test.js +143 -0
- package/src/sequencing_check/useSequencingCheck.js +114 -0
- package/src/sequencing_check/useSequencingQueries.js +51 -0
- package/src/testUtils.test.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @opencloning/opencloningdb
|
|
2
2
|
|
|
3
|
+
## 1.9.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [[`0b2a21d`](https://github.com/OpenCloning/OpenCloning_frontend/commit/0b2a21dde79f5539579bee39f0de49b01cd6c10c)]:
|
|
8
|
+
- @opencloning/utils@2.1.1
|
|
9
|
+
- @opencloning/ui@2.1.1
|
|
10
|
+
|
|
3
11
|
## 1.9.0
|
|
4
12
|
|
|
5
13
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"license": "MIT",
|
|
3
3
|
"name": "@opencloning/opencloningdb",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./src/index.js"
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./sequenceTypes": "./src/sequenceTypes.js"
|
|
9
10
|
},
|
|
10
11
|
"repository": {
|
|
11
12
|
"type": "git",
|
|
12
|
-
"url": "https://github.com/OpenCloning/
|
|
13
|
-
"directory": "packages/opencloningdb"
|
|
13
|
+
"url": "https://github.com/OpenCloning/OpenCloning.git",
|
|
14
|
+
"directory": "js/packages/opencloningdb"
|
|
14
15
|
},
|
|
15
16
|
"dependencies": {
|
|
16
17
|
"@mui/icons-material": "^5.18.0",
|
|
17
18
|
"@mui/material": "^5.18.0",
|
|
18
|
-
"@opencloning/ui": "2.
|
|
19
|
-
"@opencloning/utils": "2.
|
|
20
|
-
"axios": "^1.
|
|
19
|
+
"@opencloning/ui": "2.2.0",
|
|
20
|
+
"@opencloning/utils": "2.2.0",
|
|
21
|
+
"axios": "^1.21.0"
|
|
21
22
|
},
|
|
22
23
|
"peerDependencies": {
|
|
23
24
|
"react": "^18.3.1",
|
|
@@ -13,6 +13,7 @@ function expectLoadedSequences(spy, stubBody) {
|
|
|
13
13
|
expect(sequences).to.deep.equal(stubBody.map(({ sequence_ref, sequence }) => ({
|
|
14
14
|
databaseId: sequence_ref.id,
|
|
15
15
|
name: formatSequenceName(sequence_ref),
|
|
16
|
+
seguid: sequence_ref.seguid,
|
|
16
17
|
textFileSequence: sequence,
|
|
17
18
|
})));
|
|
18
19
|
}
|
|
@@ -20,11 +21,12 @@ function expectLoadedSequences(spy, stubBody) {
|
|
|
20
21
|
describe('<GetSequencesInBulkComponent />', () => {
|
|
21
22
|
beforeEach(() => {
|
|
22
23
|
cy.setupOpenCloningDBTestAuth();
|
|
24
|
+
// TagMultiSelect fetches the tags on mount, in every test
|
|
25
|
+
cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
|
|
23
26
|
});
|
|
24
27
|
|
|
25
28
|
it('loads all sequences with a tag', () => {
|
|
26
29
|
const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
|
|
27
|
-
cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
|
|
28
30
|
cy.interceptOpenCloningDBStub('get_text_file_sequences_by_tag', { alias: 'getSequences' });
|
|
29
31
|
|
|
30
32
|
cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
|
|
@@ -41,6 +43,56 @@ describe('<GetSequencesInBulkComponent />', () => {
|
|
|
41
43
|
});
|
|
42
44
|
});
|
|
43
45
|
|
|
46
|
+
it('loads only the sequences of the selected types', () => {
|
|
47
|
+
const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
|
|
48
|
+
cy.interceptOpenCloningDBStub('get_text_file_sequences_by_tag_plasmids', { alias: 'getSequences' });
|
|
49
|
+
|
|
50
|
+
cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
|
|
51
|
+
|
|
52
|
+
cy.wait('@getTags');
|
|
53
|
+
clickMultiSelectOption('Tags', TAG_NAME, 'div');
|
|
54
|
+
cy.get('input[placeholder="All types"]').should('exist');
|
|
55
|
+
clickMultiSelectOption('Sequence types', 'Plasmid', 'div');
|
|
56
|
+
cy.contains('.MuiChip-root', 'Plasmid').should('exist');
|
|
57
|
+
cy.contains('button', 'Load sequences').click();
|
|
58
|
+
cy.wait('@getSequences');
|
|
59
|
+
|
|
60
|
+
cy.getStub('get_text_file_sequences_by_tag_plasmids').then((stub) => {
|
|
61
|
+
expect(stub.response.body).to.have.length.greaterThan(0);
|
|
62
|
+
stub.response.body.forEach(({ sequence_ref }) => expect(sequence_ref.sequence_type).to.equal('plasmid'));
|
|
63
|
+
cy.get('@onSequencesLoadedSpy').should((spy) => expectLoadedSequences(spy, stub.response.body));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('uses defaultSequenceTypes as the initial type filter, which can be cleared', () => {
|
|
68
|
+
const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
|
|
69
|
+
cy.interceptOpenCloningDBStub('get_text_file_sequences_by_tag_plasmids', { alias: 'getPlasmids' });
|
|
70
|
+
|
|
71
|
+
cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} defaultSequenceTypes={['plasmid']} />);
|
|
72
|
+
|
|
73
|
+
cy.wait('@getTags');
|
|
74
|
+
clickMultiSelectOption('Tags', TAG_NAME, 'div');
|
|
75
|
+
cy.contains('button', 'Load sequences').click();
|
|
76
|
+
cy.wait('@getPlasmids');
|
|
77
|
+
cy.getStub('get_text_file_sequences_by_tag_plasmids').then((stub) => {
|
|
78
|
+
cy.get('@onSequencesLoadedSpy').should((spy) => expectLoadedSequences(spy, stub.response.body));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Unselecting the default type loads sequences of all types
|
|
82
|
+
cy.interceptOpenCloningDBStub('get_text_file_sequences_by_tag', { alias: 'getAll' });
|
|
83
|
+
cy.contains('.MuiChip-root', 'Plasmid').find('.MuiChip-deleteIcon').click();
|
|
84
|
+
cy.get('input[placeholder="All types"]').should('exist');
|
|
85
|
+
cy.contains('button', 'Load sequences').click();
|
|
86
|
+
cy.wait('@getAll');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('does not show the type filter when picking sequences', () => {
|
|
90
|
+
cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={cy.spy()} defaultSequenceTypes={['plasmid']} />);
|
|
91
|
+
cy.contains('label', 'Sequence types').should('exist');
|
|
92
|
+
cy.contains('button', 'Pick sequences').click();
|
|
93
|
+
cy.contains('label', 'Sequence types').should('not.exist');
|
|
94
|
+
});
|
|
95
|
+
|
|
44
96
|
it('loads the picked sequences', () => {
|
|
45
97
|
const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
|
|
46
98
|
cy.interceptOpenCloningDBStub('get_sequences_search_by_name', { alias: 'searchByName' });
|
|
@@ -64,7 +116,6 @@ describe('<GetSequencesInBulkComponent />', () => {
|
|
|
64
116
|
|
|
65
117
|
it('shows the error returned by the server', () => {
|
|
66
118
|
const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
|
|
67
|
-
cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
|
|
68
119
|
cy.intercept('GET', `**${endpoints.sequenceTextFiles}*`, {
|
|
69
120
|
statusCode: 400,
|
|
70
121
|
body: { detail: 'Too many sequences (300), the maximum is 200.' },
|
|
@@ -82,7 +133,6 @@ describe('<GetSequencesInBulkComponent />', () => {
|
|
|
82
133
|
|
|
83
134
|
it('shows a message when no sequences have the tag', () => {
|
|
84
135
|
const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
|
|
85
|
-
cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
|
|
86
136
|
cy.intercept('GET', `**${endpoints.sequenceTextFiles}*`, { statusCode: 200, body: [] }).as('getSequences');
|
|
87
137
|
|
|
88
138
|
cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
|
|
@@ -91,7 +141,7 @@ describe('<GetSequencesInBulkComponent />', () => {
|
|
|
91
141
|
clickMultiSelectOption('Tags', TAG_NAME, 'div');
|
|
92
142
|
cy.contains('button', 'Load sequences').click();
|
|
93
143
|
cy.wait('@getSequences');
|
|
94
|
-
cy.contains('No sequences found with the selected tags.').should('exist');
|
|
144
|
+
cy.contains('No sequences found with the selected tags and types.').should('exist');
|
|
95
145
|
cy.get('@onSequencesLoadedSpy').should('not.have.been.called');
|
|
96
146
|
});
|
|
97
147
|
|
|
@@ -110,7 +160,7 @@ describe('<GetSequencesInBulkComponent />', () => {
|
|
|
110
160
|
cy.contains('button', 'Load sequences').click();
|
|
111
161
|
cy.wait('@getSequences');
|
|
112
162
|
cy.contains('The selected sequences have no content (template sequences cannot be loaded).').should('exist');
|
|
113
|
-
cy.contains('No sequences found with the selected tags.').should('not.exist');
|
|
163
|
+
cy.contains('No sequences found with the selected tags and types.').should('not.exist');
|
|
114
164
|
cy.get('@onSequencesLoadedSpy').should('not.have.been.called');
|
|
115
165
|
});
|
|
116
166
|
});
|
|
@@ -4,17 +4,21 @@ import { Alert, Button, CircularProgress, FormControl, Stack, ToggleButton, Togg
|
|
|
4
4
|
import SequenceSelect from './SequenceSelect';
|
|
5
5
|
import TagMultiSelect from './TagMultiSelect';
|
|
6
6
|
import { formatSequenceName, openCloningDBHttpClient } from './common';
|
|
7
|
+
import SequenceTypeSelect from './SequenceTypeSelect';
|
|
7
8
|
import endpoints from './endpoints';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Load several sequences from OpenCloningDB, either all the sequences with any of the selected tags,
|
|
11
12
|
* or the sequences picked by the user. Template sequences are not included (they have no content).
|
|
13
|
+
* When loading by tag, the sequences can be restricted to some sequence types (none selected means all types),
|
|
14
|
+
* defaultSequenceTypes sets the initial selection (e.g. ['plasmid']).
|
|
12
15
|
*
|
|
13
|
-
* onSequencesLoaded receives an array of { databaseId, name, textFileSequence }.
|
|
16
|
+
* onSequencesLoaded receives an array of { databaseId, name, seguid, textFileSequence }.
|
|
14
17
|
*/
|
|
15
|
-
function GetSequencesInBulkComponent({ onSequencesLoaded }) {
|
|
18
|
+
function GetSequencesInBulkComponent({ onSequencesLoaded, defaultSequenceTypes = [] }) {
|
|
16
19
|
const [mode, setMode] = React.useState('tags');
|
|
17
20
|
const [selectedTags, setSelectedTags] = React.useState([]);
|
|
21
|
+
const [selectedSequenceTypes, setSelectedSequenceTypes] = React.useState(defaultSequenceTypes);
|
|
18
22
|
const [selectedSequences, setSelectedSequences] = React.useState([]);
|
|
19
23
|
const [emptyResult, setEmptyResult] = React.useState(false);
|
|
20
24
|
|
|
@@ -24,6 +28,7 @@ function GetSequencesInBulkComponent({ onSequencesLoaded }) {
|
|
|
24
28
|
return data.map(({ sequence_ref, sequence }) => ({
|
|
25
29
|
databaseId: sequence_ref.id,
|
|
26
30
|
name: formatSequenceName(sequence_ref),
|
|
31
|
+
seguid: sequence_ref.seguid,
|
|
27
32
|
textFileSequence: sequence,
|
|
28
33
|
}));
|
|
29
34
|
},
|
|
@@ -48,7 +53,9 @@ function GetSequencesInBulkComponent({ onSequencesLoaded }) {
|
|
|
48
53
|
const onLoad = () => {
|
|
49
54
|
setEmptyResult(false);
|
|
50
55
|
// selectedTags are ids, selectedSequences are sequence objects
|
|
51
|
-
mutate(mode === 'tags'
|
|
56
|
+
mutate(mode === 'tags'
|
|
57
|
+
? { tags: selectedTags, sequence_types: selectedSequenceTypes }
|
|
58
|
+
: { ids: selectedSequences.map((item) => item.id) });
|
|
52
59
|
};
|
|
53
60
|
|
|
54
61
|
return (
|
|
@@ -62,8 +69,18 @@ function GetSequencesInBulkComponent({ onSequencesLoaded }) {
|
|
|
62
69
|
? <TagMultiSelect label="Tags" value={selectedTags} onChange={setSelectedTags} allowCreate={false} />
|
|
63
70
|
: <SequenceSelect label="Sequences" value={selectedSequences} onChange={setSelectedSequences} />}
|
|
64
71
|
</FormControl>
|
|
72
|
+
{mode === 'tags' && (
|
|
73
|
+
<SequenceTypeSelect
|
|
74
|
+
multiple
|
|
75
|
+
label="Sequence types"
|
|
76
|
+
placeholder="All types"
|
|
77
|
+
size="medium"
|
|
78
|
+
value={selectedSequenceTypes}
|
|
79
|
+
onChange={setSelectedSequenceTypes}
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
65
82
|
{error && <Alert severity="error">{error.response?.data?.detail || error.message || 'Could not load sequences from OpenCloningDB'}</Alert>}
|
|
66
|
-
{emptyResult && <Alert severity="info">{mode === 'tags' ? 'No sequences found with the selected tags.' : 'The selected sequences have no content (template sequences cannot be loaded).'}</Alert>}
|
|
83
|
+
{emptyResult && <Alert severity="info">{mode === 'tags' ? 'No sequences found with the selected tags and types.' : 'The selected sequences have no content (template sequences cannot be loaded).'}</Alert>}
|
|
67
84
|
{isPending
|
|
68
85
|
? <CircularProgress sx={{ alignSelf: 'center' }} />
|
|
69
86
|
: <Button variant="contained" disabled={selection.length === 0} onClick={onLoad}>Load sequences</Button>}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import LoadHistoryComponent from './LoadHistoryComponent';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// The sequence id comes from the stub (/db/sequences/<id>/cloning_strategy), so it follows the seed data
|
|
5
|
+
const databaseIdFromStub = (stub) => Number(stub.endpoint.match(/\/db\/sequences\/(\d+)\//)[1]);
|
|
5
6
|
|
|
6
7
|
describe('<LoadHistoryComponent />', () => {
|
|
7
8
|
it('calls loadDatabaseFile with the file returned by the API', () => {
|
|
@@ -10,22 +11,23 @@ describe('<LoadHistoryComponent />', () => {
|
|
|
10
11
|
|
|
11
12
|
cy.interceptOpenCloningDBStub('get_cloning_strategy', { alias: 'getCloningStrategy' });
|
|
12
13
|
|
|
13
|
-
cy.mount(
|
|
14
|
-
<LoadHistoryComponent
|
|
15
|
-
databaseId={DATABASE_ID}
|
|
16
|
-
loadDatabaseFile={loadDatabaseFileSpy}
|
|
17
|
-
/>,
|
|
18
|
-
);
|
|
19
|
-
|
|
20
14
|
cy.getStub('get_cloning_strategy').then((cloningStrategyStub) => {
|
|
15
|
+
const databaseId = databaseIdFromStub(cloningStrategyStub);
|
|
21
16
|
const expectedContent = JSON.stringify(cloningStrategyStub.response.body);
|
|
22
17
|
|
|
18
|
+
cy.mount(
|
|
19
|
+
<LoadHistoryComponent
|
|
20
|
+
databaseId={databaseId}
|
|
21
|
+
loadDatabaseFile={loadDatabaseFileSpy}
|
|
22
|
+
/>,
|
|
23
|
+
);
|
|
24
|
+
|
|
23
25
|
cy.wait('@getCloningStrategy').its('request.url').should('include', cloningStrategyStub.endpoint);
|
|
24
26
|
cy.get('@loadDatabaseFileSpy').should('have.been.calledOnce');
|
|
25
27
|
|
|
26
28
|
cy.get('@loadDatabaseFileSpy').then((spy) => {
|
|
27
29
|
const [file, id, flag] = spy.lastCall.args;
|
|
28
|
-
expect(id).to.equal(
|
|
30
|
+
expect(id).to.equal(databaseId);
|
|
29
31
|
expect(flag).to.equal(true);
|
|
30
32
|
|
|
31
33
|
return cy.readFileAsText(file).then((content) => {
|
|
@@ -47,14 +49,14 @@ describe('<LoadHistoryComponent />', () => {
|
|
|
47
49
|
headers: cloningStrategyStub.response.headers,
|
|
48
50
|
});
|
|
49
51
|
}).as('getCloningStrategy');
|
|
50
|
-
});
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
cy.mount(
|
|
54
|
+
<LoadHistoryComponent
|
|
55
|
+
databaseId={databaseIdFromStub(cloningStrategyStub)}
|
|
56
|
+
loadDatabaseFile={cy.stub()}
|
|
57
|
+
/>,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
58
60
|
|
|
59
61
|
cy.get('.MuiCircularProgress-svg', { timeout: 500 }).should('exist');
|
|
60
62
|
cy.wait('@getCloningStrategy');
|
|
@@ -77,14 +79,14 @@ describe('<LoadHistoryComponent />', () => {
|
|
|
77
79
|
});
|
|
78
80
|
}
|
|
79
81
|
}).as('getCloningStrategy');
|
|
80
|
-
});
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
cy.mount(
|
|
84
|
+
<LoadHistoryComponent
|
|
85
|
+
databaseId={databaseIdFromStub(cloningStrategyStub)}
|
|
86
|
+
loadDatabaseFile={cy.stub()}
|
|
87
|
+
/>,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
88
90
|
|
|
89
91
|
cy.wait('@getCloningStrategy');
|
|
90
92
|
cy.contains('Failed to load history file.').should('exist');
|
|
@@ -108,7 +108,9 @@ describe('getSequencingFiles', () => {
|
|
|
108
108
|
addStubToServer(server, listStub);
|
|
109
109
|
addStubToServer(server, downloadStub);
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
// The sequence id comes from the stub (/db/sequences/<id>/sequencing_files), so it follows the seed data
|
|
112
|
+
const sequenceId = Number(listStub.endpoint.match(/\/db\/sequences\/(\d+)\//)[1]);
|
|
113
|
+
const files = await OpenCloningDBInterface.getSequencingFiles(sequenceId);
|
|
112
114
|
|
|
113
115
|
expect(files).toHaveLength(1);
|
|
114
116
|
expect(files[0].id).toBe(listStub.response.body[0].id);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import SequenceTypeSelect from './SequenceTypeSelect';
|
|
3
|
+
import { SEQUENCE_TYPE_LABELS } from './sequenceTypes';
|
|
4
|
+
import { clickMultiSelectOption } from '../../../cypress/e2e/common_functions';
|
|
5
|
+
|
|
6
|
+
describe('<SequenceTypeSelect />', () => {
|
|
7
|
+
it('single: shows all the types and calls onChange with the selected type', () => {
|
|
8
|
+
const onChangeSpy = cy.spy().as('onChangeSpy');
|
|
9
|
+
cy.mount(<SequenceTypeSelect value="locus" onChange={onChangeSpy} labelId="my-type-label" />);
|
|
10
|
+
|
|
11
|
+
cy.contains('label', 'Type').should('have.attr', 'id', 'my-type-label');
|
|
12
|
+
cy.contains('Locus').click();
|
|
13
|
+
cy.get('ul[aria-labelledby="my-type-label"] li').should('have.length', Object.keys(SEQUENCE_TYPE_LABELS).length);
|
|
14
|
+
cy.get('ul[aria-labelledby="my-type-label"] li').contains('PCR product').click();
|
|
15
|
+
cy.get('@onChangeSpy').should('have.been.calledOnceWith', 'pcr_product');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('single: types restricts the options', () => {
|
|
19
|
+
cy.mount(<SequenceTypeSelect value="locus" onChange={cy.spy()} labelId="my-type-label" types={['locus', 'allele']} />);
|
|
20
|
+
cy.contains('Locus').click();
|
|
21
|
+
cy.get('ul[aria-labelledby="my-type-label"] li').should('have.length', 2);
|
|
22
|
+
cy.get('ul[aria-labelledby="my-type-label"] li').contains('Allele').should('exist');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('multiple: adds and removes types, and shows the placeholder when empty', () => {
|
|
26
|
+
function Wrapper({ onChange }) {
|
|
27
|
+
const [value, setValue] = React.useState(['plasmid']);
|
|
28
|
+
return (
|
|
29
|
+
<SequenceTypeSelect
|
|
30
|
+
multiple
|
|
31
|
+
label="Sequence types"
|
|
32
|
+
placeholder="All types"
|
|
33
|
+
value={value}
|
|
34
|
+
onChange={(v) => { setValue(v); onChange(v); }}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const onChangeSpy = cy.spy().as('onChangeSpy');
|
|
39
|
+
cy.mount(<Wrapper onChange={onChangeSpy} />);
|
|
40
|
+
|
|
41
|
+
cy.contains('.MuiChip-root', 'Plasmid').should('exist');
|
|
42
|
+
cy.get('input[placeholder="All types"]').should('not.exist');
|
|
43
|
+
|
|
44
|
+
clickMultiSelectOption('Sequence types', 'Allele', 'div');
|
|
45
|
+
cy.get('@onChangeSpy').should('have.been.calledWith', ['plasmid', 'allele']);
|
|
46
|
+
|
|
47
|
+
cy.contains('.MuiChip-root', 'Plasmid').find('.MuiChip-deleteIcon').click();
|
|
48
|
+
cy.contains('.MuiChip-root', 'Allele').find('.MuiChip-deleteIcon').click();
|
|
49
|
+
cy.get('@onChangeSpy').should('have.been.calledWith', []);
|
|
50
|
+
cy.get('input[placeholder="All types"]').should('exist');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { FormControl, InputLabel, MenuItem, Select } from '@mui/material';
|
|
3
|
+
import { ChipMultiSelect } from '@opencloning/ui';
|
|
4
|
+
import { SEQUENCE_TYPE_LABELS } from './sequenceTypes';
|
|
5
|
+
|
|
6
|
+
const ALL_SEQUENCE_TYPES = Object.keys(SEQUENCE_TYPE_LABELS);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Select of sequence types (e.g. 'plasmid'), shown with their labels.
|
|
10
|
+
*
|
|
11
|
+
* - multiple=false: value is a type and onChange receives a type.
|
|
12
|
+
* - multiple=true: value is an array of types and onChange receives an array of types.
|
|
13
|
+
* placeholder is shown when nothing is selected (e.g. 'All types' when used as a filter).
|
|
14
|
+
*
|
|
15
|
+
* types restricts the options (all types by default). labelId sets the id of the label,
|
|
16
|
+
* which the options list references with aria-labelledby (single select only).
|
|
17
|
+
*/
|
|
18
|
+
function SequenceTypeSelect({
|
|
19
|
+
value, onChange, multiple = false, types = ALL_SEQUENCE_TYPES, label = 'Type', labelId, placeholder,
|
|
20
|
+
disabled, size = 'small', sx,
|
|
21
|
+
}) {
|
|
22
|
+
const generatedId = React.useId();
|
|
23
|
+
const id = labelId ?? `sequence-type-select-${generatedId}`;
|
|
24
|
+
const options = React.useMemo(
|
|
25
|
+
() => types.map((type) => ({ id: type, label: SEQUENCE_TYPE_LABELS[type] ?? type })),
|
|
26
|
+
[types],
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (multiple) {
|
|
30
|
+
return (
|
|
31
|
+
<ChipMultiSelect
|
|
32
|
+
label={label}
|
|
33
|
+
options={options}
|
|
34
|
+
value={value ?? []}
|
|
35
|
+
onChange={onChange}
|
|
36
|
+
placeholder={placeholder}
|
|
37
|
+
disabled={disabled}
|
|
38
|
+
size={size}
|
|
39
|
+
sx={sx}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<FormControl size={size} sx={sx} disabled={disabled}>
|
|
46
|
+
<InputLabel id={id}>{label}</InputLabel>
|
|
47
|
+
<Select labelId={id} label={label} value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
|
|
48
|
+
{options.map((option) => (
|
|
49
|
+
<MenuItem key={option.id} value={option.id}>{option.label}</MenuItem>
|
|
50
|
+
))}
|
|
51
|
+
</Select>
|
|
52
|
+
</FormControl>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default SequenceTypeSelect;
|
|
@@ -34,6 +34,8 @@ function mountComponent() {
|
|
|
34
34
|
describe('<SubmitCloningStrategiesInBulkComponent />', () => {
|
|
35
35
|
beforeEach(() => {
|
|
36
36
|
cy.setupOpenCloningDBTestAuth();
|
|
37
|
+
// TagMultiSelect fetches the tags on mount, in every test
|
|
38
|
+
cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
|
|
37
39
|
});
|
|
38
40
|
|
|
39
41
|
it('validates the files as multipart and shows the status of each one', () => {
|
|
@@ -62,7 +64,6 @@ describe('<SubmitCloningStrategiesInBulkComponent />', () => {
|
|
|
62
64
|
|
|
63
65
|
it('submits the importable rows with the selected tags', () => {
|
|
64
66
|
cy.intercept(validateUrl, { statusCode: 200, body: validationRows }).as('validate');
|
|
65
|
-
cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
|
|
66
67
|
cy.intercept(submitUrl, (req) => {
|
|
67
68
|
req.reply({ statusCode: 200, body: createdSequences });
|
|
68
69
|
}).as('submit');
|
|
@@ -4,6 +4,19 @@ import TagMultiSelect from '../TagMultiSelect';
|
|
|
4
4
|
import { Cancel as CancelIcon, CheckCircle as CheckCircleIcon, Info as InfoIcon, Warning as WarningIcon } from '@mui/icons-material';
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
/** Scrollable container for the bulk tables, so that they fit in a BulkUploadModal. */
|
|
8
|
+
export function BulkTableContainer({ children }) {
|
|
9
|
+
return (
|
|
10
|
+
<TableContainer
|
|
11
|
+
component={Paper}
|
|
12
|
+
variant="outlined"
|
|
13
|
+
sx={{ mb: 2, maxHeight: '55vh', overflowY: 'auto', flex: 1, minHeight: 0 }}
|
|
14
|
+
>
|
|
15
|
+
{children}
|
|
16
|
+
</TableContainer>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
/**
|
|
8
21
|
* Wraps a bulk upload preview table with the summary and the import / cancel buttons.
|
|
9
22
|
* The tag field is shown if onBulkTagsChange is passed, onTagCreateError receives the error if creating a tag fails.
|
|
@@ -49,13 +62,9 @@ export function BulkUploadPreviewTableWrapper({
|
|
|
49
62
|
{orderingText}
|
|
50
63
|
</Typography>
|
|
51
64
|
|
|
52
|
-
<
|
|
53
|
-
component={Paper}
|
|
54
|
-
variant="outlined"
|
|
55
|
-
sx={{ mb: 2, maxHeight: '55vh', overflowY: 'auto', flex: 1, minHeight: 0 }}
|
|
56
|
-
>
|
|
65
|
+
<BulkTableContainer>
|
|
57
66
|
{children}
|
|
58
|
-
</
|
|
67
|
+
</BulkTableContainer>
|
|
59
68
|
|
|
60
69
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
|
61
70
|
{summaryText}
|
|
@@ -104,13 +113,19 @@ export function BulkUploadPreviewTableWrapper({
|
|
|
104
113
|
</>
|
|
105
114
|
);
|
|
106
115
|
}
|
|
107
|
-
|
|
116
|
+
|
|
108
117
|
export function StatusIcon({ status }) {
|
|
118
|
+
if (!status) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
109
121
|
const isClear = status === 'clear';
|
|
110
122
|
const isError = status === 'error';
|
|
111
123
|
const isInfo = status === 'info';
|
|
124
|
+
const isPending = status === 'pending';
|
|
112
125
|
|
|
113
|
-
return
|
|
126
|
+
return isPending ? (
|
|
127
|
+
<CircularProgress size={20} data-testid="status-icon-pending" />
|
|
128
|
+
) : isClear ? (
|
|
114
129
|
<CheckCircleIcon color="success" />
|
|
115
130
|
) : isError ? (
|
|
116
131
|
<CancelIcon color="error" />
|
|
@@ -122,10 +137,11 @@ export function StatusIcon({ status }) {
|
|
|
122
137
|
}
|
|
123
138
|
|
|
124
139
|
|
|
125
|
-
|
|
140
|
+
/** Table row with a status icon cell, the children cells and an issues cell. Other props are passed to the TableRow. */
|
|
141
|
+
export function CommonTableRow({ status, issues = [], children, ...rowProps }) {
|
|
126
142
|
|
|
127
143
|
return (
|
|
128
|
-
<TableRow>
|
|
144
|
+
<TableRow {...rowProps}>
|
|
129
145
|
<TableCell align="center" padding="checkbox">
|
|
130
146
|
<StatusIcon status={status} />
|
|
131
147
|
</TableCell>
|
package/src/endpoints.js
CHANGED
|
@@ -14,7 +14,9 @@ const endpoints = {
|
|
|
14
14
|
sequenceChangeCircularity: (id) => `/sequences/${e(id)}/change_circularity`,
|
|
15
15
|
sequencePrimers: (id) => `/sequences/${e(id)}/primers`,
|
|
16
16
|
sequenceSequencingFiles: (id) => `/sequences/${e(id)}/sequencing_files`,
|
|
17
|
+
sequencingFilesBulk: '/sequences/sequencing_files/bulk',
|
|
17
18
|
sequenceSearch: '/sequences/search',
|
|
19
|
+
sequencesBySeguid: (seguid) => `/sequences/by-seguid/${e(seguid)}`,
|
|
18
20
|
sequencesValidateUpload: '/sequences/validate-upload',
|
|
19
21
|
sequencesBulk: '/sequences/bulk',
|
|
20
22
|
sequencesCloningStrategyBulkValidate: '/sequences/cloning_strategy/bulk/validate',
|
package/src/index.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
export { default as OpenCloningDBInterface } from './OpenCloningDBInterface.js';
|
|
2
2
|
export { openCloningDBHttpClient, setUnauthorizedHandler, setHttpClientTokenGetter, setWorkspaceHeader, clearWorkspaceHeader, baseUrl, formatSequenceName } from './common.js';
|
|
3
|
+
export { SEQUENCE_TYPE_LABELS } from './sequenceTypes.js';
|
|
3
4
|
export { default as endpoints } from './endpoints.js';
|
|
4
5
|
export { default as SequenceSelect } from './SequenceSelect.jsx';
|
|
5
6
|
export { default as TagMultiSelect } from './TagMultiSelect.jsx';
|
|
7
|
+
export { default as SequenceTypeSelect } from './SequenceTypeSelect.jsx';
|
|
6
8
|
export { default as SubmitCloningStrategiesInBulkComponent } from './SubmitCloningStrategiesInBulkComponent.jsx';
|
|
7
9
|
export { default as BulkUploadPreview } from './bulk_upload/BulkUploadPreview.jsx';
|
|
8
10
|
export { default as BulkUploadModal } from './bulk_upload/BulkUploadModal.jsx';
|
|
9
11
|
export { CommonTableRow } from './bulk_upload/common.jsx';
|
|
12
|
+
export { default as SequencingCheckModal } from './sequencing_check/SequencingCheckModal.jsx';
|
|
13
|
+
export { MAX_CONCURRENT_CHECKS, runWithConcurrency } from './sequencing_check/sequencingCheckUtils.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Display labels of the sequence types (SequenceType enum in the backend).
|
|
2
|
+
// Kept free of imports, so that it can be imported without loading the rest of the package.
|
|
3
|
+
export const SEQUENCE_TYPE_LABELS = {
|
|
4
|
+
locus: 'Locus',
|
|
5
|
+
allele: 'Allele',
|
|
6
|
+
plasmid: 'Plasmid',
|
|
7
|
+
pcr_product: 'PCR product',
|
|
8
|
+
restriction_fragment: 'Restriction fragment',
|
|
9
|
+
linear_dna: 'Linear DNA',
|
|
10
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useQuery } from '@tanstack/react-query';
|
|
3
|
+
import { Alert, Box, CircularProgress, IconButton, Tooltip, Typography } from '@mui/material';
|
|
4
|
+
import { Close as CloseIcon } from '@mui/icons-material';
|
|
5
|
+
import SequenceViewer from '@opencloning/ui/components/SequenceViewer';
|
|
6
|
+
import { buildSequencingAlignmentConfig } from '@opencloning/utils/alignmentUtils';
|
|
7
|
+
import { convertToTeselaJson } from '@opencloning/utils/readNwrite';
|
|
8
|
+
import error2String from '@opencloning/utils/error2String';
|
|
9
|
+
import useSequencingQueries from './useSequencingQueries';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Alignment of one sequencing file to its sequence, shown in the SequenceViewer with the alignment tab selected.
|
|
13
|
+
* sequence is an item of the output of GetSequencesInBulkComponent, file a sequencing file of that sequence.
|
|
14
|
+
*/
|
|
15
|
+
export default function SequencingAlignmentViewer({ sequence, file, onClose }) {
|
|
16
|
+
const sequenceData = React.useMemo(() => convertToTeselaJson(sequence.textFileSequence), [sequence]);
|
|
17
|
+
|
|
18
|
+
// Shared with the sequencing check, so files that were already aligned are not aligned again
|
|
19
|
+
const { alignmentQuery } = useSequencingQueries();
|
|
20
|
+
const { data, isPending, error } = useQuery(alignmentQuery(sequence, file));
|
|
21
|
+
const alignmentData = React.useMemo(() => data && buildSequencingAlignmentConfig(
|
|
22
|
+
sequence.textFileSequence,
|
|
23
|
+
sequenceData,
|
|
24
|
+
[{ fileName: file.name, fileContent: data.fileContent }],
|
|
25
|
+
data.alignedSequences,
|
|
26
|
+
), [data, sequence, sequenceData, file.name]);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Box sx={{ mb: 2 }} data-testid="sequencing-alignment-viewer">
|
|
30
|
+
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
|
31
|
+
<Typography variant="subtitle1" sx={{ flex: 1, overflowWrap: 'anywhere' }}>
|
|
32
|
+
Alignment of <Box component="span" sx={{ fontFamily: 'monospace' }}>{file.name}</Box> to {sequence.name}
|
|
33
|
+
</Typography>
|
|
34
|
+
<Tooltip title="Close alignment">
|
|
35
|
+
<IconButton size="small" onClick={onClose} aria-label="Close alignment">
|
|
36
|
+
<CloseIcon />
|
|
37
|
+
</IconButton>
|
|
38
|
+
</Tooltip>
|
|
39
|
+
</Box>
|
|
40
|
+
{isPending && (
|
|
41
|
+
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
|
42
|
+
<CircularProgress />
|
|
43
|
+
</Box>
|
|
44
|
+
)}
|
|
45
|
+
{error && <Alert severity="error">Could not align the sequencing file: {error2String(error)}</Alert>}
|
|
46
|
+
{alignmentData && <SequenceViewer sequenceData={sequenceData} alignmentData={alignmentData} readOnly />}
|
|
47
|
+
</Box>
|
|
48
|
+
);
|
|
49
|
+
}
|