@opencloning/opencloningdb 1.7.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 +21 -0
- package/package.json +30 -0
- package/src/GetPrimerComponent.cy.jsx +57 -0
- package/src/GetPrimerComponent.jsx +43 -0
- package/src/GetSequenceFileAndDatabaseIdComponent.cy.jsx +102 -0
- package/src/GetSequenceFileAndDatabaseIdComponent.jsx +62 -0
- package/src/LoadHistoryComponent.cy.jsx +95 -0
- package/src/LoadHistoryComponent.jsx +35 -0
- package/src/OpenCloningDBInterface.js +141 -0
- package/src/OpenCloningDBInterface.multipart.test.js +65 -0
- package/src/OpenCloningDBInterface.test.js +156 -0
- package/src/PrimerSelect.cy.jsx +51 -0
- package/src/PrimerSelect.jsx +40 -0
- package/src/PrimersNotInDatabaseComponent.cy.jsx +57 -0
- package/src/PrimersNotInDatabaseComponent.jsx +40 -0
- package/src/SequenceSelect.cy.jsx +32 -0
- package/src/SequenceSelect.jsx +44 -0
- package/src/SubmitToDatabaseComponent.cy.jsx +74 -0
- package/src/SubmitToDatabaseComponent.jsx +44 -0
- package/src/common.js +60 -0
- package/src/common.test.js +108 -0
- package/src/endpoints.js +46 -0
- package/src/index.js +4 -0
- package/src/testUtils.test.js +90 -0
- package/vitest.config.js +8 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @opencloning/opencloningdb
|
|
2
|
+
|
|
3
|
+
## 1.7.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [[`8e841bd`](https://github.com/OpenCloning/OpenCloning_frontend/commit/8e841bdee4797df61bcf8d1972e3c3581d24fbfb)]:
|
|
8
|
+
- @opencloning/ui@1.8.1
|
|
9
|
+
- @opencloning/utils@1.8.1
|
|
10
|
+
|
|
11
|
+
## 1.7.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- [#694](https://github.com/OpenCloning/OpenCloning_frontend/pull/694) [`2ff9010`](https://github.com/OpenCloning/OpenCloning_frontend/commit/2ff90103c1d9e8e984533e51ecf4dc7978756a57) Thanks [@manulera](https://github.com/manulera)! - Add package and app for a dedicated OpenCloning database, OpenCloningDB
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- Updated dependencies [[`2ff9010`](https://github.com/OpenCloning/OpenCloning_frontend/commit/2ff90103c1d9e8e984533e51ecf4dc7978756a57), [`2ff9010`](https://github.com/OpenCloning/OpenCloning_frontend/commit/2ff90103c1d9e8e984533e51ecf4dc7978756a57)]:
|
|
20
|
+
- @opencloning/ui@1.8.0
|
|
21
|
+
- @opencloning/utils@1.8.0
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"license": "MIT",
|
|
3
|
+
"name": "@opencloning/opencloningdb",
|
|
4
|
+
"version": "1.7.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/OpenCloning/OpenCloning_frontend.git",
|
|
13
|
+
"directory": "packages/opencloningdb"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@mui/icons-material": "^5.18.0",
|
|
17
|
+
"@mui/material": "^5.18.0",
|
|
18
|
+
"@opencloning/ui": "1.8.1",
|
|
19
|
+
"@opencloning/utils": "1.8.1",
|
|
20
|
+
"axios": "^1.16.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": "^18.3.1",
|
|
24
|
+
"react-dom": "^18.3.1",
|
|
25
|
+
"react-redux": "^8.1.3"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"registry": "https://registry.npmjs.org/"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import GetPrimerComponent from './GetPrimerComponent';
|
|
3
|
+
import { clickMultiSelectOption } from '../../../cypress/e2e/common_functions';
|
|
4
|
+
|
|
5
|
+
const PRIMER_NAME = 'lacZ_attB1_fwd';
|
|
6
|
+
|
|
7
|
+
describe('<GetPrimerComponent />', () => {
|
|
8
|
+
it('successfully selects a primer and calls setPrimer with correct data', () => {
|
|
9
|
+
const setPrimerSpy = cy.spy().as('setPrimerSpy');
|
|
10
|
+
const setErrorSpy = cy.spy().as('setErrorSpy');
|
|
11
|
+
cy.setupOpenCloningDBTestAuth();
|
|
12
|
+
|
|
13
|
+
cy.interceptOpenCloningDBStub('get_primers_search_by_name', { alias: 'getPrimers' });
|
|
14
|
+
cy.interceptOpenCloningDBStub('get_primer', { alias: 'getPrimer' });
|
|
15
|
+
|
|
16
|
+
cy.mount(<GetPrimerComponent setPrimer={setPrimerSpy} setError={setErrorSpy} />);
|
|
17
|
+
|
|
18
|
+
cy.get('input').type(PRIMER_NAME);
|
|
19
|
+
cy.get('.MuiAutocomplete-listbox li', { timeout: 10000 }).should('have.length.greaterThan', 0);
|
|
20
|
+
cy.wait('@getPrimers');
|
|
21
|
+
|
|
22
|
+
clickMultiSelectOption('Primer', PRIMER_NAME, 'div');
|
|
23
|
+
|
|
24
|
+
cy.getStub('get_primer').then((stub) => {
|
|
25
|
+
cy.wait('@getPrimer');
|
|
26
|
+
cy.get('@setPrimerSpy').should('have.been.calledWithMatch', {
|
|
27
|
+
name: stub.response.body.name,
|
|
28
|
+
sequence: stub.response.body.sequence,
|
|
29
|
+
'database_id': stub.response.body.id,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
cy.get('@setErrorSpy').should('have.been.calledWith', '');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('clears primer and error when selection is cleared', () => {
|
|
36
|
+
const setPrimerSpy = cy.spy().as('setPrimerSpy');
|
|
37
|
+
const setErrorSpy = cy.spy().as('setErrorSpy');
|
|
38
|
+
cy.setupOpenCloningDBTestAuth();
|
|
39
|
+
|
|
40
|
+
cy.interceptOpenCloningDBStub('get_primers_search_by_name', { alias: 'getPrimers' });
|
|
41
|
+
cy.interceptOpenCloningDBStub('get_primer', { alias: 'getPrimer' });
|
|
42
|
+
|
|
43
|
+
cy.mount(<GetPrimerComponent setPrimer={setPrimerSpy} setError={setErrorSpy} />);
|
|
44
|
+
|
|
45
|
+
cy.get('input').type(PRIMER_NAME);
|
|
46
|
+
cy.get('.MuiAutocomplete-listbox li', { timeout: 10000 }).should('have.length.greaterThan', 0);
|
|
47
|
+
cy.wait('@getPrimers');
|
|
48
|
+
clickMultiSelectOption('Primer', PRIMER_NAME, 'div');
|
|
49
|
+
cy.wait('@getPrimer');
|
|
50
|
+
cy.get('@setPrimerSpy').should('have.been.calledWithMatch', { name: PRIMER_NAME });
|
|
51
|
+
|
|
52
|
+
cy.get('.MuiAutocomplete-clearIndicator').click();
|
|
53
|
+
|
|
54
|
+
cy.get('@setPrimerSpy').should('have.been.calledWith', null);
|
|
55
|
+
cy.get('@setErrorSpy').should('have.been.calledWith', '');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React, { useCallback } from 'react';
|
|
2
|
+
import { useMutation } from '@tanstack/react-query';
|
|
3
|
+
import PrimerSelect from './PrimerSelect';
|
|
4
|
+
import { openCloningDBHttpClient } from './common';
|
|
5
|
+
import endpoints from './endpoints';
|
|
6
|
+
import { Box } from '@mui/material';
|
|
7
|
+
|
|
8
|
+
function GetPrimerComponent({ setPrimer, setError, existingDatabaseIds }) {
|
|
9
|
+
const { mutate, reset } = useMutation({
|
|
10
|
+
mutationFn: async (primerId) => {
|
|
11
|
+
const response = await openCloningDBHttpClient.get(endpoints.primer(primerId));
|
|
12
|
+
return response.data;
|
|
13
|
+
},
|
|
14
|
+
onSuccess: (primer) => {
|
|
15
|
+
/* database_id matches API primer model */
|
|
16
|
+
/* eslint-disable-next-line camelcase */
|
|
17
|
+
setPrimer({ name: primer.name, sequence: primer.sequence, database_id: primer.id });
|
|
18
|
+
setError('');
|
|
19
|
+
},
|
|
20
|
+
onError: (err) => {
|
|
21
|
+
setError(err?.response?.data?.detail || err?.message || 'Could not retrieve primer from OpenCloningDB');
|
|
22
|
+
setPrimer(null);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const handlePrimerSelect = useCallback(async (selectedPrimer) => {
|
|
27
|
+
if (selectedPrimer === null || selectedPrimer === '') {
|
|
28
|
+
reset();
|
|
29
|
+
setPrimer(null);
|
|
30
|
+
setError('');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
mutate(selectedPrimer.id);
|
|
34
|
+
}, [mutate, reset, setPrimer, setError]);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Box sx={{ minWidth: '400px' }}>
|
|
38
|
+
<PrimerSelect fullWidth setPrimer={handlePrimerSelect} filterDatabaseIds={existingDatabaseIds} />
|
|
39
|
+
</Box>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default GetPrimerComponent;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import GetSequenceFileAndDatabaseIdComponent from './GetSequenceFileAndDatabaseIdComponent';
|
|
3
|
+
import { clickMultiSelectOption } from '../../../cypress/e2e/common_functions';
|
|
4
|
+
import endpoints from './endpoints';
|
|
5
|
+
|
|
6
|
+
const SEQUENCE_NAME = 'ase1_CDS_PCR';
|
|
7
|
+
const textFilePattern = `**${endpoints.sequenceTextFile('*')}`;
|
|
8
|
+
|
|
9
|
+
function expectCloningStrategyFile(file, sequenceId, expectedSequenceFile) {
|
|
10
|
+
expect(file.name).to.equal('cloning_strategy.json');
|
|
11
|
+
return cy.readFileAsText(file).then((content) => {
|
|
12
|
+
const parsed = JSON.parse(content);
|
|
13
|
+
expect(parsed.sources).to.have.length(1);
|
|
14
|
+
expect(parsed.sources[0].database_id).to.equal(sequenceId);
|
|
15
|
+
expect(parsed.sources[0].type).to.equal('DatabaseSource');
|
|
16
|
+
expect(parsed.sequences).to.have.length(1);
|
|
17
|
+
expect(parsed.sequences[0]).to.include({
|
|
18
|
+
...expectedSequenceFile,
|
|
19
|
+
id: 1,
|
|
20
|
+
});
|
|
21
|
+
expect(parsed.primers).to.eql([]);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('<GetSequenceFileAndDatabaseIdComponent />', () => {
|
|
26
|
+
it('selects a sequence and calls setFile and setDatabaseId with correct data', () => {
|
|
27
|
+
const setFileSpy = cy.spy().as('setFileSpy');
|
|
28
|
+
const setDatabaseIdSpy = cy.spy().as('setDatabaseIdSpy');
|
|
29
|
+
cy.setupOpenCloningDBTestAuth();
|
|
30
|
+
|
|
31
|
+
cy.interceptOpenCloningDBStub('get_sequences_search_by_name', { alias: 'getSequences' });
|
|
32
|
+
cy.interceptOpenCloningDBStub('get_text_file_sequence', { alias: 'getSequenceFile' });
|
|
33
|
+
|
|
34
|
+
cy.mount(
|
|
35
|
+
<GetSequenceFileAndDatabaseIdComponent setFile={setFileSpy} setDatabaseId={setDatabaseIdSpy} />,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
cy.get('input').type(SEQUENCE_NAME);
|
|
39
|
+
cy.get('.MuiAutocomplete-listbox li', { timeout: 10000 }).should('have.length.greaterThan', 0);
|
|
40
|
+
cy.wait('@getSequences').its('request.url').should('include', `name=${SEQUENCE_NAME}`);
|
|
41
|
+
|
|
42
|
+
clickMultiSelectOption('Sequence', RegExp(`^${SEQUENCE_NAME}$`), 'div');
|
|
43
|
+
cy.getStub('get_text_file_sequence').then((stub) => {
|
|
44
|
+
cy.get('@setDatabaseIdSpy').should('have.been.calledWith', stub.response.body.id);
|
|
45
|
+
cy.get('@setFileSpy').should('have.been.calledOnce');
|
|
46
|
+
return cy.get('@setFileSpy').then((spy) => {
|
|
47
|
+
const [file] = spy.lastCall.args;
|
|
48
|
+
return expectCloningStrategyFile(file, stub.response.body.id, stub.response.body.sequence);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('shows a retry button when requesting the selected file fails and retries successfully', () => {
|
|
55
|
+
const setFileSpy = cy.spy().as('setFileSpy');
|
|
56
|
+
const setDatabaseIdSpy = cy.spy().as('setDatabaseIdSpy');
|
|
57
|
+
|
|
58
|
+
cy.setupOpenCloningDBTestAuth();
|
|
59
|
+
cy.interceptOpenCloningDBStub('get_sequences_search_by_name', { alias: 'getSequences' });
|
|
60
|
+
|
|
61
|
+
cy.getStub('get_text_file_sequence').then((textFileSequenceStub) => {
|
|
62
|
+
let callCount = 0;
|
|
63
|
+
cy.intercept('GET', textFilePattern, (req) => {
|
|
64
|
+
callCount += 1;
|
|
65
|
+
if (callCount === 1) {
|
|
66
|
+
req.reply({ statusCode: 500 });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
req.reply({
|
|
71
|
+
statusCode: textFileSequenceStub.response.status_code,
|
|
72
|
+
body: textFileSequenceStub.response.body,
|
|
73
|
+
headers: textFileSequenceStub.response.headers,
|
|
74
|
+
});
|
|
75
|
+
}).as('getSequenceFile');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
cy.mount(
|
|
79
|
+
<GetSequenceFileAndDatabaseIdComponent setFile={setFileSpy} setDatabaseId={setDatabaseIdSpy} />,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
cy.get('input').type(SEQUENCE_NAME);
|
|
83
|
+
cy.get('.MuiAutocomplete-listbox li', { timeout: 10000 }).should('have.length.greaterThan', 0);
|
|
84
|
+
cy.wait('@getSequences').its('request.url').should('include', `name=${SEQUENCE_NAME}`);
|
|
85
|
+
|
|
86
|
+
clickMultiSelectOption('Sequence', RegExp(`^${SEQUENCE_NAME}$`), 'div');
|
|
87
|
+
|
|
88
|
+
cy.wait('@getSequenceFile');
|
|
89
|
+
cy.contains('Failed to load sequence file.').should('exist');
|
|
90
|
+
cy.get('@setFileSpy').should('not.have.been.called');
|
|
91
|
+
cy.get('@setDatabaseIdSpy').should('not.have.been.called');
|
|
92
|
+
|
|
93
|
+
cy.contains('button', 'Retry').click();
|
|
94
|
+
|
|
95
|
+
cy.wait('@getSequenceFile');
|
|
96
|
+
cy.contains('Failed to load sequence file.').should('not.exist');
|
|
97
|
+
cy.get('@setFileSpy').should('have.been.calledOnce');
|
|
98
|
+
cy.get('@setDatabaseIdSpy').should('have.been.calledOnce');
|
|
99
|
+
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useMutation } from '@tanstack/react-query';
|
|
3
|
+
import SequenceSelect from './SequenceSelect';
|
|
4
|
+
import { openCloningDBHttpClient } from './common';
|
|
5
|
+
import endpoints from './endpoints';
|
|
6
|
+
import { CircularProgress, FormControl } from '@mui/material';
|
|
7
|
+
import RetryAlert from '@opencloning/ui/components/form/RetryAlert';
|
|
8
|
+
|
|
9
|
+
function GetSequenceFileAndDatabaseIdComponent({ setFile, setDatabaseId }) {
|
|
10
|
+
const lastSelectedSequenceRef = React.useRef(null);
|
|
11
|
+
|
|
12
|
+
const { mutate, isPending, error, reset } = useMutation({
|
|
13
|
+
mutationFn: async (selectedSequence) => {
|
|
14
|
+
const selectedId = selectedSequence.id;
|
|
15
|
+
const response = await openCloningDBHttpClient.get(endpoints.sequenceTextFile(selectedId));
|
|
16
|
+
const sequence = { ...response.data, id: 1 };
|
|
17
|
+
const source = { id: 1, input: [], database_id: selectedId, type: 'DatabaseSource' };
|
|
18
|
+
const cloningStrategy = { sources: [source], sequences: [sequence], primers: [] };
|
|
19
|
+
const fileContent = JSON.stringify(cloningStrategy);
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
databaseId: selectedId,
|
|
23
|
+
file: new File([fileContent], 'cloning_strategy.json', { type: 'application/json' }),
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
onSuccess: ({ file, databaseId }) => {
|
|
27
|
+
setFile(file);
|
|
28
|
+
setDatabaseId(databaseId);
|
|
29
|
+
},
|
|
30
|
+
onError: (requestError) => {
|
|
31
|
+
console.error('Error fetching cloning strategy:', requestError);
|
|
32
|
+
},
|
|
33
|
+
retry: false,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const onSequenceSelect = React.useCallback((selectedSequence) => {
|
|
37
|
+
if (!selectedSequence) {
|
|
38
|
+
lastSelectedSequenceRef.current = null;
|
|
39
|
+
reset();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
lastSelectedSequenceRef.current = selectedSequence;
|
|
44
|
+
mutate(selectedSequence);
|
|
45
|
+
}, [mutate, reset]);
|
|
46
|
+
|
|
47
|
+
const retryLoad = React.useCallback(() => {
|
|
48
|
+
if (lastSelectedSequenceRef.current) {
|
|
49
|
+
mutate(lastSelectedSequenceRef.current);
|
|
50
|
+
}
|
|
51
|
+
}, [mutate]);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<FormControl fullWidth>
|
|
55
|
+
<SequenceSelect multiple={false} label="Sequence" onChange={onSequenceSelect} />
|
|
56
|
+
{isPending && <CircularProgress />}
|
|
57
|
+
{error && <RetryAlert onRetry={retryLoad}>Failed to load sequence file.</RetryAlert>}
|
|
58
|
+
</FormControl>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default GetSequenceFileAndDatabaseIdComponent;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import LoadHistoryComponent from './LoadHistoryComponent';
|
|
3
|
+
|
|
4
|
+
const DATABASE_ID = 10;
|
|
5
|
+
|
|
6
|
+
describe('<LoadHistoryComponent />', () => {
|
|
7
|
+
it('calls loadDatabaseFile with the file returned by the API', () => {
|
|
8
|
+
const loadDatabaseFileSpy = cy.spy().as('loadDatabaseFileSpy');
|
|
9
|
+
cy.setupOpenCloningDBTestAuth();
|
|
10
|
+
|
|
11
|
+
cy.interceptOpenCloningDBStub('get_cloning_strategy', { alias: 'getCloningStrategy' });
|
|
12
|
+
|
|
13
|
+
cy.mount(
|
|
14
|
+
<LoadHistoryComponent
|
|
15
|
+
databaseId={DATABASE_ID}
|
|
16
|
+
loadDatabaseFile={loadDatabaseFileSpy}
|
|
17
|
+
/>,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
cy.getStub('get_cloning_strategy').then((cloningStrategyStub) => {
|
|
21
|
+
const expectedContent = JSON.stringify(cloningStrategyStub.response.body);
|
|
22
|
+
|
|
23
|
+
cy.wait('@getCloningStrategy').its('request.url').should('include', cloningStrategyStub.endpoint);
|
|
24
|
+
cy.get('@loadDatabaseFileSpy').should('have.been.calledOnce');
|
|
25
|
+
|
|
26
|
+
cy.get('@loadDatabaseFileSpy').then((spy) => {
|
|
27
|
+
const [file, id, flag] = spy.lastCall.args;
|
|
28
|
+
expect(id).to.equal(DATABASE_ID);
|
|
29
|
+
expect(flag).to.equal(true);
|
|
30
|
+
|
|
31
|
+
return cy.readFileAsText(file).then((content) => {
|
|
32
|
+
expect(content).to.equal(expectedContent);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('shows a loading spinner while the request is pending', () => {
|
|
39
|
+
cy.setupOpenCloningDBTestAuth();
|
|
40
|
+
|
|
41
|
+
cy.getStub('get_cloning_strategy').then((cloningStrategyStub) => {
|
|
42
|
+
cy.intercept({ method: cloningStrategyStub.method, pathname: cloningStrategyStub.endpoint }, (req) => {
|
|
43
|
+
req.reply({
|
|
44
|
+
delay: 1000,
|
|
45
|
+
statusCode: cloningStrategyStub.response.status_code,
|
|
46
|
+
body: cloningStrategyStub.response.body,
|
|
47
|
+
headers: cloningStrategyStub.response.headers,
|
|
48
|
+
});
|
|
49
|
+
}).as('getCloningStrategy');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
cy.mount(
|
|
53
|
+
<LoadHistoryComponent
|
|
54
|
+
databaseId={DATABASE_ID}
|
|
55
|
+
loadDatabaseFile={cy.stub()}
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
cy.get('.MuiCircularProgress-svg', { timeout: 500 }).should('exist');
|
|
60
|
+
cy.wait('@getCloningStrategy');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('shows a retry button when the request fails, and retries on click', () => {
|
|
64
|
+
cy.setupOpenCloningDBTestAuth();
|
|
65
|
+
|
|
66
|
+
cy.getStub('get_cloning_strategy').then((cloningStrategyStub) => {
|
|
67
|
+
let callCount = 0;
|
|
68
|
+
cy.intercept({ method: cloningStrategyStub.method, pathname: cloningStrategyStub.endpoint }, (req) => {
|
|
69
|
+
callCount += 1;
|
|
70
|
+
if (callCount === 1) {
|
|
71
|
+
req.reply({ statusCode: 500 });
|
|
72
|
+
} else {
|
|
73
|
+
req.reply({
|
|
74
|
+
statusCode: cloningStrategyStub.response.status_code,
|
|
75
|
+
body: cloningStrategyStub.response.body,
|
|
76
|
+
headers: cloningStrategyStub.response.headers,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}).as('getCloningStrategy');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
cy.mount(
|
|
83
|
+
<LoadHistoryComponent
|
|
84
|
+
databaseId={DATABASE_ID}
|
|
85
|
+
loadDatabaseFile={cy.stub()}
|
|
86
|
+
/>,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
cy.wait('@getCloningStrategy');
|
|
90
|
+
cy.contains('Failed to load history file.').should('exist');
|
|
91
|
+
cy.contains('button', 'Retry').should('exist').click();
|
|
92
|
+
cy.wait('@getCloningStrategy');
|
|
93
|
+
cy.contains('Failed to load history file.').should('not.exist');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { CircularProgress } from '@mui/material';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { useQuery } from '@tanstack/react-query';
|
|
4
|
+
import { openCloningDBHttpClient } from './common';
|
|
5
|
+
import RetryAlert from '@opencloning/ui/components/form/RetryAlert';
|
|
6
|
+
import endpoints from './endpoints';
|
|
7
|
+
|
|
8
|
+
function LoadHistoryComponent({ databaseId, loadDatabaseFile }) {
|
|
9
|
+
const url = endpoints.sequenceCloningStrategy(databaseId);
|
|
10
|
+
|
|
11
|
+
const { isLoading, error, data, refetch } = useQuery({
|
|
12
|
+
queryKey: ['cloningStrategy', databaseId],
|
|
13
|
+
queryFn: async () => {
|
|
14
|
+
const response = await openCloningDBHttpClient.get(url);
|
|
15
|
+
return response.data;
|
|
16
|
+
},
|
|
17
|
+
retry: false,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
React.useEffect(() => {
|
|
21
|
+
if (data) {
|
|
22
|
+
const file = new File([JSON.stringify(data)], 'cloning_strategy.json', { type: 'application/json' });
|
|
23
|
+
loadDatabaseFile(file, databaseId, true);
|
|
24
|
+
}
|
|
25
|
+
}, [data, databaseId, loadDatabaseFile]);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div>
|
|
29
|
+
{isLoading && <CircularProgress />}
|
|
30
|
+
{error && <RetryAlert onRetry={refetch}>Failed to load history file.</RetryAlert>}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default LoadHistoryComponent;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Save as SaveIcon, Link as LinkIcon } from '@mui/icons-material';
|
|
2
|
+
import GetPrimerComponent from './GetPrimerComponent';
|
|
3
|
+
import GetSequenceFileAndDatabaseIdComponent from './GetSequenceFileAndDatabaseIdComponent';
|
|
4
|
+
import { openCloningDBHttpClient } from './common';
|
|
5
|
+
import endpoints from './endpoints';
|
|
6
|
+
import LoadHistoryComponent from './LoadHistoryComponent';
|
|
7
|
+
import SubmitToDatabaseComponent from './SubmitToDatabaseComponent';
|
|
8
|
+
import PrimersNotInDatabaseComponent from './PrimersNotInDatabaseComponent';
|
|
9
|
+
|
|
10
|
+
function isSubmissionDataValid(submissionData) {
|
|
11
|
+
return Boolean(submissionData.title);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function submitSequenceToDatabase({ submissionData, substate, id }) {
|
|
15
|
+
// const substateCopy = cloneDeep(substate);
|
|
16
|
+
// const selectedSequence = substateCopy.sequences.find((s) => s.id === id);
|
|
17
|
+
// selectedSequence.name = submissionData.title;
|
|
18
|
+
// substateCopy.description = '';
|
|
19
|
+
// console.log(substateCopy);
|
|
20
|
+
const { sources, primers, sequences } = substate;
|
|
21
|
+
const { data } = await openCloningDBHttpClient.post(endpoints.postSequence, { sources, primers, sequences });
|
|
22
|
+
|
|
23
|
+
const primerIds = new Set(primers.map((p) => p.id));
|
|
24
|
+
const sequenceIds = new Set(sequences.map((s) => s.id));
|
|
25
|
+
|
|
26
|
+
const primerMappings = data.mappings.filter(({localId}) => primerIds.has(localId));
|
|
27
|
+
const sequenceMappings = data.mappings.filter(({localId}) => sequenceIds.has(localId));
|
|
28
|
+
|
|
29
|
+
return { databaseId: data.id, primerMappings, sequenceMappings };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function getPrimer(databaseId) {
|
|
33
|
+
const response = await openCloningDBHttpClient.get(endpoints.primer(databaseId));
|
|
34
|
+
return { name: response.data.name, database_id: databaseId, sequence: response.data.sequence };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function submitPrimerToDatabase({ submissionData, primer }) {
|
|
38
|
+
const payload = {
|
|
39
|
+
name: submissionData.title,
|
|
40
|
+
sequence: primer.sequence,
|
|
41
|
+
};
|
|
42
|
+
const response = await openCloningDBHttpClient.post(endpoints.postPrimer, payload);
|
|
43
|
+
return response.data.id;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function submitSequencingFileToDatabase({ databaseId, sequencingFiles }) {
|
|
47
|
+
const formData = new FormData();
|
|
48
|
+
sequencingFiles.forEach((file) => {
|
|
49
|
+
if (file) {
|
|
50
|
+
formData.append('files', file);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const resp = await openCloningDBHttpClient.post(
|
|
56
|
+
endpoints.sequenceSequencingFiles(databaseId),
|
|
57
|
+
formData,
|
|
58
|
+
{
|
|
59
|
+
headers: {
|
|
60
|
+
'Content-Type': 'multipart/form-data',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
return resp.data;
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.error(e);
|
|
67
|
+
throw new Error(e.response?.data?.detail || e.message || 'Error submitting sequencing files');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function getSequencingFiles(databaseId) {
|
|
72
|
+
// Returns array of { name, getFile } where getFile is async and returns the file content
|
|
73
|
+
try {
|
|
74
|
+
const resp = await openCloningDBHttpClient.get(endpoints.sequenceSequencingFiles(databaseId));
|
|
75
|
+
const files = resp.data || [];
|
|
76
|
+
return files.map((fileInfo) => ({
|
|
77
|
+
id: fileInfo.id,
|
|
78
|
+
name: fileInfo.original_name,
|
|
79
|
+
getFile: async () => {
|
|
80
|
+
const downloadResp = await openCloningDBHttpClient.get(endpoints.sequencingFileDownload(fileInfo.id), {
|
|
81
|
+
responseType: 'blob',
|
|
82
|
+
});
|
|
83
|
+
return new File([downloadResp.data], fileInfo.original_name);
|
|
84
|
+
},
|
|
85
|
+
}));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.error(e);
|
|
88
|
+
throw new Error(e.response?.data?.detail || e.message || 'Error getting sequencing files');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function locateSequenceInDatabase(sequence) {
|
|
93
|
+
const response = await openCloningDBHttpClient.post(endpoints.sequenceSearch, sequence);
|
|
94
|
+
return response.data;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default {
|
|
98
|
+
// Name of the database interface
|
|
99
|
+
name: 'OpenCloningDB',
|
|
100
|
+
// Returns a link to the sequence in the database
|
|
101
|
+
getSequenceLink: (databaseId) => `sequences/${databaseId}`,
|
|
102
|
+
// Returns a link to the primer in the database
|
|
103
|
+
getPrimerLink: (databaseId) => `primers/${databaseId}`,
|
|
104
|
+
// Component for selecting and loading sequence files from the database
|
|
105
|
+
GetSequenceFileAndDatabaseIdComponent,
|
|
106
|
+
// Component for selecting and loading primers from the database
|
|
107
|
+
GetPrimerComponent,
|
|
108
|
+
// Component for submitting resources to the database
|
|
109
|
+
SubmitToDatabaseComponent,
|
|
110
|
+
// Component for handling primers not yet in database
|
|
111
|
+
PrimersNotInDatabaseComponent,
|
|
112
|
+
// Function to submit a primer to the database
|
|
113
|
+
submitPrimerToDatabase,
|
|
114
|
+
// Function to submit a sequence and its history to the database
|
|
115
|
+
submitSequenceToDatabase,
|
|
116
|
+
// Function to validate submission data
|
|
117
|
+
isSubmissionDataValid,
|
|
118
|
+
// Icon displayed on the node corner to submit
|
|
119
|
+
SubmitIcon: SaveIcon,
|
|
120
|
+
// Icon displayed on the node corner for entities in the database
|
|
121
|
+
DatabaseIcon: LinkIcon,
|
|
122
|
+
// OPTIONAL =======================================================================
|
|
123
|
+
// Component for loading history from the database (can be hook-like does not have to render anything)
|
|
124
|
+
LoadHistoryComponent,
|
|
125
|
+
// Function to load sequences from url parameters
|
|
126
|
+
loadSequenceFromUrlParams: () => {},
|
|
127
|
+
// Function to get the primer ({name, database_id, sequence}) from the database
|
|
128
|
+
getPrimer,
|
|
129
|
+
// Function to get the name of a sequence from the database
|
|
130
|
+
getSequenceName: () => {},
|
|
131
|
+
// Function to get the sequencing files from the database, see docs for what the return value should be
|
|
132
|
+
getSequencingFiles,
|
|
133
|
+
// Autoload sequencing files (Boolean)
|
|
134
|
+
autoloadSequencingFiles: true,
|
|
135
|
+
// Omit unsaved intermediates disclaimer (Boolean)
|
|
136
|
+
omitUnsavedIntermediatesDisclaimer: true,
|
|
137
|
+
// Function to locate a sequence in the database
|
|
138
|
+
locateSequenceInDatabase,
|
|
139
|
+
// Function to submit sequencing files to the database
|
|
140
|
+
submitSequencingFileToDatabase,
|
|
141
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getStub } from './testUtils.test';
|
|
3
|
+
import endpoints from './endpoints';
|
|
4
|
+
|
|
5
|
+
const multipartStub = getStub('post_sequence_sequencing_files');
|
|
6
|
+
const sequenceFileStub = multipartStub.body.multipart_files[0];
|
|
7
|
+
const databaseId = Number(multipartStub.endpoint.match(/\/sequences\/(\d+)\//)?.[1]);
|
|
8
|
+
const postMock = vi.fn().mockResolvedValue({
|
|
9
|
+
data: 'direct-response',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
vi.mock('./common', async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal();
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
openCloningDBHttpClient: {
|
|
17
|
+
...actual.openCloningDBHttpClient,
|
|
18
|
+
post: (...args) => postMock(...args),
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
vi.mock('./GetPrimerComponent', () => ({ default: () => null }));
|
|
24
|
+
vi.mock('./GetSequenceFileAndDatabaseIdComponent', () => ({ default: () => null }));
|
|
25
|
+
vi.mock('./LoadHistoryComponent', () => ({ default: () => null }));
|
|
26
|
+
vi.mock('./SubmitToDatabaseComponent', () => ({ default: () => null }));
|
|
27
|
+
vi.mock('./PrimersNotInDatabaseComponent', () => ({ default: () => null }));
|
|
28
|
+
|
|
29
|
+
import OpenCloningDBInterface from './OpenCloningDBInterface';
|
|
30
|
+
|
|
31
|
+
function readFileAsText(file) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const reader = new FileReader();
|
|
34
|
+
reader.onerror = () => reject(reader.error);
|
|
35
|
+
reader.onload = () => resolve(reader.result);
|
|
36
|
+
reader.readAsText(file);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('submitSequencingFileToDatabase multipart payload', () => {
|
|
41
|
+
it('submits FormData with files field and forwards response', async () => {
|
|
42
|
+
postMock.mockClear();
|
|
43
|
+
const sequenceFile = new File([sequenceFileStub.content], sequenceFileStub.filename, {
|
|
44
|
+
type: sequenceFileStub.content_type,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const result = await OpenCloningDBInterface.submitSequencingFileToDatabase({
|
|
48
|
+
databaseId,
|
|
49
|
+
sequencingFiles: [sequenceFile],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const [url, formData] = postMock.mock.calls[0];
|
|
53
|
+
expect(url).toBe(endpoints.sequenceSequencingFiles(databaseId));
|
|
54
|
+
expect(formData).toBeInstanceOf(FormData);
|
|
55
|
+
|
|
56
|
+
const files = formData.getAll('files');
|
|
57
|
+
expect(files).toHaveLength(1);
|
|
58
|
+
expect(files[0]).toBeInstanceOf(File);
|
|
59
|
+
expect(files[0].name).toBe(sequenceFileStub.filename);
|
|
60
|
+
expect(files[0].type).toBe(sequenceFileStub.content_type);
|
|
61
|
+
expect(await readFileAsText(files[0])).toBe(sequenceFileStub.content);
|
|
62
|
+
|
|
63
|
+
expect(result).toEqual('direct-response');
|
|
64
|
+
});
|
|
65
|
+
});
|