@opencloning/ui 1.3.3 → 1.4.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,239 @@
1
+ import React from 'react';
2
+ import { ConfigProvider } from '@opencloning/ui/providers/ConfigProvider';
3
+ import { localFilesHttpClient } from '@opencloning/ui/hooks/useServerStaticFiles';
4
+ import UploadPlasmidsButton from './UploadPlasmidsButton';
5
+ import mocloSyntax from '../../../../../cypress/test_files/syntax/moclo_syntax.json';
6
+ import { dummyIndex } from '../form/ServerStaticFileSelect.cy.jsx';
7
+
8
+ mocloSyntax.overhangNames = {
9
+ ...mocloSyntax.overhangNames,
10
+ CCCT: 'CCCT_overhang',
11
+ AACG: 'AACG_overhang',
12
+ };
13
+
14
+ // Test config
15
+ const testConfig = {
16
+ backendUrl: 'http://localhost:8000',
17
+ showAppBar: false,
18
+ noExternalRequests: false,
19
+ enableAssembler: true,
20
+ enablePlannotate: false,
21
+ };
22
+
23
+ describe('<UploadPlasmidsButton />', () => {
24
+ beforeEach(() => {
25
+ cy.window().then((win) => {
26
+ win.localStorage.clear();
27
+ });
28
+ });
29
+
30
+ it('calls addPlasmids with correctly formatted valid plasmid', () => {
31
+ const addPlasmidsSpy = cy.spy().as('addPlasmidsSpy');
32
+
33
+ cy.mount(
34
+ <ConfigProvider config={testConfig}>
35
+ <UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
36
+ </ConfigProvider>,
37
+ );
38
+
39
+ cy.get('button').contains('Load Plasmids from Local Server').should('not.exist');
40
+ cy.get('button').contains('Add Plasmids').siblings('input').selectFile([
41
+ 'cypress/test_files/syntax/pYTK002.gb',
42
+ 'cypress/test_files/syntax/moclo_ytk_multi_part.gb',
43
+ 'cypress/test_files/syntax/pYTK095.gb',
44
+ 'cypress/test_files/sequencing/locus.gb',
45
+ // This one just to verify that it works with no features
46
+ 'cypress/test_files/syntax/pYTK002_no_features.gb'
47
+ ],
48
+ { force: true });
49
+
50
+ // Wait for the dialog to appear (indicating plasmids were processed)
51
+ cy.get('.MuiDialog-root', { timeout: 10000 }).should('be.visible');
52
+
53
+ cy.get('@addPlasmidsSpy').should('not.have.been.called');
54
+
55
+ cy.get('[data-testid="invalid-plasmids-box"]').contains('Invalid Plasmids').should('exist');
56
+ cy.get('[data-testid="valid-plasmids-box"]').contains('Valid Plasmids').should('exist');
57
+
58
+ cy.get('[data-testid="invalid-plasmids-box"]').contains('pYTK057')
59
+ cy.get('[data-testid="invalid-plasmids-box"]').contains('moclo_ytk_multi_part.gb')
60
+ cy.get('[data-testid="invalid-plasmids-box"] .MuiChip-label').contains('ATCC-TGGC')
61
+ cy.get('[data-testid="invalid-plasmids-box"] .MuiChip-label').contains('CCCT-AACG (CCCT_overhang-AACG_overhang)')
62
+ cy.get('[data-testid="invalid-plasmids-box"]').contains('Contains multiple parts')
63
+ cy.get('[data-testid="invalid-plasmids-box"]').contains('locus.gb')
64
+
65
+ cy.get('[data-testid="valid-plasmids-box"] tr').eq(1).find('td').eq(0).should('contain', 'pYTK002')
66
+ cy.get('[data-testid="valid-plasmids-box"] tr').eq(1).find('td').eq(1).should('contain', 'pYTK002.gb')
67
+ cy.get('[data-testid="valid-plasmids-box"] tr').eq(1).find('td').eq(2).should('contain', 'CCCT-AACG (CCCT_overhang-AACG_overhang)')
68
+ cy.get('[data-testid="valid-plasmids-box"] tr').eq(1).find('td').eq(3).should('contain', '1')
69
+ cy.get('[data-testid="valid-plasmids-box"] tr').eq(1).find('td').eq(4).should('contain', 'ConS')
70
+ cy.get('[data-testid="valid-plasmids-box"] tr').eq(1).then(($el) => {
71
+ const bgColor = window.getComputedStyle($el[0]).backgroundColor;
72
+ cy.wrap(bgColor).should('equal', 'rgb(132, 197, 222)');
73
+ });
74
+
75
+ cy.get('[data-testid="valid-plasmids-box"] tr').eq(2).find('td').eq(0).should('contain', 'pYTK095')
76
+ cy.get('[data-testid="valid-plasmids-box"] tr').eq(2).find('td').eq(1).should('contain', 'pYTK095.gb')
77
+ cy.get('[data-testid="valid-plasmids-box"] tr').eq(2).find('td').eq(2).should('contain', 'TACA-CCCT (TACA-CCCT_overhang)')
78
+ cy.get('[data-testid="valid-plasmids-box"] tr').eq(2).find('td').eq(3).should('contain', 'Spans multiple parts')
79
+ cy.get('[data-testid="valid-plasmids-box"] tr').eq(2).find('td').eq(4).should('contain', 'AmpR')
80
+
81
+ // No features
82
+ cy.get('[data-testid="valid-plasmids-box"] tr').eq(3).find('td').eq(4).should('contain', '-');
83
+
84
+ // Click the import button
85
+ cy.contains('button', 'Import valid plasmids').click();
86
+
87
+ // Verify addPlasmids was called
88
+ cy.get('@addPlasmidsSpy').should('have.been.called');
89
+
90
+ // Verify it was called with an array and check structure
91
+ cy.get('@addPlasmidsSpy').then((spy) => {
92
+ const firstCall = spy.getCall(0);
93
+ cy.wrap(firstCall.args[0]).should('be.an', 'array');
94
+ cy.wrap(firstCall.args[0]).should('have.length', 3);
95
+
96
+ const firstPlasmid = firstCall.args[0][0];
97
+
98
+ cy.wrap(firstPlasmid.file_name).should('equal', 'pYTK002.gb');
99
+ cy.wrap(firstPlasmid.plasmid_name).should('equal', 'pYTK002.gb (ConS)');
100
+ cy.wrap(firstPlasmid.left_overhang).should('equal', 'CCCT');
101
+ cy.wrap(firstPlasmid.right_overhang).should('equal', 'AACG');
102
+ cy.wrap(firstPlasmid.key).should('equal', 'CCCT-AACG');
103
+
104
+ const {appData} = firstPlasmid.sequenceData;
105
+
106
+ cy.wrap(appData.fileName).should('equal', 'pYTK002.gb');
107
+ cy.wrap(appData.correspondingParts).should('deep.equal', ['CCCT-AACG']);
108
+ cy.wrap(appData.correspondingPartsNames).should('deep.equal', ["CCCT_overhang-AACG_overhang"]);
109
+
110
+ });
111
+ });
112
+
113
+
114
+ it('does not allow to submit when all plasmids are invalid', () => {
115
+ cy.mount(
116
+ <ConfigProvider config={testConfig}>
117
+ <UploadPlasmidsButton addPlasmids={() => {}} syntax={mocloSyntax} />
118
+ </ConfigProvider>,
119
+ );
120
+ cy.get('button').contains('Add Plasmids').siblings('input').selectFile([
121
+ 'cypress/test_files/sequencing/locus.gb'],
122
+ { force: true });
123
+
124
+ // Wait for the dialog to appear (indicating plasmids were processed)
125
+ cy.get('.MuiDialog-root', { timeout: 10000 }).should('be.visible');
126
+
127
+ cy.get('[data-testid="invalid-plasmids-box"]').contains('Invalid Plasmids').should('exist');
128
+ cy.get('[data-testid="valid-plasmids-box"]').should('not.exist');
129
+
130
+ cy.get('button').contains('Import valid plasmids').should('be.disabled');
131
+
132
+ });
133
+
134
+ it('cancelling does not call addPlasmids', () => {
135
+ const addPlasmidsSpy = cy.spy().as('addPlasmidsSpy');
136
+ cy.mount(
137
+ <ConfigProvider config={testConfig}>
138
+ <UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
139
+ </ConfigProvider>,
140
+ );
141
+
142
+ cy.get('button').contains('Add Plasmids').siblings('input').selectFile([
143
+ 'cypress/test_files/syntax/pYTK002.gb',
144
+ 'cypress/test_files/syntax/moclo_ytk_multi_part.gb',
145
+ 'cypress/test_files/syntax/pYTK095.gb',
146
+ 'cypress/test_files/sequencing/locus.gb'],
147
+ { force: true });
148
+
149
+ cy.get('.MuiDialog-root', { timeout: 10000 }).should('be.visible');
150
+
151
+ cy.get('button').contains('Cancel').click();
152
+
153
+ cy.get('@addPlasmidsSpy').should('not.have.been.called');
154
+
155
+ });
156
+
157
+ it('loads plasmids from local server', () => {
158
+ const httpGet = cy.stub(localFilesHttpClient, 'get').callsFake((url) => {
159
+ if (url.endsWith('/index.json')) {
160
+ return Promise.resolve({
161
+ data: dummyIndex,
162
+ });
163
+ }
164
+ if (url.endsWith('/example.fa')) {
165
+ return Promise.resolve({ data: '>example\nATGC' });
166
+ }
167
+ if (url.endsWith('/example2.gb')) {
168
+ return cy.readFile('cypress/test_files/syntax/pYTK002.gb').then((fileContent) => ({ data: fileContent }));
169
+ }
170
+ throw new Error(`Unexpected URL: ${url}`);
171
+ });
172
+ cy.wrap(httpGet).as('httpGet');
173
+ const addPlasmidsSpy = cy.spy().as('addPlasmidsSpy');
174
+ cy.mount(
175
+ <ConfigProvider config={{...testConfig, staticContentPath: 'collection'}}>
176
+ <UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
177
+ </ConfigProvider>,
178
+ );
179
+ cy.get('button').contains('Load Plasmids from Local Server').click();
180
+
181
+ cy.get('button').contains('Submit').should('be.disabled');
182
+ cy.get('#option-select').click();
183
+ cy.contains('Example sequence 1').click();
184
+ cy.contains('Example sequence 2').click();
185
+ // Click outside to close select element
186
+ cy.get('.MuiDialog-container h2').click({force: true});
187
+ cy.contains('button', 'Submit').click();
188
+
189
+ cy.get('[data-testid="invalid-plasmids-box"]').contains('Invalid Plasmids').should('exist');
190
+ cy.get('[data-testid="valid-plasmids-box"]').contains('Valid Plasmids').should('exist');
191
+ cy.get('[data-testid="valid-plasmids-box"]').should('contain', 'pYTK002');
192
+ cy.get('[data-testid="invalid-plasmids-box"]').should('contain', 'example.fa');
193
+ cy.get('button').contains('Import valid plasmids').click();
194
+ cy.get('@addPlasmidsSpy').should('have.been.called');
195
+ cy.get('@addPlasmidsSpy').then((spy) => {
196
+ const firstCall = spy.getCall(0);
197
+ cy.wrap(firstCall.args[0]).should('be.an', 'array');
198
+ cy.wrap(firstCall.args[0]).should('have.length', 1);
199
+ cy.wrap(firstCall.args[0][0].file_name).should('equal', 'example2.gb');
200
+ });
201
+ });
202
+ it('handles error loading plasmids from local server', () => {
203
+ const httpGet = cy.stub(localFilesHttpClient, 'get').callsFake((url) => {
204
+ if (url.endsWith('/index.json')) {
205
+ return Promise.resolve({
206
+ data: dummyIndex,
207
+ });
208
+ }
209
+ if (url.endsWith('/example2.gb')) {
210
+ return Promise.resolve({ data: 'wrong_format' });
211
+ }
212
+ });
213
+ cy.wrap(httpGet).as('httpGet');
214
+ const addPlasmidsSpy = cy.spy().as('addPlasmidsSpy');
215
+ cy.mount(
216
+ <ConfigProvider config={{...testConfig, staticContentPath: 'collection'}}>
217
+ <UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
218
+ </ConfigProvider>,
219
+ );
220
+ cy.get('button').contains('Load Plasmids from Local Server').click();
221
+ cy.get('button').contains('Submit').should('be.disabled');
222
+ cy.get('#option-select').click();
223
+ cy.contains('Example sequence 1').click();
224
+ cy.get('.MuiDialog-container h2').click({force: true});
225
+ cy.contains('button', 'Submit').click();
226
+ cy.get('.MuiAlert-colorError').contains('Error requesting file').should('exist');
227
+
228
+ cy.get('#option-select').click();
229
+ cy.get('ul').contains('Example sequence 1').click();
230
+ cy.contains('Example sequence 2').click();
231
+ cy.get('.MuiDialog-container h2').click({force: true});
232
+ cy.contains('button', 'Submit').click();
233
+ cy.get('.MuiAlert-colorError').contains('Error uploading plasmid from file example2.gb').should('exist');
234
+ cy.get('.MuiAlert-colorError').contains('Error requesting file').should('not.exist');
235
+
236
+ cy.get('@addPlasmidsSpy').should('not.have.been.called');
237
+ });
238
+
239
+ });
@@ -0,0 +1,121 @@
1
+ import React from 'react';
2
+ import { Button, Dialog, DialogTitle, DialogContent, DialogActions, Alert, Box } from '@mui/material';
3
+ import { useConfig } from '../../providers';
4
+ import ServerStaticFileSelect from '../form/ServerStaticFileSelect';
5
+ import { usePlasmidsLogic } from './usePlasmidsLogic';
6
+ import PlasmidSyntaxTable from './PlasmidSyntaxTable';
7
+ import { jsonToGenbank } from '@teselagen/bio-parsers';
8
+
9
+ function formatPlasmid(sequenceData) {
10
+
11
+ const { appData } = sequenceData;
12
+ const { fileName, correspondingParts, longestFeature } = appData;
13
+ const [left_overhang, right_overhang] = correspondingParts[0].split('-');
14
+
15
+ let plasmidName = fileName;
16
+ if (longestFeature[0]?.name) {
17
+ plasmidName += ` (${longestFeature[0].name})`;
18
+ }
19
+
20
+ return {
21
+ type: 'loadedFile',
22
+ plasmid_name: plasmidName,
23
+ file_name: fileName,
24
+ left_overhang,
25
+ right_overhang,
26
+ key: `${left_overhang}-${right_overhang}`,
27
+ sequenceData,
28
+ genbankString: jsonToGenbank(sequenceData),
29
+ };
30
+
31
+ }
32
+
33
+ export function UploadPlasmidsFromLocalServerButton({ handleFileChange }) {
34
+ const [dialogOpen, setDialogOpen] = React.useState(false);
35
+ const [error, setError] = React.useState(null);
36
+ const {staticContentPath} = useConfig();
37
+
38
+ const onFileSelected = React.useCallback(async (files) => {
39
+ try {
40
+ await handleFileChange(files)
41
+ } catch (e) {
42
+ setError(e.message)
43
+ }
44
+ }, [handleFileChange, setError])
45
+
46
+ if (!staticContentPath) {
47
+ return null;
48
+ }
49
+
50
+ return <>
51
+ <Button color="primary" onClick={() => {setError(null); setDialogOpen(true)}}>
52
+ Load Plasmids from Local Server
53
+ </Button>
54
+ {dialogOpen && <Dialog open onClose={() => {setError(null); setDialogOpen(false)}}>
55
+ <DialogTitle>Load Plasmids from Local Server</DialogTitle>
56
+ <DialogContent sx={{ width: '500px' }}>
57
+ {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
58
+ <ServerStaticFileSelect onFileSelected={onFileSelected} type="sequence" multiple />
59
+ </DialogContent>
60
+ </Dialog>}
61
+ </>
62
+ }
63
+
64
+ function UploadPlasmidsButton({ addPlasmids, syntax }) {
65
+ const { uploadPlasmids, linkedPlasmids, setLinkedPlasmids } = usePlasmidsLogic(syntax)
66
+ const validPlasmids = React.useMemo(() => linkedPlasmids.filter((plasmid) => plasmid.appData.correspondingParts.length === 1), [linkedPlasmids])
67
+ const invalidPlasmids = React.useMemo(() => linkedPlasmids.filter((plasmid) => plasmid.appData.correspondingParts.length !== 1), [linkedPlasmids])
68
+ const fileInputRef = React.useRef(null)
69
+
70
+ const handleFileChange = async (files) => {
71
+ fileInputRef.current.value = ''
72
+ await uploadPlasmids(Array.from(files))
73
+ }
74
+
75
+ const handleImportValidPlasmids = React.useCallback(() => {
76
+ addPlasmids(validPlasmids.map(formatPlasmid))
77
+ setLinkedPlasmids([])
78
+ }, [addPlasmids, validPlasmids, setLinkedPlasmids])
79
+
80
+ return (<>
81
+ <Button color="primary" onClick={() => fileInputRef.current.click()}>
82
+ Add Plasmids
83
+ </Button>
84
+ <input multiple type="file" ref={fileInputRef} style={{ display: 'none' }} onChange={(event) => handleFileChange(Array.from(event.target.files))} accept=".gbk,.gb,.fasta,.fa,.dna" />
85
+ <UploadPlasmidsFromLocalServerButton handleFileChange={handleFileChange} />
86
+ <Dialog
87
+ maxWidth="lg"
88
+ fullWidth
89
+ open={invalidPlasmids.length > 0 || validPlasmids.length > 0}
90
+ onClose={() => setLinkedPlasmids([])}
91
+ PaperProps={{
92
+ style: {
93
+ maxHeight: '80vh',
94
+ },
95
+ }}
96
+ >
97
+ <DialogActions sx={{ justifyContent: 'center', position: 'sticky', top: 0, zIndex: 99, background: '#fff' }}>
98
+ <Button disabled={validPlasmids.length === 0} variant="contained" color="success" onClick={handleImportValidPlasmids}>Import valid plasmids</Button>
99
+ <Button variant="contained" color="error" onClick={() => setLinkedPlasmids([])}>Cancel</Button>
100
+ </DialogActions>
101
+ {invalidPlasmids.length > 0 && (
102
+ <Box data-testid="invalid-plasmids-box">
103
+ <DialogTitle>Invalid Plasmids</DialogTitle>
104
+ <DialogContent>
105
+ <PlasmidSyntaxTable plasmids={invalidPlasmids} />
106
+ </DialogContent>
107
+ </Box>
108
+ )}
109
+ {validPlasmids.length > 0 && (
110
+ <Box data-testid="valid-plasmids-box">
111
+ <DialogTitle>Valid Plasmids</DialogTitle>
112
+ <DialogContent>
113
+ <PlasmidSyntaxTable plasmids={validPlasmids} />
114
+ </DialogContent>
115
+ </Box>
116
+ )}
117
+ </Dialog>
118
+ </>)
119
+ }
120
+
121
+ export default UploadPlasmidsButton
@@ -69,11 +69,15 @@ export function usePlasmidsLogic({ parts, assemblyEnzyme, overhangNames }) {
69
69
 
70
70
  const uploadPlasmids = React.useCallback(async (files) => {
71
71
  const plasmids = await Promise.all(files.map(async (file) => {
72
- const data = await anyToJson(file);
73
- const sequenceData = data[0].parsedSequence;
74
- // Force circular
75
- sequenceData.circular = true;
76
- return {...sequenceData, appData: { fileName: file.name, correspondingParts: [], partInfo: [], correspondingPartsNames: [] } };
72
+ try {
73
+ const data = await anyToJson(file);
74
+ const sequenceData = data[0].parsedSequence;
75
+ // Force circular
76
+ sequenceData.circular = true;
77
+ return {...sequenceData, appData: { fileName: file.name, correspondingParts: [], partInfo: [], correspondingPartsNames: [] } };
78
+ } catch (e) {
79
+ throw new Error(`Error uploading plasmid from file ${file.name}: ${e.message}`);
80
+ }
77
81
  }));
78
82
  setLinkedPlasmids(plasmids);
79
83
  }, [setLinkedPlasmids]);