@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.
@@ -0,0 +1,156 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
2
+ import { setupServer } from 'msw/node';
3
+
4
+ import { addStubToServer, getStub, setupToken, clearToken } from './testUtils.test';
5
+ import { setWorkspaceHeader } from './common';
6
+ import { file2base64 } from '@opencloning/utils/readNwrite';
7
+
8
+ vi.mock('./GetPrimerComponent', () => ({ default: () => null }));
9
+ vi.mock('./GetSequenceFileAndDatabaseIdComponent', () => ({ default: () => null }));
10
+ vi.mock('./LoadHistoryComponent', () => ({ default: () => null }));
11
+ vi.mock('./SubmitToDatabaseComponent', () => ({ default: () => null }));
12
+ vi.mock('./PrimersNotInDatabaseComponent', () => ({ default: () => null }));
13
+
14
+ import OpenCloningDBInterface from './OpenCloningDBInterface';
15
+
16
+ const server = setupServer();
17
+
18
+ beforeAll(() => {
19
+ setupToken();
20
+ setWorkspaceHeader(1);
21
+ server.listen({ onUnhandledRequest: 'error' });
22
+ });
23
+ afterEach(() => server.resetHandlers());
24
+ afterAll(() => {
25
+ clearToken();
26
+ server.close();
27
+ });
28
+
29
+ describe('submitPrimerToDatabase', () => {
30
+ it('posts the primer and returns the new id', async () => {
31
+ const stub = getStub('post_primer');
32
+ addStubToServer(server, stub);
33
+
34
+ const id = await OpenCloningDBInterface.submitPrimerToDatabase({
35
+ submissionData: { title: stub.body.name },
36
+ primer: { sequence: stub.body.sequence },
37
+ });
38
+
39
+ expect(id).toBe(stub.response.body.id);
40
+ });
41
+ });
42
+
43
+ describe('getPrimer', () => {
44
+ it('returns primer in interface format', async () => {
45
+ const stub = getStub('get_primer');
46
+ addStubToServer(server, stub);
47
+
48
+ const primer = await OpenCloningDBInterface.getPrimer(stub.response.body.id);
49
+
50
+ expect(primer).toEqual({
51
+ name: stub.response.body.name,
52
+ 'database_id': stub.response.body.id,
53
+ sequence: stub.response.body.sequence,
54
+ });
55
+ });
56
+ });
57
+
58
+ describe('submitSequenceToDatabase', () => {
59
+ it('submits /sequence payload and returns databaseId + filtered mappings', async () => {
60
+ const stub = getStub('post_sequence');
61
+ addStubToServer(server, {
62
+ ...stub,
63
+ body: {
64
+ sources: stub.body.sources,
65
+ primers: stub.body.primers,
66
+ sequences: stub.body.sequences,
67
+ },
68
+ });
69
+
70
+ const primerIds = new Set(stub.body.primers.map((primer) => primer.id));
71
+ const sequenceIds = new Set(stub.body.sequences.map((sequence) => sequence.id));
72
+ const expectedPrimerMappings = stub.response.body.mappings.filter(({ localId }) => primerIds.has(localId));
73
+ const expectedSequenceMappings = stub.response.body.mappings.filter(({ localId }) =>
74
+ sequenceIds.has(localId),
75
+ );
76
+
77
+ const result = await OpenCloningDBInterface.submitSequenceToDatabase({
78
+ submissionData: { title: 'name' },
79
+ substate: stub.body,
80
+ id: stub.body.sequences[0].id,
81
+ });
82
+
83
+ expect(result).toEqual({
84
+ databaseId: stub.response.body.id,
85
+ primerMappings: expectedPrimerMappings,
86
+ sequenceMappings: expectedSequenceMappings,
87
+ });
88
+ });
89
+ });
90
+
91
+ describe('locateSequenceInDatabase', () => {
92
+ it('posts to /sequence/search and returns response data', async () => {
93
+ const stub = getStub('post_sequence_search');
94
+ addStubToServer(server, stub);
95
+
96
+ const result = await OpenCloningDBInterface.locateSequenceInDatabase(stub.body);
97
+
98
+ expect(result).toEqual(stub.response.body);
99
+ });
100
+ });
101
+
102
+ describe('getSequencingFiles', () => {
103
+ it('gets sequencing files list and supports getFile download', async () => {
104
+ const listStub = getStub('get_sequence_sequencing_files');
105
+ const downloadStub = getStub('download_sequencing_file');
106
+ addStubToServer(server, listStub);
107
+ addStubToServer(server, downloadStub);
108
+
109
+ const files = await OpenCloningDBInterface.getSequencingFiles(10);
110
+
111
+ expect(files).toHaveLength(1);
112
+ expect(files[0].id).toBe(listStub.response.body[0].id);
113
+ expect(files[0].name).toBe(listStub.response.body[0].original_name);
114
+
115
+ const downloadedFile = await files[0].getFile();
116
+ expect(downloadedFile).toBeInstanceOf(File);
117
+ expect(downloadedFile.name).toBe(listStub.response.body[0].original_name);
118
+ const downloadedText = await file2base64(downloadedFile);
119
+ expect(downloadedText).toBe(downloadStub.response.body);
120
+ });
121
+ });
122
+
123
+ describe('isSubmissionDataValid', () => {
124
+ it('returns true when title is provided', () => {
125
+ expect(OpenCloningDBInterface.isSubmissionDataValid({ title: 'name' })).toBe(true);
126
+ });
127
+
128
+ it('returns false when title is missing or empty', () => {
129
+ expect(OpenCloningDBInterface.isSubmissionDataValid({})).toBe(false);
130
+ expect(OpenCloningDBInterface.isSubmissionDataValid({ title: '' })).toBe(false);
131
+ });
132
+ });
133
+
134
+ describe('getSequenceLink', () => {
135
+ it('returns sequence link path', () => {
136
+ expect(OpenCloningDBInterface.getSequenceLink(42)).toBe('sequences/42');
137
+ });
138
+ });
139
+
140
+ describe('getPrimerLink', () => {
141
+ it('returns primer link path', () => {
142
+ expect(OpenCloningDBInterface.getPrimerLink(7)).toBe('primers/7');
143
+ });
144
+ });
145
+
146
+ describe('loadSequenceFromUrlParams', () => {
147
+ it('is currently a no-op', () => {
148
+ expect(OpenCloningDBInterface.loadSequenceFromUrlParams()).toBeUndefined();
149
+ });
150
+ });
151
+
152
+ describe('getSequenceName', () => {
153
+ it('is currently a no-op', () => {
154
+ expect(OpenCloningDBInterface.getSequenceName()).toBeUndefined();
155
+ });
156
+ });
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import PrimerSelect from './PrimerSelect';
3
+ import { DatabaseProvider } from '@opencloning/ui/providers/DatabaseContext';
4
+ import OpenCloningDBInterface from './OpenCloningDBInterface';
5
+ import { clickMultiSelectOption } from '../../../cypress/e2e/common_functions';
6
+ import endpoints from './endpoints';
7
+
8
+ const PRIMER_NAME = 'lacZ_attB1_fwd';
9
+
10
+ describe('<PrimerSelect />', () => {
11
+ it('searches for a primer and shows results', () => {
12
+ cy.setupOpenCloningDBTestAuth();
13
+ const primerSpy = cy.spy().as('primerSpy');
14
+
15
+ cy.interceptOpenCloningDBStub('get_primers_search_by_name', {
16
+ alias: 'getPrimers',
17
+ })
18
+
19
+ cy.mount(
20
+ <DatabaseProvider value={OpenCloningDBInterface}>
21
+ <PrimerSelect setPrimer={primerSpy} />
22
+ </DatabaseProvider>
23
+ );
24
+ // Clicking on the input
25
+ cy.get('input').click();
26
+ cy.contains('Type at least').should('exist');
27
+ cy.get('input').type(PRIMER_NAME);
28
+ cy.get('.MuiAutocomplete-listbox li', { timeout: 10000 }).should('have.length.greaterThan', 0);
29
+
30
+ clickMultiSelectOption('Primer', PRIMER_NAME, 'div');
31
+
32
+ cy.getStub('get_primers_search_by_name').then((stub) => {
33
+ const stubPrimer = stub.response.body.items.find((primer) => primer.name === PRIMER_NAME);
34
+ cy.get('input').should('have.value', PRIMER_NAME);
35
+ cy.get('@primerSpy').should('have.been.calledWith', stubPrimer);
36
+ });
37
+ });
38
+
39
+ it('shows an error message when the request fails', () => {
40
+ cy.intercept('GET', Cypress.getDbURL(endpoints.primers, '*'), { statusCode: 500 }).as('getPrimers');
41
+ cy.mount(
42
+ <DatabaseProvider value={OpenCloningDBInterface}>
43
+ <PrimerSelect setPrimer={cy.stub()} />
44
+ </DatabaseProvider>
45
+ );
46
+ cy.get('input').type('ase1');
47
+ cy.wait('@getPrimers');
48
+ cy.contains('Could not retrieve primers from OpenCloningDB').should('exist');
49
+ cy.contains('button', 'Retry').should('exist');
50
+ });
51
+ });
@@ -0,0 +1,40 @@
1
+ import React from 'react';
2
+ import { QuerySelect, useDebouncedSearchQuery } from '@opencloning/ui';
3
+ import { openCloningDBHttpClient } from './common';
4
+ import endpoints from './endpoints';
5
+
6
+ const messages = { loadingMessage: 'retrieving primers', errorMessage: 'Could not retrieve primers from OpenCloningDB' };
7
+
8
+ const getGetQuery = (filterDatabaseIds) => {
9
+ return (name) => ({
10
+ queryKey: ['primers-search', name],
11
+ queryFn: async () => {
12
+ const { data } = await openCloningDBHttpClient.get(endpoints.primers, {
13
+ params: { name },
14
+ });
15
+ return data.items.filter((primer) => !filterDatabaseIds.includes(primer.id));
16
+ },
17
+ });
18
+ };
19
+
20
+ function PrimerSelect({ setPrimer, filterDatabaseIds = [], ...rest }) {
21
+ const { query, autocompleteProps, clearInput } = useDebouncedSearchQuery(getGetQuery(filterDatabaseIds));
22
+
23
+ return (
24
+ <QuerySelect
25
+ query={query}
26
+ label="Primer"
27
+ loadingMessage={messages.loadingMessage}
28
+ errorMessage={messages.errorMessage}
29
+ onChange={setPrimer}
30
+ getOptionLabel={(option) => (option === '' ? '' : option.name)}
31
+ getOptionKey={(option) => option.id}
32
+ multiple={false}
33
+ autocompleteProps={autocompleteProps}
34
+ onClear={clearInput}
35
+ {...rest}
36
+ />
37
+ );
38
+ }
39
+
40
+ export default PrimerSelect;
@@ -0,0 +1,57 @@
1
+ import React from 'react';
2
+ import { Provider } from 'react-redux';
3
+ import { configureStore } from '@reduxjs/toolkit';
4
+ import PrimersNotInDatabaseComponent from './PrimersNotInDatabaseComponent';
5
+ import { mockSequences, mockSources, mockPrimers } from '../../../tests/mockNetworkData';
6
+
7
+ const defaultState = {
8
+ sequences: mockSequences,
9
+ sources: mockSources,
10
+ primers: mockPrimers,
11
+ };
12
+
13
+ const createTestStore = (cloningState) => configureStore({
14
+ reducer: {
15
+ cloning: (state = cloningState) => state,
16
+ },
17
+ preloadedState: { cloning: cloningState },
18
+ });
19
+
20
+ describe('<PrimersNotInDatabaseComponent />', () => {
21
+ it('renders nothing when no primers are involved', () => {
22
+ // id=4: source 4 has no inputs, so no primers in substate
23
+ const store = createTestStore(defaultState);
24
+ cy.mount(
25
+ <Provider store={store}>
26
+ <PrimersNotInDatabaseComponent
27
+ id={4}
28
+ submissionData={{}}
29
+ setSubmissionData={cy.spy().as('setSubmissionDataSpy')}
30
+ />
31
+ </Provider>,
32
+ );
33
+
34
+ cy.get('.MuiAlert-root').should('not.exist');
35
+ });
36
+
37
+ it('shows primers that will be saved to the database', () => {
38
+ // id=1: substate includes source 2 (OligoHybridization with primers 7 and 8).
39
+ // Primer 7 (Primer1) has no database_id → shown.
40
+ // Primer 8 (Primer2) already has database_id → hidden.
41
+ // Source 3 has database_id so getSubState stops there (stopAtDatabaseId=true).
42
+ const store = createTestStore(defaultState);
43
+ cy.mount(
44
+ <Provider store={store}>
45
+ <PrimersNotInDatabaseComponent
46
+ id={1}
47
+ submissionData={{}}
48
+ setSubmissionData={cy.spy().as('setSubmissionDataSpy')}
49
+ />
50
+ </Provider>,
51
+ );
52
+
53
+ cy.get('.MuiAlert-root').contains('The below primers will be saved to the database').should('exist');
54
+ cy.get('.MuiAlert-root li').should('have.length', 1);
55
+ cy.get('.MuiAlert-root li').contains('Primer1').should('exist');
56
+ });
57
+ });
@@ -0,0 +1,40 @@
1
+ import React from 'react';
2
+ import { useSelector } from 'react-redux';
3
+ import { Alert, Box, Typography } from '@mui/material';
4
+ import { getSubState } from '@opencloning/utils/network';
5
+
6
+ function PrimersNotInDatabaseComponent({ id, submissionData, setSubmissionData }) {
7
+ const primers = useSelector((state) => {
8
+ const subState = getSubState(state, id, true);
9
+ return subState.primers.filter((p) => !p.database_id);
10
+ });
11
+
12
+ if (primers.length === 0) return null;
13
+
14
+ return (
15
+ <Alert
16
+ severity="info"
17
+ sx={{
18
+ marginTop: 2,
19
+ paddingY: 1,
20
+ width: '100%',
21
+ '& .MuiAlert-message': {
22
+ width: '100%',
23
+ },
24
+ }}
25
+ icon={false}
26
+ >
27
+ <Box>
28
+ <Typography>The below primers will be saved to the database, consider changing their name in the primer tab</Typography>
29
+ <ul>
30
+ {primers.map((primer) => (
31
+ <li key={primer.id}>{primer.name}</li>
32
+ ))}
33
+ </ul>
34
+ </Box>
35
+
36
+ </Alert>
37
+ );
38
+ }
39
+
40
+ export default PrimersNotInDatabaseComponent;
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import SequenceSelect from './SequenceSelect';
3
+ import { clickMultiSelectOption } from '../../../cypress/e2e/common_functions';
4
+
5
+ const SEQUENCE_NAME = 'ase1_CDS_PCR';
6
+
7
+ describe('<SequenceSelect />', () => {
8
+ it('searches for a sequence and shows results', () => {
9
+ cy.setupOpenCloningDBTestAuth();
10
+ const onChangeSpy = cy.spy().as('onChangeSpy');
11
+
12
+ cy.interceptOpenCloningDBStub('get_sequences_search_by_name', { alias: 'getSequences' });
13
+
14
+ cy.mount(
15
+ <SequenceSelect label="Sequence" onChange={onChangeSpy} multiple={false} />
16
+ );
17
+
18
+ cy.get('input').click();
19
+ cy.contains('Type at least').should('exist');
20
+ cy.get('input').type(SEQUENCE_NAME);
21
+ cy.get('.MuiAutocomplete-listbox li', { timeout: 10000 }).should('have.length.greaterThan', 0);
22
+
23
+ clickMultiSelectOption('Sequence', RegExp(`^${SEQUENCE_NAME}$`), 'div');
24
+
25
+ cy.getStub('get_sequences_search_by_name').then((stub) => {
26
+ const stubSequence = stub.response.body.items.find((sequence) => sequence.name === SEQUENCE_NAME);
27
+ cy.get('@onChangeSpy').should('have.been.calledWith', stubSequence);
28
+ });
29
+
30
+ cy.get('input').should('have.value', SEQUENCE_NAME);
31
+ });
32
+ });
@@ -0,0 +1,44 @@
1
+ import React from 'react';
2
+ import { QuerySelect, useDebouncedSearchQuery } from '@opencloning/ui';
3
+ import { openCloningDBHttpClient } from './common';
4
+ import endpoints from './endpoints';
5
+
6
+ const messages = {
7
+ loadingMessage: 'retrieving sequences',
8
+ errorMessage: 'Could not retrieve sequences from OpenCloningDB',
9
+ };
10
+
11
+ const getGetQuery = (sequenceTypes) => {
12
+ return (name) => ({
13
+ queryKey: ['sequences', { sequence_types: sequenceTypes, name }],
14
+ queryFn: async () => {
15
+ const { data } = await openCloningDBHttpClient.get(endpoints.sequences, {
16
+ params: { sequence_types: sequenceTypes, name },
17
+ });
18
+ return data.items;
19
+ },
20
+ })};
21
+
22
+ function SequenceSelect({ value, onChange, label, multiple = true, sequenceTypes = undefined, ...rest }) {
23
+ const { query, autocompleteProps, clearInput } = useDebouncedSearchQuery(getGetQuery(sequenceTypes));
24
+
25
+ return (
26
+ <QuerySelect
27
+ query={query}
28
+ label={label}
29
+ loadingMessage={messages.loadingMessage}
30
+ errorMessage={messages.errorMessage}
31
+ multiple={multiple}
32
+ getOptionLabel={(seq) => seq.name ?? `Sequence ${seq.id}`}
33
+ getOptionKey={(seq) => seq.id}
34
+ value={value}
35
+ onChange={onChange}
36
+ autoComplete={true}
37
+ autocompleteProps={autocompleteProps}
38
+ onClear={clearInput}
39
+ {...rest}
40
+ />
41
+ );
42
+ }
43
+
44
+ export default SequenceSelect;
@@ -0,0 +1,74 @@
1
+ import React from 'react';
2
+ import { Provider } from 'react-redux';
3
+ import { configureStore } from '@reduxjs/toolkit';
4
+ import SubmitToDatabaseComponent from './SubmitToDatabaseComponent';
5
+ import { mockPrimers, mockTeselaJsonCache } from '../../../tests/mockNetworkData';
6
+
7
+ const createTestStore = (cloningState) =>
8
+ configureStore({
9
+ reducer: {
10
+ cloning: (state = cloningState) => state,
11
+ },
12
+ preloadedState: { cloning: cloningState },
13
+ });
14
+
15
+ const defaultState = {
16
+ primers: mockPrimers,
17
+ teselaJsonCache: mockTeselaJsonCache,
18
+ };
19
+
20
+ describe('<SubmitToDatabaseComponent />', () => {
21
+ it('displays the primer name in a disabled input and shows the alert', () => {
22
+ // Primer id=7, name='Primer1'
23
+ const store = createTestStore(defaultState);
24
+ cy.mount(
25
+ <Provider store={store}>
26
+ <SubmitToDatabaseComponent
27
+ id={7}
28
+ setSubmissionData={cy.spy().as('setSubmissionDataSpy')}
29
+ resourceType="primer"
30
+ />
31
+ </Provider>,
32
+ );
33
+
34
+ cy.get('input#resource_title').should('have.value', 'Primer1').and('be.disabled');
35
+ cy.get('.MuiAlert-root').contains('To change the primer name').should('exist');
36
+ });
37
+
38
+ it('displays the sequence name in a disabled input and shows the alert', () => {
39
+ // Sequence id=1, name='Seq1'
40
+ const store = createTestStore(defaultState);
41
+ cy.mount(
42
+ <Provider store={store}>
43
+ <SubmitToDatabaseComponent
44
+ id={1}
45
+ setSubmissionData={cy.spy().as('setSubmissionDataSpy')}
46
+ resourceType="sequence"
47
+ />
48
+ </Provider>,
49
+ );
50
+
51
+ cy.get('input#resource_title').should('have.value', 'Seq1').and('be.disabled');
52
+ cy.get('.MuiAlert-root').contains('To change the sequence name').should('exist');
53
+ });
54
+ it('sets the submission data to null when the name is missing (unlikely to happen in reality)', () => {
55
+ const submissionDataNoName = structuredClone(defaultState);
56
+ Object.values(submissionDataNoName.teselaJsonCache).forEach((value) => {
57
+ value.name = '';
58
+ });
59
+ const store = createTestStore(submissionDataNoName);
60
+ cy.mount(
61
+ <Provider store={store}>
62
+ <SubmitToDatabaseComponent
63
+ id={1}
64
+ setSubmissionData={cy.spy().as('setSubmissionDataSpy')}
65
+ resourceType="sequence"
66
+ />
67
+ </Provider>,
68
+ );
69
+ cy.get('@setSubmissionDataSpy').should((spy) => {
70
+ const result = spy.lastCall.args[0];
71
+ expect(result).equal(null);
72
+ });
73
+ });
74
+ });
@@ -0,0 +1,44 @@
1
+ import { Alert, FormControl, TextField } from '@mui/material';
2
+ import React from 'react';
3
+ import { useSelector } from 'react-redux';
4
+ import { Edit as EditIcon } from '@mui/icons-material';
5
+
6
+ function SubmitToDatabaseComponent({ id, setSubmissionData, resourceType }) {
7
+ const name = useSelector((state) => {
8
+ if (resourceType === 'primer') {
9
+ return state.cloning.primers.find((p) => p.id === id).name;
10
+ }
11
+ return state.cloning.teselaJsonCache[id].name;
12
+ });
13
+
14
+ React.useEffect(() => {
15
+ if (name) {
16
+ setSubmissionData((prev) => ({ ...prev, title: name }));
17
+ } else {
18
+ setSubmissionData(null);
19
+ }
20
+ }, [name, setSubmissionData]);
21
+
22
+ return (
23
+ <>
24
+ <Alert severity="info" sx={{ mb: 2 }}>
25
+ <span style={{ display: 'flex', alignItems: 'center' }}>
26
+ {`To change the ${resourceType} name, go back and click on the icon`}
27
+ <EditIcon sx={{ verticalAlign: 'middle', ml: 0.5, fontSize: '1.5rem' }} />
28
+ </span>
29
+
30
+ </Alert>
31
+ <FormControl fullWidth sx={{ mb: 2 }}>
32
+ <TextField
33
+ id="resource_title"
34
+ label="Name"
35
+ variant="standard"
36
+ value={name}
37
+ disabled
38
+ />
39
+ </FormControl>
40
+ </>
41
+ );
42
+ }
43
+
44
+ export default SubmitToDatabaseComponent;
package/src/common.js ADDED
@@ -0,0 +1,60 @@
1
+ import axios from 'axios';
2
+
3
+ export const baseUrl = 'http://localhost:8001';
4
+
5
+ let unauthorizedHandler = null;
6
+
7
+ export function setUnauthorizedHandler(fn) {
8
+ unauthorizedHandler = fn;
9
+ }
10
+
11
+ export function setWorkspaceHeader(id) {
12
+ openCloningDBHttpClient.defaults.headers.common['X-Workspace-Id'] = id;
13
+ }
14
+
15
+ export function clearWorkspaceHeader() {
16
+ delete openCloningDBHttpClient.defaults.headers.common['X-Workspace-Id'];
17
+ }
18
+
19
+ export const openCloningDBHttpClient = axios.create({
20
+ baseURL: baseUrl,
21
+ paramsSerializer: (params) => {
22
+ const searchParams = new URLSearchParams();
23
+
24
+ Object.entries(params || {}).forEach(([key, value]) => {
25
+ if (value === undefined || value === null) {
26
+ return;
27
+ }
28
+
29
+ if (Array.isArray(value)) {
30
+ value.forEach((v) => {
31
+ if (v !== undefined && v !== null) {
32
+ searchParams.append(key, String(v));
33
+ }
34
+ });
35
+ } else {
36
+ searchParams.append(key, String(value));
37
+ }
38
+ });
39
+
40
+ return searchParams.toString();
41
+ },
42
+ });
43
+
44
+ openCloningDBHttpClient.interceptors.request.use((config) => {
45
+ const token = localStorage.getItem('token');
46
+ if (token) {
47
+ config.headers.Authorization = `Bearer ${token}`;
48
+ }
49
+ return config;
50
+ });
51
+
52
+ openCloningDBHttpClient.interceptors.response.use(
53
+ (response) => response,
54
+ (error) => {
55
+ if (error.response?.status === 401 && unauthorizedHandler) {
56
+ unauthorizedHandler();
57
+ }
58
+ return Promise.reject(error);
59
+ },
60
+ );