@opencloning/opencloningdb 1.8.8 → 1.9.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 +33 -0
- package/package.json +3 -3
- package/src/GetSequencesInBulkComponent.cy.jsx +116 -0
- package/src/GetSequencesInBulkComponent.jsx +74 -0
- package/src/OpenCloningDBInterface.js +8 -0
- package/src/OpenCloningDBInterface.multipart.test.js +2 -0
- package/src/OpenCloningDBInterface.test.js +2 -0
- package/src/SubmitCloningStrategiesInBulkComponent.cy.jsx +179 -0
- package/src/SubmitCloningStrategiesInBulkComponent.jsx +105 -0
- package/src/TagMultiSelect.cy.jsx +88 -0
- package/src/TagMultiSelect.jsx +61 -0
- package/src/bulk_upload/BulkUploadModal.jsx +40 -0
- package/src/bulk_upload/BulkUploadPreview.jsx +41 -0
- package/src/bulk_upload/CloningStrategyBulkUploadPreviewTable.jsx +103 -0
- package/src/bulk_upload/bulk_upload_utils.js +76 -0
- package/src/bulk_upload/bulk_upload_utils.test.js +85 -0
- package/src/bulk_upload/cloning_strategy_bulk_api.js +28 -0
- package/src/bulk_upload/common.jsx +142 -0
- package/src/endpoints.js +1 -0
- package/src/index.js +5 -0
- package/src/useCreateTagMutation.js +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
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
|
+
|
|
11
|
+
## 1.9.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- [#786](https://github.com/OpenCloning/OpenCloning_frontend/pull/786) [`c8c46d4`](https://github.com/OpenCloning/OpenCloning_frontend/commit/c8c46d4dc36cb8741d1bd89816c7ca82b89996b3) Thanks [@manulera](https://github.com/manulera)! - Assembler: load plasmids from the database and upload the assemblies to it.
|
|
16
|
+
|
|
17
|
+
- `@opencloning/ui`:
|
|
18
|
+
- The Assembler's plasmid buttons are grouped into a "Load Plasmids" menu (from files, from the local server, from the database).
|
|
19
|
+
- When the database interface provides `GetSequencesInBulkComponent`, plasmids can be loaded by tag or picked, and are used as `DatabaseSource`s. In that case the default plasmids of a syntax are not loaded.
|
|
20
|
+
- When it provides `SubmitCloningStrategiesInBulkComponent`, a new "Upload to DB" button uploads the assemblies with their history, as in the bulk upload of sequences with history.
|
|
21
|
+
- Both are new optional entries of the database interface (see `DummyInterface`).
|
|
22
|
+
- `@opencloning/opencloningdb`:
|
|
23
|
+
- Implements `GetSequencesInBulkComponent` and `SubmitCloningStrategiesInBulkComponent`.
|
|
24
|
+
- Exports `TagMultiSelect` (with `allowCreate` and `onCreateError`), `BulkUploadModal`, `BulkUploadPreview` and `CommonTableRow`, moved from the `opencloningdb` app.
|
|
25
|
+
- `opencloningdb`:
|
|
26
|
+
- The "Upload sequences with history" button uses `SubmitCloningStrategiesInBulkComponent`. Its validation, submission and conflict messages are now shown in the dialog instead of as alerts.
|
|
27
|
+
- The tag selects no longer drop excluded tags from the shared tags cache.
|
|
28
|
+
- `syntax-builder`: the dev server runs on port 3000 and listens on `127.0.0.1`, like the other apps.
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- Updated dependencies [[`c8c46d4`](https://github.com/OpenCloning/OpenCloning_frontend/commit/c8c46d4dc36cb8741d1bd89816c7ca82b89996b3)]:
|
|
33
|
+
- @opencloning/ui@2.1.0
|
|
34
|
+
- @opencloning/utils@2.1.0
|
|
35
|
+
|
|
3
36
|
## 1.8.8
|
|
4
37
|
|
|
5
38
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"license": "MIT",
|
|
3
3
|
"name": "@opencloning/opencloningdb",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.9.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@mui/icons-material": "^5.18.0",
|
|
17
17
|
"@mui/material": "^5.18.0",
|
|
18
|
-
"@opencloning/ui": "2.
|
|
19
|
-
"@opencloning/utils": "2.
|
|
18
|
+
"@opencloning/ui": "2.1.1",
|
|
19
|
+
"@opencloning/utils": "2.1.1",
|
|
20
20
|
"axios": "^1.20.0"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import GetSequencesInBulkComponent from './GetSequencesInBulkComponent';
|
|
3
|
+
import { clickMultiSelectOption } from '../../../cypress/e2e/common_functions';
|
|
4
|
+
import endpoints from './endpoints';
|
|
5
|
+
import { formatSequenceName } from './common';
|
|
6
|
+
|
|
7
|
+
const SEQUENCE_NAME = 'ase1_CDS_PCR';
|
|
8
|
+
const TAG_NAME = 'gibson_assembly';
|
|
9
|
+
|
|
10
|
+
function expectLoadedSequences(spy, stubBody) {
|
|
11
|
+
expect(spy).to.have.been.calledOnce;
|
|
12
|
+
const [sequences] = spy.lastCall.args;
|
|
13
|
+
expect(sequences).to.deep.equal(stubBody.map(({ sequence_ref, sequence }) => ({
|
|
14
|
+
databaseId: sequence_ref.id,
|
|
15
|
+
name: formatSequenceName(sequence_ref),
|
|
16
|
+
textFileSequence: sequence,
|
|
17
|
+
})));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('<GetSequencesInBulkComponent />', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
cy.setupOpenCloningDBTestAuth();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('loads all sequences with a tag', () => {
|
|
26
|
+
const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
|
|
27
|
+
cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
|
|
28
|
+
cy.interceptOpenCloningDBStub('get_text_file_sequences_by_tag', { alias: 'getSequences' });
|
|
29
|
+
|
|
30
|
+
cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
|
|
31
|
+
|
|
32
|
+
cy.contains('button', 'Load sequences').should('be.disabled');
|
|
33
|
+
cy.wait('@getTags');
|
|
34
|
+
clickMultiSelectOption('Tags', TAG_NAME, 'div');
|
|
35
|
+
cy.contains('button', 'Load sequences').click();
|
|
36
|
+
cy.wait('@getSequences');
|
|
37
|
+
|
|
38
|
+
cy.getStub('get_text_file_sequences_by_tag').then((stub) => {
|
|
39
|
+
expect(stub.response.body).to.have.length.greaterThan(1);
|
|
40
|
+
cy.get('@onSequencesLoadedSpy').should((spy) => expectLoadedSequences(spy, stub.response.body));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('loads the picked sequences', () => {
|
|
45
|
+
const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
|
|
46
|
+
cy.interceptOpenCloningDBStub('get_sequences_search_by_name', { alias: 'searchByName' });
|
|
47
|
+
cy.interceptOpenCloningDBStub('get_sequences_search_by_name_uid_query', { alias: 'searchByUid' });
|
|
48
|
+
cy.interceptOpenCloningDBStub('get_text_file_sequences_by_ids', { alias: 'getSequences' });
|
|
49
|
+
|
|
50
|
+
cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
|
|
51
|
+
|
|
52
|
+
cy.contains('button', 'Pick sequences').click();
|
|
53
|
+
cy.contains('button', 'Load sequences').should('be.disabled');
|
|
54
|
+
cy.get('input').type(SEQUENCE_NAME);
|
|
55
|
+
cy.wait('@searchByName');
|
|
56
|
+
clickMultiSelectOption('Sequences', RegExp(`^${SEQUENCE_NAME}$`), 'div');
|
|
57
|
+
cy.contains('button', 'Load sequences').click();
|
|
58
|
+
cy.wait('@getSequences');
|
|
59
|
+
|
|
60
|
+
cy.getStub('get_text_file_sequences_by_ids').then((stub) => {
|
|
61
|
+
cy.get('@onSequencesLoadedSpy').should((spy) => expectLoadedSequences(spy, stub.response.body));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('shows the error returned by the server', () => {
|
|
66
|
+
const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
|
|
67
|
+
cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
|
|
68
|
+
cy.intercept('GET', `**${endpoints.sequenceTextFiles}*`, {
|
|
69
|
+
statusCode: 400,
|
|
70
|
+
body: { detail: 'Too many sequences (300), the maximum is 200.' },
|
|
71
|
+
}).as('getSequences');
|
|
72
|
+
|
|
73
|
+
cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
|
|
74
|
+
|
|
75
|
+
cy.wait('@getTags');
|
|
76
|
+
clickMultiSelectOption('Tags', TAG_NAME, 'div');
|
|
77
|
+
cy.contains('button', 'Load sequences').click();
|
|
78
|
+
cy.wait('@getSequences');
|
|
79
|
+
cy.get('.MuiAlert-colorError').contains('Too many sequences (300), the maximum is 200.').should('exist');
|
|
80
|
+
cy.get('@onSequencesLoadedSpy').should('not.have.been.called');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('shows a message when no sequences have the tag', () => {
|
|
84
|
+
const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
|
|
85
|
+
cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
|
|
86
|
+
cy.intercept('GET', `**${endpoints.sequenceTextFiles}*`, { statusCode: 200, body: [] }).as('getSequences');
|
|
87
|
+
|
|
88
|
+
cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
|
|
89
|
+
|
|
90
|
+
cy.wait('@getTags');
|
|
91
|
+
clickMultiSelectOption('Tags', TAG_NAME, 'div');
|
|
92
|
+
cy.contains('button', 'Load sequences').click();
|
|
93
|
+
cy.wait('@getSequences');
|
|
94
|
+
cy.contains('No sequences found with the selected tags.').should('exist');
|
|
95
|
+
cy.get('@onSequencesLoadedSpy').should('not.have.been.called');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('shows a message when the picked sequences have no content', () => {
|
|
99
|
+
const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
|
|
100
|
+
cy.interceptOpenCloningDBStub('get_sequences_search_by_name', { alias: 'searchByName' });
|
|
101
|
+
cy.interceptOpenCloningDBStub('get_sequences_search_by_name_uid_query', { alias: 'searchByUid' });
|
|
102
|
+
cy.intercept('GET', `**${endpoints.sequenceTextFiles}*`, { statusCode: 200, body: [] }).as('getSequences');
|
|
103
|
+
|
|
104
|
+
cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
|
|
105
|
+
|
|
106
|
+
cy.contains('button', 'Pick sequences').click();
|
|
107
|
+
cy.get('input').type(SEQUENCE_NAME);
|
|
108
|
+
cy.wait('@searchByName');
|
|
109
|
+
clickMultiSelectOption('Sequences', RegExp(`^${SEQUENCE_NAME}$`), 'div');
|
|
110
|
+
cy.contains('button', 'Load sequences').click();
|
|
111
|
+
cy.wait('@getSequences');
|
|
112
|
+
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');
|
|
114
|
+
cy.get('@onSequencesLoadedSpy').should('not.have.been.called');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useMutation } from '@tanstack/react-query';
|
|
3
|
+
import { Alert, Button, CircularProgress, FormControl, Stack, ToggleButton, ToggleButtonGroup } from '@mui/material';
|
|
4
|
+
import SequenceSelect from './SequenceSelect';
|
|
5
|
+
import TagMultiSelect from './TagMultiSelect';
|
|
6
|
+
import { formatSequenceName, openCloningDBHttpClient } from './common';
|
|
7
|
+
import endpoints from './endpoints';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Load several sequences from OpenCloningDB, either all the sequences with any of the selected tags,
|
|
11
|
+
* or the sequences picked by the user. Template sequences are not included (they have no content).
|
|
12
|
+
*
|
|
13
|
+
* onSequencesLoaded receives an array of { databaseId, name, textFileSequence }.
|
|
14
|
+
*/
|
|
15
|
+
function GetSequencesInBulkComponent({ onSequencesLoaded }) {
|
|
16
|
+
const [mode, setMode] = React.useState('tags');
|
|
17
|
+
const [selectedTags, setSelectedTags] = React.useState([]);
|
|
18
|
+
const [selectedSequences, setSelectedSequences] = React.useState([]);
|
|
19
|
+
const [emptyResult, setEmptyResult] = React.useState(false);
|
|
20
|
+
|
|
21
|
+
const { mutate, isPending, error, reset } = useMutation({
|
|
22
|
+
mutationFn: async (params) => {
|
|
23
|
+
const { data } = await openCloningDBHttpClient.get(endpoints.sequenceTextFiles, { params });
|
|
24
|
+
return data.map(({ sequence_ref, sequence }) => ({
|
|
25
|
+
databaseId: sequence_ref.id,
|
|
26
|
+
name: formatSequenceName(sequence_ref),
|
|
27
|
+
textFileSequence: sequence,
|
|
28
|
+
}));
|
|
29
|
+
},
|
|
30
|
+
onSuccess: (sequences) => {
|
|
31
|
+
setEmptyResult(sequences.length === 0);
|
|
32
|
+
if (sequences.length > 0) {
|
|
33
|
+
onSequencesLoaded(sequences);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
retry: false,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const onModeChange = (event, newMode) => {
|
|
40
|
+
if (newMode !== null) {
|
|
41
|
+
setMode(newMode);
|
|
42
|
+
setEmptyResult(false);
|
|
43
|
+
reset();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const selection = mode === 'tags' ? selectedTags : selectedSequences;
|
|
48
|
+
const onLoad = () => {
|
|
49
|
+
setEmptyResult(false);
|
|
50
|
+
// selectedTags are ids, selectedSequences are sequence objects
|
|
51
|
+
mutate(mode === 'tags' ? { tags: selectedTags } : { ids: selectedSequences.map((item) => item.id) });
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Stack spacing={2} sx={{ pt: 1 }}>
|
|
56
|
+
<ToggleButtonGroup value={mode} exclusive onChange={onModeChange} size="small" color="primary">
|
|
57
|
+
<ToggleButton value="tags">By tag</ToggleButton>
|
|
58
|
+
<ToggleButton value="sequences">Pick sequences</ToggleButton>
|
|
59
|
+
</ToggleButtonGroup>
|
|
60
|
+
<FormControl fullWidth>
|
|
61
|
+
{mode === 'tags'
|
|
62
|
+
? <TagMultiSelect label="Tags" value={selectedTags} onChange={setSelectedTags} allowCreate={false} />
|
|
63
|
+
: <SequenceSelect label="Sequences" value={selectedSequences} onChange={setSelectedSequences} />}
|
|
64
|
+
</FormControl>
|
|
65
|
+
{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>}
|
|
67
|
+
{isPending
|
|
68
|
+
? <CircularProgress sx={{ alignSelf: 'center' }} />
|
|
69
|
+
: <Button variant="contained" disabled={selection.length === 0} onClick={onLoad}>Load sequences</Button>}
|
|
70
|
+
</Stack>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default GetSequencesInBulkComponent;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Save as SaveIcon, Link as LinkIcon } from '@mui/icons-material';
|
|
2
2
|
import GetPrimerComponent from './GetPrimerComponent';
|
|
3
3
|
import GetSequenceFileAndDatabaseIdComponent from './GetSequenceFileAndDatabaseIdComponent';
|
|
4
|
+
import GetSequencesInBulkComponent from './GetSequencesInBulkComponent';
|
|
4
5
|
import { openCloningDBHttpClient } from './common';
|
|
5
6
|
import endpoints from './endpoints';
|
|
6
7
|
import LoadHistoryComponent from './LoadHistoryComponent';
|
|
7
8
|
import SubmitToDatabaseComponent from './SubmitToDatabaseComponent';
|
|
9
|
+
import SubmitCloningStrategiesInBulkComponent from './SubmitCloningStrategiesInBulkComponent';
|
|
8
10
|
import PrimersNotInDatabaseComponent from './PrimersNotInDatabaseComponent';
|
|
9
11
|
|
|
10
12
|
function isSubmissionDataValid(submissionData) {
|
|
@@ -138,4 +140,10 @@ export default {
|
|
|
138
140
|
locateSequenceInDatabase,
|
|
139
141
|
// Function to submit sequencing files to the database
|
|
140
142
|
submitSequencingFileToDatabase,
|
|
143
|
+
// Component for loading several sequences at once (e.g. in the assembler), calls
|
|
144
|
+
// onSequencesLoaded with an array of { databaseId, name, textFileSequence }
|
|
145
|
+
GetSequencesInBulkComponent,
|
|
146
|
+
// Dialog for submitting several cloning strategies at once (e.g. the assembler products), receives
|
|
147
|
+
// { files, onClose, onSuccess }, where files is an array of File (cloning strategy JSON)
|
|
148
|
+
SubmitCloningStrategiesInBulkComponent,
|
|
141
149
|
};
|
|
@@ -25,6 +25,8 @@ vi.mock('./GetSequenceFileAndDatabaseIdComponent', () => ({ default: () => null
|
|
|
25
25
|
vi.mock('./LoadHistoryComponent', () => ({ default: () => null }));
|
|
26
26
|
vi.mock('./SubmitToDatabaseComponent', () => ({ default: () => null }));
|
|
27
27
|
vi.mock('./PrimersNotInDatabaseComponent', () => ({ default: () => null }));
|
|
28
|
+
vi.mock('./GetSequencesInBulkComponent', () => ({ default: () => null }));
|
|
29
|
+
vi.mock('./SubmitCloningStrategiesInBulkComponent', () => ({ default: () => null }));
|
|
28
30
|
|
|
29
31
|
import OpenCloningDBInterface from './OpenCloningDBInterface';
|
|
30
32
|
|
|
@@ -10,6 +10,8 @@ vi.mock('./GetSequenceFileAndDatabaseIdComponent', () => ({ default: () => null
|
|
|
10
10
|
vi.mock('./LoadHistoryComponent', () => ({ default: () => null }));
|
|
11
11
|
vi.mock('./SubmitToDatabaseComponent', () => ({ default: () => null }));
|
|
12
12
|
vi.mock('./PrimersNotInDatabaseComponent', () => ({ default: () => null }));
|
|
13
|
+
vi.mock('./GetSequencesInBulkComponent', () => ({ default: () => null }));
|
|
14
|
+
vi.mock('./SubmitCloningStrategiesInBulkComponent', () => ({ default: () => null }));
|
|
13
15
|
|
|
14
16
|
import OpenCloningDBInterface from './OpenCloningDBInterface';
|
|
15
17
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import SubmitCloningStrategiesInBulkComponent from './SubmitCloningStrategiesInBulkComponent';
|
|
3
|
+
import endpoints from './endpoints';
|
|
4
|
+
|
|
5
|
+
const TAG_NAME = 'cre_lox_recombination';
|
|
6
|
+
const validateUrl = { method: 'POST', pathname: `/db${endpoints.sequencesCloningStrategyBulkValidate}` };
|
|
7
|
+
const submitUrl = { method: 'POST', pathname: `/db${endpoints.sequencesCloningStrategyBulk}` };
|
|
8
|
+
|
|
9
|
+
const fileContents = ['clear', 'synced', 'warning', 'error'].map((name) => ({
|
|
10
|
+
name: `${name}.json`,
|
|
11
|
+
content: JSON.stringify({ sequences: [], sources: [], primers: [], description: name }),
|
|
12
|
+
}));
|
|
13
|
+
const files = fileContents.map(({ name, content }) => new File([content], name, { type: 'application/json' }));
|
|
14
|
+
|
|
15
|
+
const strategy = (name) => ({ sequences: [], sources: [], primers: [], description: name });
|
|
16
|
+
const clearRow = { file_name: 'clear.json', cloning_strategy: strategy('clear') };
|
|
17
|
+
const syncedRow = { file_name: 'synced.json', cloning_strategy: strategy('synced'), already_synced: true };
|
|
18
|
+
const warningRow = { file_name: 'warning.json', cloning_strategy: strategy('warning'), parsing_warnings: ['Some warning'] };
|
|
19
|
+
const errorRow = { file_name: 'error.json', parsing_errors: ['Cloning strategy is not correct: bad'] };
|
|
20
|
+
const validationRows = [clearRow, syncedRow, warningRow, errorRow];
|
|
21
|
+
|
|
22
|
+
const createdSequences = [{ id: 100, type: 'Sequence', name: 'product' }];
|
|
23
|
+
|
|
24
|
+
function bodyToString(body) {
|
|
25
|
+
return typeof body === 'string' ? body : new TextDecoder().decode(body);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function mountComponent() {
|
|
29
|
+
const onCloseSpy = cy.spy().as('onCloseSpy');
|
|
30
|
+
const onSuccessSpy = cy.spy().as('onSuccessSpy');
|
|
31
|
+
cy.mount(<SubmitCloningStrategiesInBulkComponent files={files} onClose={onCloseSpy} onSuccess={onSuccessSpy} />);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('<SubmitCloningStrategiesInBulkComponent />', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
cy.setupOpenCloningDBTestAuth();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('validates the files as multipart and shows the status of each one', () => {
|
|
40
|
+
cy.intercept(validateUrl, (req) => {
|
|
41
|
+
expect(req.headers['content-type']).to.contain('multipart/form-data');
|
|
42
|
+
const body = bodyToString(req.body);
|
|
43
|
+
fileContents.forEach(({ name, content }) => {
|
|
44
|
+
expect(body).to.contain(`filename="${name}"`);
|
|
45
|
+
expect(body).to.contain(content);
|
|
46
|
+
});
|
|
47
|
+
req.reply({ statusCode: 200, body: validationRows });
|
|
48
|
+
}).as('validate');
|
|
49
|
+
|
|
50
|
+
mountComponent();
|
|
51
|
+
cy.wait('@validate');
|
|
52
|
+
cy.get('[data-testid="submit-cloning-strategies-in-bulk-dialog"]').within(() => {
|
|
53
|
+
cy.get('tbody tr').should('have.length', 4);
|
|
54
|
+
// Errors first, then warnings, informational and clear rows
|
|
55
|
+
cy.get('tbody tr').eq(0).should('contain', 'error.json').and('contain', 'Invalid').and('contain', 'Cloning strategy is not correct: bad');
|
|
56
|
+
cy.get('tbody tr').eq(1).should('contain', 'warning.json').and('contain', 'Review warnings').and('contain', 'Some warning');
|
|
57
|
+
cy.get('tbody tr').eq(2).should('contain', 'synced.json').and('contain', 'Already synced');
|
|
58
|
+
cy.get('tbody tr').eq(3).should('contain', 'clear.json').and('contain', 'Ready to import');
|
|
59
|
+
cy.contains('1 clear, 1 warning, 1 error, 1 informational out of 4 uploaded items.').should('exist');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('submits the importable rows with the selected tags', () => {
|
|
64
|
+
cy.intercept(validateUrl, { statusCode: 200, body: validationRows }).as('validate');
|
|
65
|
+
cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
|
|
66
|
+
cy.intercept(submitUrl, (req) => {
|
|
67
|
+
req.reply({ statusCode: 200, body: createdSequences });
|
|
68
|
+
}).as('submit');
|
|
69
|
+
|
|
70
|
+
mountComponent();
|
|
71
|
+
cy.wait(['@validate', '@getTags']);
|
|
72
|
+
// Not using clickMultiSelectOption, the click outside fails inside a dialog
|
|
73
|
+
cy.get('div[role="dialog"]').contains('label', 'Tags to apply (optional)').siblings('div').first().click();
|
|
74
|
+
cy.get('li[role="option"]').contains(TAG_NAME).click();
|
|
75
|
+
cy.get('div[role="dialog"]').contains('Upload to OpenCloningDB').click();
|
|
76
|
+
cy.contains('button', 'Import entries with warnings too').click();
|
|
77
|
+
|
|
78
|
+
cy.wait('@submit').then(({ request }) => {
|
|
79
|
+
expect(request.body).to.deep.equal([
|
|
80
|
+
{ file_name: 'warning.json', cloning_strategy: warningRow.cloning_strategy },
|
|
81
|
+
{ file_name: 'clear.json', cloning_strategy: clearRow.cloning_strategy },
|
|
82
|
+
]);
|
|
83
|
+
cy.getStub('get_tags').then((stub) => {
|
|
84
|
+
const tag = stub.response.body.find((t) => t.name === TAG_NAME);
|
|
85
|
+
expect(request.query).to.deep.equal({ tags: String(tag.id) });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
cy.get('@onSuccessSpy').should('have.been.calledOnceWith', createdSequences, [
|
|
89
|
+
{ file_name: 'warning.json', cloning_strategy: warningRow.cloning_strategy },
|
|
90
|
+
{ file_name: 'clear.json', cloning_strategy: clearRow.cloning_strategy },
|
|
91
|
+
]);
|
|
92
|
+
cy.get('@onCloseSpy').should('have.been.calledOnce');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('submits only the clear rows without tags', () => {
|
|
96
|
+
cy.intercept(validateUrl, { statusCode: 200, body: validationRows }).as('validate');
|
|
97
|
+
cy.intercept(submitUrl, (req) => {
|
|
98
|
+
expect(req.query).to.deep.equal({});
|
|
99
|
+
expect(req.body).to.deep.equal([{ file_name: 'clear.json', cloning_strategy: clearRow.cloning_strategy }]);
|
|
100
|
+
req.reply({ statusCode: 200, body: createdSequences });
|
|
101
|
+
}).as('submit');
|
|
102
|
+
|
|
103
|
+
mountComponent();
|
|
104
|
+
cy.wait('@validate');
|
|
105
|
+
cy.contains('button', 'Import entries without warnings').click();
|
|
106
|
+
cy.wait('@submit');
|
|
107
|
+
cy.get('@onSuccessSpy').should('have.been.calledOnceWith', createdSequences, [
|
|
108
|
+
{ file_name: 'clear.json', cloning_strategy: clearRow.cloning_strategy },
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('does not allow importing when no row is importable', () => {
|
|
113
|
+
cy.intercept(validateUrl, { statusCode: 200, body: [syncedRow, errorRow] }).as('validate');
|
|
114
|
+
cy.intercept(submitUrl, cy.spy().as('submitSpy'));
|
|
115
|
+
|
|
116
|
+
mountComponent();
|
|
117
|
+
cy.wait('@validate');
|
|
118
|
+
// Only already synced rows, which are not submitted
|
|
119
|
+
cy.contains('button', 'Import').should('be.disabled');
|
|
120
|
+
cy.get('@submitSpy').should('not.have.been.called');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('updates the rows when the submission conflicts with the database', () => {
|
|
124
|
+
cy.intercept(validateUrl, { statusCode: 200, body: [clearRow] }).as('validate');
|
|
125
|
+
cy.intercept(submitUrl, { statusCode: 409, body: [{ ...clearRow, already_synced: true }] }).as('submit');
|
|
126
|
+
|
|
127
|
+
mountComponent();
|
|
128
|
+
cy.wait('@validate');
|
|
129
|
+
cy.contains('button', 'Import').click();
|
|
130
|
+
cy.wait('@submit');
|
|
131
|
+
cy.contains('Conflicts detected while importing. Review the updated validation results.').should('exist');
|
|
132
|
+
cy.get('tbody tr').eq(0).should('contain', 'clear.json').and('contain', 'Already synced');
|
|
133
|
+
cy.get('@onSuccessSpy').should('not.have.been.called');
|
|
134
|
+
cy.get('@onCloseSpy').should('not.have.been.called');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('shows the error when the submission fails', () => {
|
|
138
|
+
cy.intercept(validateUrl, { statusCode: 200, body: [clearRow] }).as('validate');
|
|
139
|
+
cy.intercept(submitUrl, { statusCode: 403, body: { detail: 'Insufficient permissions' } }).as('submit');
|
|
140
|
+
|
|
141
|
+
mountComponent();
|
|
142
|
+
cy.wait('@validate');
|
|
143
|
+
cy.contains('button', 'Import').click();
|
|
144
|
+
cy.wait('@submit');
|
|
145
|
+
cy.get('.MuiAlert-colorError').contains('Insufficient permissions').should('exist');
|
|
146
|
+
cy.get('@onSuccessSpy').should('not.have.been.called');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('shows the error when the validation fails', () => {
|
|
150
|
+
cy.intercept(validateUrl, { statusCode: 400, body: { detail: 'File too large' } }).as('validate');
|
|
151
|
+
|
|
152
|
+
mountComponent();
|
|
153
|
+
cy.wait('@validate');
|
|
154
|
+
cy.get('.MuiAlert-colorError').contains('File too large').should('exist');
|
|
155
|
+
cy.contains('button', 'Close').click();
|
|
156
|
+
cy.get('@onCloseSpy').should('have.been.calledOnce');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('uses the given title and test id', () => {
|
|
160
|
+
cy.intercept(validateUrl, { statusCode: 200, body: validationRows }).as('validate');
|
|
161
|
+
cy.mount(<SubmitCloningStrategiesInBulkComponent files={files} onClose={cy.spy()} title="Custom title" dataTestId="custom-test-id" />);
|
|
162
|
+
cy.wait('@validate');
|
|
163
|
+
cy.get('[data-testid="custom-test-id"]').should('have.attr', 'role', 'dialog').within(() => {
|
|
164
|
+
cy.contains('Custom title').should('exist');
|
|
165
|
+
cy.get('tbody tr').should('have.length', 4);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('closes without submitting when cancelling', () => {
|
|
170
|
+
cy.intercept(validateUrl, { statusCode: 200, body: validationRows }).as('validate');
|
|
171
|
+
cy.intercept(submitUrl, cy.spy().as('submitSpy'));
|
|
172
|
+
|
|
173
|
+
mountComponent();
|
|
174
|
+
cy.wait('@validate');
|
|
175
|
+
cy.contains('button', 'Cancel').click();
|
|
176
|
+
cy.get('@onCloseSpy').should('have.been.calledOnce');
|
|
177
|
+
cy.get('@submitSpy').should('not.have.been.called');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { Alert, Box, Button } from '@mui/material';
|
|
4
|
+
import error2String from '@opencloning/utils/error2String';
|
|
5
|
+
import BulkUploadModal from './bulk_upload/BulkUploadModal';
|
|
6
|
+
import CloningStrategyBulkUploadPreviewTable from './bulk_upload/CloningStrategyBulkUploadPreviewTable';
|
|
7
|
+
import { validateCloningStrategyFiles, submitCloningStrategies } from './bulk_upload/cloning_strategy_bulk_api';
|
|
8
|
+
import { rowsToCloningStrategySyncResults } from './bulk_upload/bulk_upload_utils';
|
|
9
|
+
|
|
10
|
+
// Each dialog validates its own files, this gives it a unique key in the query cache
|
|
11
|
+
let validationCounter = 0;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Dialog to submit several cloning strategies at once (the bulk upload of sequences with history):
|
|
15
|
+
* the files are validated (and synced with the database), the user reviews the result, and the
|
|
16
|
+
* importable ones are submitted.
|
|
17
|
+
*
|
|
18
|
+
* files: array of File (OpenCloning JSON or SnapGene .dna).
|
|
19
|
+
* onSuccess(sequenceRefs, syncResults): sequenceRefs are returned by the server, all the sequences of the
|
|
20
|
+
* submitted strategies, both the created ones and those that were already in the database. syncResults
|
|
21
|
+
* are the submitted strategies ({ file_name, cloning_strategy }).
|
|
22
|
+
*/
|
|
23
|
+
function SubmitCloningStrategiesInBulkComponent({
|
|
24
|
+
files,
|
|
25
|
+
onClose,
|
|
26
|
+
onSuccess,
|
|
27
|
+
title = 'Upload to OpenCloningDB',
|
|
28
|
+
dataTestId = 'submit-cloning-strategies-in-bulk-dialog',
|
|
29
|
+
}) {
|
|
30
|
+
// Files cannot be part of the query key (they are not serializable), and React.useId would be
|
|
31
|
+
// reused if the dialog is closed and opened again in the same place, so a counter is used.
|
|
32
|
+
const [validationId] = React.useState(() => ++validationCounter);
|
|
33
|
+
const queryClient = useQueryClient();
|
|
34
|
+
const [bulkTags, setBulkTags] = React.useState([]);
|
|
35
|
+
// Validation rows returned by the server when a submission conflicts with the database
|
|
36
|
+
const [conflictRows, setConflictRows] = React.useState(null);
|
|
37
|
+
const [message, setMessage] = React.useState(null);
|
|
38
|
+
|
|
39
|
+
// A query (not a mutation) so that it is not sent twice in strict mode, gcTime 0 so that it is never reused
|
|
40
|
+
const validation = useQuery({
|
|
41
|
+
queryKey: ['cloningStrategyBulkValidate', validationId],
|
|
42
|
+
queryFn: () => validateCloningStrategyFiles(files),
|
|
43
|
+
retry: false,
|
|
44
|
+
gcTime: 0,
|
|
45
|
+
staleTime: Infinity,
|
|
46
|
+
refetchOnWindowFocus: false,
|
|
47
|
+
});
|
|
48
|
+
const rows = conflictRows ?? validation.data ?? [];
|
|
49
|
+
|
|
50
|
+
const submission = useMutation({
|
|
51
|
+
mutationFn: submitCloningStrategies,
|
|
52
|
+
onSuccess: (sequenceRefs, { syncResults }) => {
|
|
53
|
+
queryClient.invalidateQueries({ queryKey: ['sequences'] });
|
|
54
|
+
queryClient.invalidateQueries({ queryKey: ['primers'] });
|
|
55
|
+
onSuccess?.(sequenceRefs, syncResults);
|
|
56
|
+
onClose();
|
|
57
|
+
},
|
|
58
|
+
onError: (error) => {
|
|
59
|
+
const updatedRows = error?.response?.status === 409 ? error.response.data : null;
|
|
60
|
+
if (Array.isArray(updatedRows)) {
|
|
61
|
+
setConflictRows(updatedRows);
|
|
62
|
+
setMessage({ severity: 'warning', text: 'Conflicts detected while importing. Review the updated validation results.' });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
setMessage({ severity: 'error', text: error2String(error) });
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const handleSubmit = (submittedRows) => {
|
|
70
|
+
const syncResults = rowsToCloningStrategySyncResults(submittedRows);
|
|
71
|
+
if (syncResults.length < 1) {
|
|
72
|
+
setMessage({ severity: 'warning', text: 'No importable cloning strategies selected' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
setMessage(null);
|
|
76
|
+
submission.mutate({ syncResults, tags: bulkTags });
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<BulkUploadModal open onClose={submission.isPending ? undefined : onClose} title={title} dataTestId={dataTestId}>
|
|
81
|
+
{message && <Alert severity={message.severity} sx={{ mb: 2 }}>{message.text}</Alert>}
|
|
82
|
+
{validation.error ? (
|
|
83
|
+
<>
|
|
84
|
+
<Alert severity="error" sx={{ mb: 2 }}>{error2String(validation.error)}</Alert>
|
|
85
|
+
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
86
|
+
<Button variant="outlined" onClick={onClose}>Close</Button>
|
|
87
|
+
</Box>
|
|
88
|
+
</>
|
|
89
|
+
) : (
|
|
90
|
+
<CloningStrategyBulkUploadPreviewTable
|
|
91
|
+
rows={rows}
|
|
92
|
+
handleSubmit={handleSubmit}
|
|
93
|
+
handleCancel={onClose}
|
|
94
|
+
isSubmitting={submission.isPending}
|
|
95
|
+
isValidating={validation.isPending}
|
|
96
|
+
bulkTags={bulkTags}
|
|
97
|
+
onBulkTagsChange={setBulkTags}
|
|
98
|
+
onTagCreateError={(error) => setMessage({ severity: 'error', text: `Could not create tag: ${error2String(error)}` })}
|
|
99
|
+
/>
|
|
100
|
+
)}
|
|
101
|
+
</BulkUploadModal>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default SubmitCloningStrategiesInBulkComponent;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import TagMultiSelect from './TagMultiSelect';
|
|
3
|
+
import endpoints from './endpoints';
|
|
4
|
+
|
|
5
|
+
const tagsUrl = { method: 'GET', pathname: `/db${endpoints.tags}` };
|
|
6
|
+
const postTagUrl = { method: 'POST', pathname: `/db${endpoints.tagPost}` };
|
|
7
|
+
|
|
8
|
+
const tags = [{ id: 1, name: 'tag_one' }, { id: 2, name: 'tag_two' }];
|
|
9
|
+
|
|
10
|
+
// TagMultiSelect is controlled, this keeps the selected ids in state
|
|
11
|
+
function Harness({ onChangeSpy, ...props }) {
|
|
12
|
+
const [value, setValue] = React.useState([]);
|
|
13
|
+
return (
|
|
14
|
+
<TagMultiSelect
|
|
15
|
+
label="Tags"
|
|
16
|
+
value={value}
|
|
17
|
+
onChange={(ids) => { onChangeSpy(ids); setValue(ids); }}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function openOptions() {
|
|
24
|
+
cy.get('label').contains('Tags').siblings('div').first().click();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('<TagMultiSelect />', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
cy.setupOpenCloningDBTestAuth();
|
|
30
|
+
cy.intercept(tagsUrl, { statusCode: 200, body: tags }).as('getTags');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('selects tags by id', () => {
|
|
34
|
+
const onChangeSpy = cy.spy().as('onChangeSpy');
|
|
35
|
+
cy.mount(<Harness onChangeSpy={onChangeSpy} />);
|
|
36
|
+
cy.wait('@getTags');
|
|
37
|
+
openOptions();
|
|
38
|
+
cy.get('li[role="option"]').contains('tag_two').click();
|
|
39
|
+
cy.get('@onChangeSpy').should('have.been.calledWith', [2]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('does not show excluded tags', () => {
|
|
43
|
+
cy.mount(<Harness onChangeSpy={cy.spy()} excludeTagIds={[1]} />);
|
|
44
|
+
cy.wait('@getTags');
|
|
45
|
+
openOptions();
|
|
46
|
+
cy.get('li[role="option"]').should('have.length', 1);
|
|
47
|
+
cy.get('li[role="option"]').contains('tag_two').should('exist');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('creates a tag and selects it', () => {
|
|
51
|
+
const onChangeSpy = cy.spy().as('onChangeSpy');
|
|
52
|
+
cy.intercept(postTagUrl, (req) => {
|
|
53
|
+
expect(req.body).to.deep.equal({ name: 'new_tag' });
|
|
54
|
+
req.reply({ statusCode: 201, body: { id: 3, name: 'new_tag' } });
|
|
55
|
+
}).as('postTag');
|
|
56
|
+
cy.mount(<Harness onChangeSpy={onChangeSpy} />);
|
|
57
|
+
cy.wait('@getTags');
|
|
58
|
+
cy.intercept(tagsUrl, { statusCode: 200, body: [...tags, { id: 3, name: 'new_tag' }] }).as('getTagsAfterCreate');
|
|
59
|
+
cy.get('input').type('new_tag');
|
|
60
|
+
cy.contains('Create tag').click();
|
|
61
|
+
cy.wait('@postTag');
|
|
62
|
+
cy.wait('@getTagsAfterCreate');
|
|
63
|
+
cy.get('@onChangeSpy').should('have.been.calledWith', [3]);
|
|
64
|
+
cy.get('.MuiChip-root').contains('new_tag').should('exist');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('calls onCreateError when creating a tag fails', () => {
|
|
68
|
+
const onChangeSpy = cy.spy().as('onChangeSpy');
|
|
69
|
+
const onCreateErrorSpy = cy.spy().as('onCreateErrorSpy');
|
|
70
|
+
cy.intercept(postTagUrl, { statusCode: 409, body: { detail: 'Tag already exists' } }).as('postTag');
|
|
71
|
+
cy.mount(<Harness onChangeSpy={onChangeSpy} onCreateError={onCreateErrorSpy} />);
|
|
72
|
+
cy.wait('@getTags');
|
|
73
|
+
cy.get('input').type('tag_three');
|
|
74
|
+
cy.contains('Create tag').click();
|
|
75
|
+
cy.wait('@postTag');
|
|
76
|
+
cy.get('@onCreateErrorSpy').should('have.been.calledOnce');
|
|
77
|
+
cy.get('@onCreateErrorSpy').its('lastCall.args.0.response.data.detail').should('equal', 'Tag already exists');
|
|
78
|
+
cy.get('@onChangeSpy').should('not.have.been.called');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does not offer to create tags if allowCreate is false', () => {
|
|
82
|
+
cy.mount(<Harness onChangeSpy={cy.spy()} allowCreate={false} />);
|
|
83
|
+
cy.wait('@getTags');
|
|
84
|
+
cy.get('input').type('tag_three');
|
|
85
|
+
cy.contains('No options').should('exist');
|
|
86
|
+
cy.contains('Create tag').should('not.exist');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useQuery } from '@tanstack/react-query';
|
|
3
|
+
import { QuerySelect } from '@opencloning/ui';
|
|
4
|
+
import { openCloningDBHttpClient } from './common';
|
|
5
|
+
import endpoints from './endpoints';
|
|
6
|
+
import useCreateTagMutation from './useCreateTagMutation';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Multi-select of the workspace tags. value and onChange use arrays of tag ids.
|
|
10
|
+
* If allowCreate, a tag that does not exist can be created from the options popup,
|
|
11
|
+
* and it is added to the selection. onCreateError receives the error if the creation fails.
|
|
12
|
+
*/
|
|
13
|
+
function TagMultiSelect({ onChange, label = 'Tags', value, excludeTagIds = [], allowCreate = true, onCreateError, ...rest }) {
|
|
14
|
+
const query = React.useMemo(() => ({
|
|
15
|
+
queryKey: ['tags'],
|
|
16
|
+
queryFn: async () => {
|
|
17
|
+
const { data } = await openCloningDBHttpClient.get(endpoints.tags);
|
|
18
|
+
return data;
|
|
19
|
+
},
|
|
20
|
+
select: (tags) => tags.filter((tag) => !excludeTagIds.includes(tag.id)),
|
|
21
|
+
}), [excludeTagIds]);
|
|
22
|
+
|
|
23
|
+
const { data: options = [] } = useQuery(query);
|
|
24
|
+
const valueAsObjects = React.useMemo(
|
|
25
|
+
() => (value || []).map((id) => options.find((o) => o.id === id)).filter(Boolean),
|
|
26
|
+
[value, options],
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const createTagMutation = useCreateTagMutation({ onError: onCreateError });
|
|
30
|
+
|
|
31
|
+
const handleCreateTag = React.useCallback(async (inputValue) => {
|
|
32
|
+
let createdTag;
|
|
33
|
+
try {
|
|
34
|
+
createdTag = await createTagMutation.mutateAsync(inputValue);
|
|
35
|
+
} catch {
|
|
36
|
+
// Reported by onError
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const selectedTagIds = value || [];
|
|
40
|
+
onChange(selectedTagIds.includes(createdTag.id) ? selectedTagIds : [...selectedTagIds, createdTag.id]);
|
|
41
|
+
}, [createTagMutation, onChange, value]);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<QuerySelect
|
|
45
|
+
query={query}
|
|
46
|
+
label={label}
|
|
47
|
+
loadingMessage="retrieving tags"
|
|
48
|
+
errorMessage="Could not retrieve tags from OpenCloningDB"
|
|
49
|
+
getOptionLabel={(tag) => tag.name}
|
|
50
|
+
getOptionKey={(tag) => tag.id}
|
|
51
|
+
value={valueAsObjects}
|
|
52
|
+
onChange={(selectedTags) => onChange(selectedTags?.map((t) => t.id) ?? [])}
|
|
53
|
+
inputProps={{ size: 'small' }}
|
|
54
|
+
autoComplete={true}
|
|
55
|
+
noOptionsAction={allowCreate ? { label: 'Create tag', onClick: handleCreateTag } : null}
|
|
56
|
+
{...rest}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default TagMultiSelect;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Modal, Typography } from '@mui/material';
|
|
3
|
+
|
|
4
|
+
/** Modal used by the bulk upload previews. Pass onClose undefined to prevent closing it (e.g. while submitting). */
|
|
5
|
+
export default function BulkUploadModal({
|
|
6
|
+
open,
|
|
7
|
+
onClose,
|
|
8
|
+
title,
|
|
9
|
+
dataTestId,
|
|
10
|
+
children,
|
|
11
|
+
}) {
|
|
12
|
+
const titleId = React.useId();
|
|
13
|
+
return (
|
|
14
|
+
<Modal open={open} onClose={onClose} aria-labelledby={titleId}>
|
|
15
|
+
<Box
|
|
16
|
+
role="dialog"
|
|
17
|
+
aria-modal="true"
|
|
18
|
+
aria-labelledby={titleId}
|
|
19
|
+
data-testid={dataTestId}
|
|
20
|
+
sx={{
|
|
21
|
+
bgcolor: 'background.paper',
|
|
22
|
+
p: 3,
|
|
23
|
+
width: 'min(1280px, 98vw)',
|
|
24
|
+
maxHeight: '90vh',
|
|
25
|
+
overflow: 'hidden',
|
|
26
|
+
mx: 'auto',
|
|
27
|
+
my: '5vh',
|
|
28
|
+
borderRadius: 1,
|
|
29
|
+
display: 'flex',
|
|
30
|
+
flexDirection: 'column',
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<Typography id={titleId} variant="h6" sx={{ mb: 1, textAlign: 'center' }}>
|
|
34
|
+
{title}
|
|
35
|
+
</Typography>
|
|
36
|
+
{children}
|
|
37
|
+
</Box>
|
|
38
|
+
</Modal>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, CircularProgress } from '@mui/material';
|
|
3
|
+
import { BulkUploadPreviewTableWrapper } from './common';
|
|
4
|
+
|
|
5
|
+
export default function BulkUploadPreview({
|
|
6
|
+
rows,
|
|
7
|
+
getRowsInfo,
|
|
8
|
+
renderTable,
|
|
9
|
+
handleSubmit,
|
|
10
|
+
handleCancel,
|
|
11
|
+
isSubmitting,
|
|
12
|
+
isValidating,
|
|
13
|
+
bulkTags,
|
|
14
|
+
onBulkTagsChange,
|
|
15
|
+
onTagCreateError,
|
|
16
|
+
importMode,
|
|
17
|
+
}) {
|
|
18
|
+
if (isValidating) {
|
|
19
|
+
return (
|
|
20
|
+
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
|
21
|
+
<CircularProgress size={24} />
|
|
22
|
+
</Box>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const rowsInfo = getRowsInfo(rows);
|
|
27
|
+
return (
|
|
28
|
+
<BulkUploadPreviewTableWrapper
|
|
29
|
+
rowsInfo={rowsInfo}
|
|
30
|
+
handleSubmit={handleSubmit}
|
|
31
|
+
handleCancel={handleCancel}
|
|
32
|
+
isSubmitting={isSubmitting}
|
|
33
|
+
bulkTags={bulkTags}
|
|
34
|
+
onBulkTagsChange={onBulkTagsChange}
|
|
35
|
+
onTagCreateError={onTagCreateError}
|
|
36
|
+
importMode={importMode}
|
|
37
|
+
>
|
|
38
|
+
{renderTable(rowsInfo.orderedRows)}
|
|
39
|
+
</BulkUploadPreviewTableWrapper>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Table, TableBody, TableCell, TableHead, TableRow } from '@mui/material';
|
|
3
|
+
import {
|
|
4
|
+
cloningStrategyRowIssues,
|
|
5
|
+
cloningStrategyRowStatus,
|
|
6
|
+
getCloningStrategyRowsInfo,
|
|
7
|
+
} from './bulk_upload_utils';
|
|
8
|
+
import { CommonTableRow } from './common';
|
|
9
|
+
import BulkUploadPreview from './BulkUploadPreview';
|
|
10
|
+
|
|
11
|
+
function statusLabel(status) {
|
|
12
|
+
if (status === 'error') {
|
|
13
|
+
return 'Invalid';
|
|
14
|
+
}
|
|
15
|
+
if (status === 'warning') {
|
|
16
|
+
return 'Review warnings';
|
|
17
|
+
}
|
|
18
|
+
if (status === 'info') {
|
|
19
|
+
return 'Already synced';
|
|
20
|
+
}
|
|
21
|
+
return 'Ready to import';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function strategyCounts(row) {
|
|
25
|
+
return {
|
|
26
|
+
sequenceCount: row.cloning_strategy?.sequences?.length ?? 0,
|
|
27
|
+
primerCount: row.cloning_strategy?.primers?.length ?? 0,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function CloningStrategyBulkUploadPreviewTableRow({ row }) {
|
|
32
|
+
const issues = cloningStrategyRowIssues(row);
|
|
33
|
+
const status = cloningStrategyRowStatus(row);
|
|
34
|
+
const { sequenceCount, primerCount } = strategyCounts(row);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<CommonTableRow status={status} issues={issues}>
|
|
38
|
+
<TableCell>{row.file_name || '—'}</TableCell>
|
|
39
|
+
<TableCell>{statusLabel(status)}</TableCell>
|
|
40
|
+
<TableCell>{sequenceCount}</TableCell>
|
|
41
|
+
<TableCell>{primerCount}</TableCell>
|
|
42
|
+
</CommonTableRow>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function TableComponent({ rows }) {
|
|
47
|
+
return (
|
|
48
|
+
<Table stickyHeader size="small" sx={{ tableLayout: 'fixed', width: '100%' }}>
|
|
49
|
+
<colgroup>
|
|
50
|
+
<col style={{ width: '48px' }} />
|
|
51
|
+
<col style={{ width: '25%' }} />
|
|
52
|
+
<col style={{ width: '17%' }} />
|
|
53
|
+
<col style={{ width: '10%' }} />
|
|
54
|
+
<col style={{ width: '10%' }} />
|
|
55
|
+
<col style={{ width: '38%' }} />
|
|
56
|
+
</colgroup>
|
|
57
|
+
<TableHead sx={{ textAlign: 'center' }}>
|
|
58
|
+
<TableRow sx={{ textAlign: 'center' }}>
|
|
59
|
+
<TableCell padding="checkbox"></TableCell>
|
|
60
|
+
<TableCell>File Name</TableCell>
|
|
61
|
+
<TableCell>Sync State</TableCell>
|
|
62
|
+
<TableCell>Sequences</TableCell>
|
|
63
|
+
<TableCell>Primers</TableCell>
|
|
64
|
+
<TableCell>Issues</TableCell>
|
|
65
|
+
</TableRow>
|
|
66
|
+
</TableHead>
|
|
67
|
+
<TableBody>
|
|
68
|
+
{rows.map((row, index) => (
|
|
69
|
+
<CloningStrategyBulkUploadPreviewTableRow
|
|
70
|
+
key={`${row.file_name || 'cloning-strategy'}-${index}`}
|
|
71
|
+
row={row}
|
|
72
|
+
/>
|
|
73
|
+
))}
|
|
74
|
+
</TableBody>
|
|
75
|
+
</Table>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default function CloningStrategyBulkUploadPreviewTable({
|
|
80
|
+
rows,
|
|
81
|
+
handleSubmit,
|
|
82
|
+
handleCancel,
|
|
83
|
+
isSubmitting,
|
|
84
|
+
isValidating,
|
|
85
|
+
bulkTags,
|
|
86
|
+
onBulkTagsChange,
|
|
87
|
+
onTagCreateError,
|
|
88
|
+
}) {
|
|
89
|
+
return (
|
|
90
|
+
<BulkUploadPreview
|
|
91
|
+
rows={rows}
|
|
92
|
+
getRowsInfo={getCloningStrategyRowsInfo}
|
|
93
|
+
renderTable={(orderedRows) => <TableComponent rows={orderedRows} />}
|
|
94
|
+
handleSubmit={handleSubmit}
|
|
95
|
+
handleCancel={handleCancel}
|
|
96
|
+
isSubmitting={isSubmitting}
|
|
97
|
+
isValidating={isValidating}
|
|
98
|
+
bulkTags={bulkTags}
|
|
99
|
+
onBulkTagsChange={onBulkTagsChange}
|
|
100
|
+
onTagCreateError={onTagCreateError}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Helpers to display the validation rows returned by the cloning strategy bulk validate endpoint. */
|
|
2
|
+
|
|
3
|
+
function formatMismatchKind(kind) {
|
|
4
|
+
return String(kind ?? '').replaceAll('_', ' ');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function cloningStrategyRowIssues(row) {
|
|
8
|
+
const issues = [];
|
|
9
|
+
(row.parsing_errors ?? []).forEach((error) => issues.push(error));
|
|
10
|
+
(row.parsing_warnings ?? []).forEach((warning) => issues.push(warning));
|
|
11
|
+
(row.primer_database_id_mismatches ?? []).forEach((mismatch) => {
|
|
12
|
+
issues.push(
|
|
13
|
+
`Primer ${mismatch.primer_id} database_id mismatch: ${formatMismatchKind(mismatch.kind)} (provided ${mismatch.provided_database_id})`,
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
(row.sequence_database_id_mismatches ?? []).forEach((mismatch) => {
|
|
17
|
+
issues.push(
|
|
18
|
+
`Sequence ${mismatch.sequence_id} database_id mismatch: ${formatMismatchKind(mismatch.kind)} (provided ${mismatch.provided_database_id})`,
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
if (row.already_synced) {
|
|
22
|
+
issues.push('Already synced to this workspace');
|
|
23
|
+
}
|
|
24
|
+
return issues;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function cloningStrategyRowStatus(row) {
|
|
28
|
+
if ((row.parsing_errors ?? []).length > 0) {
|
|
29
|
+
return 'error';
|
|
30
|
+
}
|
|
31
|
+
if (row.already_synced) {
|
|
32
|
+
return 'info';
|
|
33
|
+
}
|
|
34
|
+
if ((row.parsing_warnings ?? []).length > 0) {
|
|
35
|
+
return 'warning';
|
|
36
|
+
}
|
|
37
|
+
if ((row.primer_database_id_mismatches ?? []).length > 0) {
|
|
38
|
+
return 'warning';
|
|
39
|
+
}
|
|
40
|
+
if ((row.sequence_database_id_mismatches ?? []).length > 0) {
|
|
41
|
+
return 'warning';
|
|
42
|
+
}
|
|
43
|
+
return 'clear';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getCloningStrategyRowsInfo(rows) {
|
|
47
|
+
const errorRows = rows.filter((row) => cloningStrategyRowStatus(row) === 'error');
|
|
48
|
+
const warningRows = rows.filter((row) => cloningStrategyRowStatus(row) === 'warning');
|
|
49
|
+
const infoRows = rows.filter((row) => cloningStrategyRowStatus(row) === 'info');
|
|
50
|
+
const clearRows = rows.filter((row) => cloningStrategyRowStatus(row) === 'clear');
|
|
51
|
+
const orderedRows = [...errorRows, ...warningRows, ...infoRows, ...clearRows];
|
|
52
|
+
const clearAndWarningRows = [...warningRows, ...clearRows];
|
|
53
|
+
return {
|
|
54
|
+
errorRows,
|
|
55
|
+
warningRows,
|
|
56
|
+
infoRows,
|
|
57
|
+
clearRows,
|
|
58
|
+
orderedRows,
|
|
59
|
+
clearAndWarningRows,
|
|
60
|
+
clearRowsCount: clearRows.length,
|
|
61
|
+
warningRowsCount: warningRows.length,
|
|
62
|
+
infoRowsCount: infoRows.length,
|
|
63
|
+
errorRowsCount: errorRows.length,
|
|
64
|
+
totalRowsCount: rows.length,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The rows that can be submitted (not already synced, with a cloning strategy), in the format of the submit endpoint. */
|
|
69
|
+
export function rowsToCloningStrategySyncResults(rows) {
|
|
70
|
+
return rows
|
|
71
|
+
.filter((row) => !row.already_synced && row.cloning_strategy)
|
|
72
|
+
.map(({ file_name: fileName, cloning_strategy: cloningStrategy }) => ({
|
|
73
|
+
['file_name']: fileName,
|
|
74
|
+
['cloning_strategy']: cloningStrategy,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { cloningStrategyRowIssues, cloningStrategyRowStatus, getCloningStrategyRowsInfo, rowsToCloningStrategySyncResults } from './bulk_upload_utils';
|
|
2
|
+
|
|
3
|
+
const clearRow = { file_name: 'clear.json', cloning_strategy: {} };
|
|
4
|
+
const errorRow = { file_name: 'error.json', parsing_errors: ['Cloning strategy is not valid JSON'] };
|
|
5
|
+
const syncedRow = { file_name: 'synced.json', cloning_strategy: {}, already_synced: true };
|
|
6
|
+
const parsingWarningRow = { file_name: 'warning.json', cloning_strategy: {}, parsing_warnings: ['old schema'] };
|
|
7
|
+
const primerMismatchRow = {
|
|
8
|
+
file_name: 'primer.json',
|
|
9
|
+
cloning_strategy: {},
|
|
10
|
+
primer_database_id_mismatches: [{ primer_id: 3, kind: 'not_in_database', provided_database_id: 7 }],
|
|
11
|
+
};
|
|
12
|
+
const sequenceMismatchRow = {
|
|
13
|
+
file_name: 'sequence.json',
|
|
14
|
+
cloning_strategy: {},
|
|
15
|
+
sequence_database_id_mismatches: [{ sequence_id: 2, kind: 'different_sequence', provided_database_id: 10 }],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('cloningStrategyRowStatus', () => {
|
|
19
|
+
it('returns the status of each kind of row', () => {
|
|
20
|
+
expect(cloningStrategyRowStatus(clearRow)).toBe('clear');
|
|
21
|
+
expect(cloningStrategyRowStatus(errorRow)).toBe('error');
|
|
22
|
+
expect(cloningStrategyRowStatus(syncedRow)).toBe('info');
|
|
23
|
+
expect(cloningStrategyRowStatus(parsingWarningRow)).toBe('warning');
|
|
24
|
+
expect(cloningStrategyRowStatus(primerMismatchRow)).toBe('warning');
|
|
25
|
+
expect(cloningStrategyRowStatus(sequenceMismatchRow)).toBe('warning');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('gives precedence to errors, then already synced, then warnings', () => {
|
|
29
|
+
expect(cloningStrategyRowStatus({ ...syncedRow, ...parsingWarningRow, parsing_errors: ['bad'] })).toBe('error');
|
|
30
|
+
expect(cloningStrategyRowStatus({ ...parsingWarningRow, ...primerMismatchRow, already_synced: true })).toBe('info');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('cloningStrategyRowIssues', () => {
|
|
35
|
+
it('returns no issues for a clear row', () => {
|
|
36
|
+
expect(cloningStrategyRowIssues(clearRow)).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('lists errors, warnings, mismatches and sync state', () => {
|
|
40
|
+
const row = {
|
|
41
|
+
...primerMismatchRow,
|
|
42
|
+
...sequenceMismatchRow,
|
|
43
|
+
parsing_errors: ['error 1'],
|
|
44
|
+
parsing_warnings: ['warning 1'],
|
|
45
|
+
already_synced: true,
|
|
46
|
+
};
|
|
47
|
+
expect(cloningStrategyRowIssues(row)).toEqual([
|
|
48
|
+
'error 1',
|
|
49
|
+
'warning 1',
|
|
50
|
+
'Primer 3 database_id mismatch: not in database (provided 7)',
|
|
51
|
+
'Sequence 2 database_id mismatch: different sequence (provided 10)',
|
|
52
|
+
'Already synced to this workspace',
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('getCloningStrategyRowsInfo', () => {
|
|
58
|
+
it('orders rows (errors, warnings, info, clear) and counts them', () => {
|
|
59
|
+
const rows = [clearRow, syncedRow, parsingWarningRow, errorRow, sequenceMismatchRow];
|
|
60
|
+
const info = getCloningStrategyRowsInfo(rows);
|
|
61
|
+
expect(info.orderedRows).toEqual([errorRow, parsingWarningRow, sequenceMismatchRow, syncedRow, clearRow]);
|
|
62
|
+
expect(info.clearRows).toEqual([clearRow]);
|
|
63
|
+
expect(info.clearAndWarningRows).toEqual([parsingWarningRow, sequenceMismatchRow, clearRow]);
|
|
64
|
+
expect(info.errorRowsCount).toBe(1);
|
|
65
|
+
expect(info.warningRowsCount).toBe(2);
|
|
66
|
+
expect(info.infoRowsCount).toBe(1);
|
|
67
|
+
expect(info.clearRowsCount).toBe(1);
|
|
68
|
+
expect(info.totalRowsCount).toBe(5);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('rowsToCloningStrategySyncResults', () => {
|
|
73
|
+
it('keeps the rows with a strategy that are not synced, with only file_name and cloning_strategy', () => {
|
|
74
|
+
const rows = [clearRow, syncedRow, parsingWarningRow, errorRow, sequenceMismatchRow];
|
|
75
|
+
expect(rowsToCloningStrategySyncResults(rows)).toEqual([
|
|
76
|
+
{ file_name: 'clear.json', cloning_strategy: {} },
|
|
77
|
+
{ file_name: 'warning.json', cloning_strategy: {} },
|
|
78
|
+
{ file_name: 'sequence.json', cloning_strategy: {} },
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns an empty array if nothing can be submitted', () => {
|
|
83
|
+
expect(rowsToCloningStrategySyncResults([syncedRow, errorRow])).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { openCloningDBHttpClient } from '../common';
|
|
2
|
+
import endpoints from '../endpoints';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate cloning strategy files (OpenCloning JSON or SnapGene .dna) against the database.
|
|
6
|
+
* files: array of File. Resolves to the validation rows, one per file.
|
|
7
|
+
*/
|
|
8
|
+
export async function validateCloningStrategyFiles(files) {
|
|
9
|
+
const formData = new FormData();
|
|
10
|
+
files.forEach((file) => {
|
|
11
|
+
formData.append('files', file);
|
|
12
|
+
});
|
|
13
|
+
const { data } = await openCloningDBHttpClient.post(endpoints.sequencesCloningStrategyBulkValidate, formData, {
|
|
14
|
+
headers: { 'Content-Type': 'multipart/form-data' },
|
|
15
|
+
});
|
|
16
|
+
return data;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Submit validated cloning strategies. syncResults as returned by rowsToCloningStrategySyncResults,
|
|
21
|
+
* tags: tag ids to apply to the new sequences and primers. Resolves to the sequence refs of the strategies.
|
|
22
|
+
*/
|
|
23
|
+
export async function submitCloningStrategies({ syncResults, tags }) {
|
|
24
|
+
const { data } = await openCloningDBHttpClient.post(endpoints.sequencesCloningStrategyBulk, syncResults, {
|
|
25
|
+
params: tags?.length ? { tags } : {},
|
|
26
|
+
});
|
|
27
|
+
return data;
|
|
28
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Typography, TableContainer, Paper, Button, CircularProgress, Box, TableRow, TableCell, FormControl } from '@mui/material';
|
|
3
|
+
import TagMultiSelect from '../TagMultiSelect';
|
|
4
|
+
import { Cancel as CancelIcon, CheckCircle as CheckCircleIcon, Info as InfoIcon, Warning as WarningIcon } from '@mui/icons-material';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wraps a bulk upload preview table with the summary and the import / cancel buttons.
|
|
9
|
+
* The tag field is shown if onBulkTagsChange is passed, onTagCreateError receives the error if creating a tag fails.
|
|
10
|
+
*/
|
|
11
|
+
export function BulkUploadPreviewTableWrapper({
|
|
12
|
+
rowsInfo,
|
|
13
|
+
handleSubmit,
|
|
14
|
+
handleCancel,
|
|
15
|
+
isSubmitting,
|
|
16
|
+
children,
|
|
17
|
+
bulkTags = [],
|
|
18
|
+
onBulkTagsChange,
|
|
19
|
+
onTagCreateError,
|
|
20
|
+
importMode = 'clearAndWarnings',
|
|
21
|
+
}) {
|
|
22
|
+
const { clearRows, clearAndWarningRows, warningRowsCount, errorRowsCount, infoRowsCount = 0, orderedRows } = rowsInfo;
|
|
23
|
+
const allClearRequired = importMode === 'allClearRequired';
|
|
24
|
+
const canImportAll = clearRows.length === orderedRows.length && orderedRows.length > 0;
|
|
25
|
+
const summaryText = allClearRequired
|
|
26
|
+
? `${clearRows.length} clear, ${errorRowsCount} error${infoRowsCount ? `, ${infoRowsCount} informational` : ''} out of ${orderedRows.length} uploaded items.`
|
|
27
|
+
: `${clearRows.length} clear, ${warningRowsCount} warning, ${errorRowsCount} error${infoRowsCount ? `, ${infoRowsCount} informational` : ''} out of ${orderedRows.length} uploaded items.`;
|
|
28
|
+
const orderingText = allClearRequired
|
|
29
|
+
? 'All rows must be clear to import. Errors are shown first.'
|
|
30
|
+
: infoRowsCount > 0
|
|
31
|
+
? 'Errors are shown first, then warnings, informational rows, and clear rows.'
|
|
32
|
+
: 'Errors are shown first, then warnings, then clear rows.';
|
|
33
|
+
|
|
34
|
+
const clearButtonText = warningRowsCount === 0 ? 'Import' : 'Import entries without warnings';
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
{onBulkTagsChange && (
|
|
38
|
+
<FormControl fullWidth sx={{ mb: 2 }}>
|
|
39
|
+
<TagMultiSelect
|
|
40
|
+
label="Tags to apply (optional)"
|
|
41
|
+
value={bulkTags}
|
|
42
|
+
onChange={onBulkTagsChange}
|
|
43
|
+
onCreateError={onTagCreateError}
|
|
44
|
+
data-testid="bulk-upload-tags"
|
|
45
|
+
/>
|
|
46
|
+
</FormControl>
|
|
47
|
+
)}
|
|
48
|
+
<Typography variant="body2" sx={{ mb: 2 }}>
|
|
49
|
+
{orderingText}
|
|
50
|
+
</Typography>
|
|
51
|
+
|
|
52
|
+
<TableContainer
|
|
53
|
+
component={Paper}
|
|
54
|
+
variant="outlined"
|
|
55
|
+
sx={{ mb: 2, maxHeight: '55vh', overflowY: 'auto', flex: 1, minHeight: 0 }}
|
|
56
|
+
>
|
|
57
|
+
{children}
|
|
58
|
+
</TableContainer>
|
|
59
|
+
|
|
60
|
+
<Typography variant="body2" sx={{ mb: 2 }}>
|
|
61
|
+
{summaryText}
|
|
62
|
+
</Typography>
|
|
63
|
+
|
|
64
|
+
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
|
65
|
+
<Button
|
|
66
|
+
variant="outlined"
|
|
67
|
+
color="error"
|
|
68
|
+
onClick={handleCancel}
|
|
69
|
+
disabled={isSubmitting}
|
|
70
|
+
>
|
|
71
|
+
Cancel
|
|
72
|
+
</Button>
|
|
73
|
+
{allClearRequired ? (
|
|
74
|
+
<Button
|
|
75
|
+
variant="contained"
|
|
76
|
+
onClick={() => handleSubmit(orderedRows)}
|
|
77
|
+
disabled={!canImportAll || isSubmitting}
|
|
78
|
+
data-testid="bulk-upload-import-button"
|
|
79
|
+
>
|
|
80
|
+
{isSubmitting ? <CircularProgress size={24} /> : 'Import'}
|
|
81
|
+
</Button>
|
|
82
|
+
) : (
|
|
83
|
+
<>
|
|
84
|
+
<Button
|
|
85
|
+
variant="contained"
|
|
86
|
+
onClick={() => handleSubmit(clearRows, 'clear')}
|
|
87
|
+
disabled={clearRows.length < 1 || isSubmitting}
|
|
88
|
+
>
|
|
89
|
+
{isSubmitting ? <CircularProgress size={24} /> : clearButtonText}
|
|
90
|
+
</Button>
|
|
91
|
+
{warningRowsCount > 0 && (
|
|
92
|
+
<Button
|
|
93
|
+
variant="contained"
|
|
94
|
+
color="warning"
|
|
95
|
+
onClick={() => handleSubmit(clearAndWarningRows, 'with warnings')}
|
|
96
|
+
disabled={clearAndWarningRows.length < 1 || isSubmitting}
|
|
97
|
+
>
|
|
98
|
+
{isSubmitting ? <CircularProgress size={24} /> : 'Import entries with warnings too'}
|
|
99
|
+
</Button>
|
|
100
|
+
)}
|
|
101
|
+
</>
|
|
102
|
+
)}
|
|
103
|
+
</Box>
|
|
104
|
+
</>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function StatusIcon({ status }) {
|
|
109
|
+
const isClear = status === 'clear';
|
|
110
|
+
const isError = status === 'error';
|
|
111
|
+
const isInfo = status === 'info';
|
|
112
|
+
|
|
113
|
+
return isClear ? (
|
|
114
|
+
<CheckCircleIcon color="success" />
|
|
115
|
+
) : isError ? (
|
|
116
|
+
<CancelIcon color="error" />
|
|
117
|
+
) : isInfo ? (
|
|
118
|
+
<InfoIcon color="info" />
|
|
119
|
+
) : (
|
|
120
|
+
<WarningIcon color="warning" />
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
export function CommonTableRow({ status, issues, children }) {
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<TableRow>
|
|
129
|
+
<TableCell align="center" padding="checkbox">
|
|
130
|
+
<StatusIcon status={status} />
|
|
131
|
+
</TableCell>
|
|
132
|
+
{children}
|
|
133
|
+
<TableCell>
|
|
134
|
+
{status !== 'clear' && (
|
|
135
|
+
issues.map((issue) => (
|
|
136
|
+
<div key={issue}>{issue}</div>
|
|
137
|
+
))
|
|
138
|
+
)}
|
|
139
|
+
</TableCell>
|
|
140
|
+
</TableRow>
|
|
141
|
+
);
|
|
142
|
+
}
|
package/src/endpoints.js
CHANGED
|
@@ -6,6 +6,7 @@ const endpoints = {
|
|
|
6
6
|
sequences: '/sequences',
|
|
7
7
|
sequence: (id) => `/sequences/${e(id)}`,
|
|
8
8
|
sequenceTextFile: (id) => `/sequences/${e(id)}/text_file_sequence`,
|
|
9
|
+
sequenceTextFiles: '/sequences/text_file_sequences',
|
|
9
10
|
sequenceChangeAnnotation: (id) => `/sequences/${e(id)}/change_annotation`,
|
|
10
11
|
sequenceCloningStrategy: (id) => `/sequences/${e(id)}/cloning_strategy`,
|
|
11
12
|
sequenceChildren: (id) => `/sequences/${e(id)}/children`,
|
package/src/index.js
CHANGED
|
@@ -2,3 +2,8 @@ export { default as OpenCloningDBInterface } from './OpenCloningDBInterface.js';
|
|
|
2
2
|
export { openCloningDBHttpClient, setUnauthorizedHandler, setHttpClientTokenGetter, setWorkspaceHeader, clearWorkspaceHeader, baseUrl, formatSequenceName } from './common.js';
|
|
3
3
|
export { default as endpoints } from './endpoints.js';
|
|
4
4
|
export { default as SequenceSelect } from './SequenceSelect.jsx';
|
|
5
|
+
export { default as TagMultiSelect } from './TagMultiSelect.jsx';
|
|
6
|
+
export { default as SubmitCloningStrategiesInBulkComponent } from './SubmitCloningStrategiesInBulkComponent.jsx';
|
|
7
|
+
export { default as BulkUploadPreview } from './bulk_upload/BulkUploadPreview.jsx';
|
|
8
|
+
export { default as BulkUploadModal } from './bulk_upload/BulkUploadModal.jsx';
|
|
9
|
+
export { CommonTableRow } from './bulk_upload/common.jsx';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { openCloningDBHttpClient } from './common';
|
|
3
|
+
import endpoints from './endpoints';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mutation to create a tag, called with the tag name, resolves to the created tag.
|
|
7
|
+
* onError receives the error if the creation fails.
|
|
8
|
+
*/
|
|
9
|
+
export default function useCreateTagMutation({ onError } = {}) {
|
|
10
|
+
const queryClient = useQueryClient();
|
|
11
|
+
return useMutation({
|
|
12
|
+
mutationFn: async (tagName) => {
|
|
13
|
+
const { data } = await openCloningDBHttpClient.post(endpoints.tagPost, { name: tagName });
|
|
14
|
+
return data;
|
|
15
|
+
},
|
|
16
|
+
onSuccess: () => {
|
|
17
|
+
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
|
18
|
+
},
|
|
19
|
+
onError,
|
|
20
|
+
});
|
|
21
|
+
}
|