@opencloning/opencloningdb 1.8.3 → 1.9.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 +82 -0
- package/package.json +4 -4
- 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/common.js +7 -2
- package/src/common.test.js +8 -7
- package/src/endpoints.js +28 -21
- package/src/endpoints.test.js +17 -0
- package/src/index.js +6 -1
- package/src/testUtils.test.js +3 -3
- package/src/useCreateTagMutation.js +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,87 @@
|
|
|
1
1
|
# @opencloning/opencloningdb
|
|
2
2
|
|
|
3
|
+
## 1.9.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#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.
|
|
8
|
+
|
|
9
|
+
- `@opencloning/ui`:
|
|
10
|
+
- The Assembler's plasmid buttons are grouped into a "Load Plasmids" menu (from files, from the local server, from the database).
|
|
11
|
+
- 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.
|
|
12
|
+
- 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.
|
|
13
|
+
- Both are new optional entries of the database interface (see `DummyInterface`).
|
|
14
|
+
- `@opencloning/opencloningdb`:
|
|
15
|
+
- Implements `GetSequencesInBulkComponent` and `SubmitCloningStrategiesInBulkComponent`.
|
|
16
|
+
- Exports `TagMultiSelect` (with `allowCreate` and `onCreateError`), `BulkUploadModal`, `BulkUploadPreview` and `CommonTableRow`, moved from the `opencloningdb` app.
|
|
17
|
+
- `opencloningdb`:
|
|
18
|
+
- The "Upload sequences with history" button uses `SubmitCloningStrategiesInBulkComponent`. Its validation, submission and conflict messages are now shown in the dialog instead of as alerts.
|
|
19
|
+
- The tag selects no longer drop excluded tags from the shared tags cache.
|
|
20
|
+
- `syntax-builder`: the dev server runs on port 3000 and listens on `127.0.0.1`, like the other apps.
|
|
21
|
+
|
|
22
|
+
### Patch Changes
|
|
23
|
+
|
|
24
|
+
- Updated dependencies [[`c8c46d4`](https://github.com/OpenCloning/OpenCloning_frontend/commit/c8c46d4dc36cb8741d1bd89816c7ca82b89996b3)]:
|
|
25
|
+
- @opencloning/ui@2.1.0
|
|
26
|
+
- @opencloning/utils@2.1.0
|
|
27
|
+
|
|
28
|
+
## 1.8.8
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- [#778](https://github.com/OpenCloning/OpenCloning_frontend/pull/778) [`fe1d81c`](https://github.com/OpenCloning/OpenCloning_frontend/commit/fe1d81c6f4b85f6317a2a9e46cc68c571c79c394) Thanks [@manulera](https://github.com/manulera)! - Only send the auth token to the backend, and rename the http client APIs.
|
|
33
|
+
|
|
34
|
+
- `@opencloning/utils`: `@opencloning/utils/httpClients` exports `getBackendHttpClient(backendUrl, { authenticated })`, a client that can only reach the backend (`allowAbsoluteUrls: false`) and is the only one carrying the auth token, and `getExternalHttpClient()` for whitelisted external urls. Replaces `@opencloning/utils/getHttpClient` (`getHttpClient`, `getAuthenticatedHttpClient`). `attachAuthInterceptors` refuses clients that could send the token elsewhere.
|
|
35
|
+
- `@opencloning/ui` (breaking): `useHttpClient` is renamed `useBackendHttpClient` and takes urls relative to `backendUrl`; `useBackendRoute` is removed; `useBackendAPI` is renamed `useSourceRequest` and `sendPostRequest` is renamed `sendSourceRequest`.
|
|
36
|
+
- `@opencloning/opencloningdb`, `@opencloning/opencloning-elabftw`: clients can no longer be pointed at other hosts.
|
|
37
|
+
|
|
38
|
+
- [#778](https://github.com/OpenCloning/OpenCloning_frontend/pull/778) [`fe1d81c`](https://github.com/OpenCloning/OpenCloning_frontend/commit/fe1d81c6f4b85f6317a2a9e46cc68c571c79c394) Thanks [@manulera](https://github.com/manulera)! - Encode values interpolated into request URL paths to prevent client-side path traversal. A sample UID such as `../workspaces/1/users/5` could make a user's own request hit a different endpoint, e.g. "delete UID" removing a workspace member.
|
|
39
|
+
|
|
40
|
+
- `@opencloning/utils`: new `encodePathSegment` (`@opencloning/utils/urlUtils`), which applies `encodeURIComponent` and rejects `.` and `..`.
|
|
41
|
+
- `@opencloning/opencloningdb`: all path parameters in `endpoints.js` are encoded.
|
|
42
|
+
- `@opencloning/opencloning-elabftw`: eLabFTW item and upload paths are encoded.
|
|
43
|
+
- `@opencloning/ui`: the `example`, `key` and `template` URL parameters are encoded when loading examples and templates.
|
|
44
|
+
|
|
45
|
+
- Updated dependencies [[`fe1d81c`](https://github.com/OpenCloning/OpenCloning_frontend/commit/fe1d81c6f4b85f6317a2a9e46cc68c571c79c394), [`fe1d81c`](https://github.com/OpenCloning/OpenCloning_frontend/commit/fe1d81c6f4b85f6317a2a9e46cc68c571c79c394), [`fe1d81c`](https://github.com/OpenCloning/OpenCloning_frontend/commit/fe1d81c6f4b85f6317a2a9e46cc68c571c79c394)]:
|
|
46
|
+
- @opencloning/utils@2.0.0
|
|
47
|
+
- @opencloning/ui@2.0.0
|
|
48
|
+
|
|
49
|
+
## 1.8.7
|
|
50
|
+
|
|
51
|
+
### Patch Changes
|
|
52
|
+
|
|
53
|
+
- Updated dependencies [[`f7f376e`](https://github.com/OpenCloning/OpenCloning_frontend/commit/f7f376ee351d9b2da52413700836dc55ba54b5ea)]:
|
|
54
|
+
- @opencloning/ui@1.10.0
|
|
55
|
+
- @opencloning/utils@1.10.0
|
|
56
|
+
|
|
57
|
+
## 1.8.6
|
|
58
|
+
|
|
59
|
+
### Patch Changes
|
|
60
|
+
|
|
61
|
+
- [#771](https://github.com/OpenCloning/OpenCloning_frontend/pull/771) [`73363e7`](https://github.com/OpenCloning/OpenCloning_frontend/commit/73363e7a7f7e494e20a54bd87e5dba97f8a9197e) Thanks [@manulera](https://github.com/manulera)! - Keep OIDC sessions signed in by requesting a token on each API call instead of reusing a stored JWT
|
|
62
|
+
|
|
63
|
+
- Updated dependencies [[`73363e7`](https://github.com/OpenCloning/OpenCloning_frontend/commit/73363e7a7f7e494e20a54bd87e5dba97f8a9197e)]:
|
|
64
|
+
- @opencloning/utils@1.9.5
|
|
65
|
+
- @opencloning/ui@1.9.5
|
|
66
|
+
|
|
67
|
+
## 1.8.5
|
|
68
|
+
|
|
69
|
+
### Patch Changes
|
|
70
|
+
|
|
71
|
+
- Updated dependencies []:
|
|
72
|
+
- @opencloning/ui@1.9.4
|
|
73
|
+
- @opencloning/utils@1.9.4
|
|
74
|
+
|
|
75
|
+
## 1.8.4
|
|
76
|
+
|
|
77
|
+
### Patch Changes
|
|
78
|
+
|
|
79
|
+
- [#741](https://github.com/OpenCloning/OpenCloning_frontend/pull/741) [`c65f24a`](https://github.com/OpenCloning/OpenCloning_frontend/commit/c65f24ae853b45005e9a7f429236b8b8d7ea84fa) Thanks [@manulera](https://github.com/manulera)! - Add workspace functionality to add and remove users by owners
|
|
80
|
+
|
|
81
|
+
- Updated dependencies [[`df69a1b`](https://github.com/OpenCloning/OpenCloning_frontend/commit/df69a1bfb3b3128e93d89eef48a29f81a54ebb82)]:
|
|
82
|
+
- @opencloning/utils@1.9.3
|
|
83
|
+
- @opencloning/ui@1.9.3
|
|
84
|
+
|
|
3
85
|
## 1.8.3
|
|
4
86
|
|
|
5
87
|
### 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.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@mui/icons-material": "^5.18.0",
|
|
17
17
|
"@mui/material": "^5.18.0",
|
|
18
|
-
"@opencloning/ui": "1.
|
|
19
|
-
"@opencloning/utils": "1.
|
|
20
|
-
"axios": "^1.
|
|
18
|
+
"@opencloning/ui": "2.1.0",
|
|
19
|
+
"@opencloning/utils": "2.1.0",
|
|
20
|
+
"axios": "^1.20.0"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"react": "^18.3.1",
|
|
@@ -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;
|