@opencloning/ui 1.9.2 → 2.1.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/package.json +5 -6
  3. package/src/components/CloningSpeedDial.jsx +3 -5
  4. package/src/components/EditSequenceNameDialog.jsx +3 -5
  5. package/src/components/ExternalServicesStatusCheck.jsx +3 -5
  6. package/src/components/LoadCloningHistoryWrapper.jsx +5 -7
  7. package/src/components/assembler/Assembler.cy.jsx +184 -0
  8. package/src/components/assembler/Assembler.jsx +57 -11
  9. package/src/components/assembler/ExistingSyntaxDialog.jsx +2 -2
  10. package/src/components/assembler/UploadPlasmidsButton.cy.jsx +88 -6
  11. package/src/components/assembler/UploadPlasmidsButton.jsx +137 -73
  12. package/src/components/assembler/assembler_utils.js +25 -12
  13. package/src/components/assembler/assembler_utils.test.js +40 -1
  14. package/src/components/assembler/useAssembler.js +24 -8
  15. package/src/components/assembler/usePlasmidsLogic.js +20 -2
  16. package/src/components/domesticate/DomesticateDialog.jsx +4 -5
  17. package/src/components/dummy/DummyInterface.js +6 -0
  18. package/src/components/form/EnzymeMultiSelect.jsx +3 -5
  19. package/src/components/navigation/MainAppBar.jsx +4 -2
  20. package/src/components/navigation/SelectTemplateDialog.jsx +4 -2
  21. package/src/components/primers/primer_design/SequenceTabComponents/hooks/useDesignPrimers.js +4 -6
  22. package/src/components/primers/primer_details/usePrimerDetailsEndpoints.js +4 -6
  23. package/src/components/sources/CollectionSource.jsx +2 -2
  24. package/src/components/sources/Source.jsx +32 -32
  25. package/src/components/sources/SourceAnnotation.jsx +2 -2
  26. package/src/components/sources/SourceAssembly.jsx +7 -7
  27. package/src/components/sources/SourceDatabase.jsx +2 -2
  28. package/src/components/sources/SourceFile.jsx +2 -2
  29. package/src/components/sources/SourceGenomeRegion.cy.jsx +46 -0
  30. package/src/components/sources/SourceGenomeRegion.jsx +29 -15
  31. package/src/components/sources/SourceHomologousRecombination.jsx +3 -3
  32. package/src/components/sources/SourceKnownGenomeRegion.jsx +3 -3
  33. package/src/components/sources/SourceManuallyTyped.jsx +2 -2
  34. package/src/components/sources/SourcePCRorHybridization.jsx +3 -3
  35. package/src/components/sources/SourcePolymeraseExtension.jsx +2 -2
  36. package/src/components/sources/SourceRepositoryId.jsx +4 -4
  37. package/src/components/sources/SourceRestriction.jsx +2 -2
  38. package/src/components/sources/SourceReverseComplement.jsx +2 -2
  39. package/src/components/sources/SourceServerStaticFile.jsx +3 -3
  40. package/src/components/verification/VerificationFileDialog.cy.jsx +17 -21
  41. package/src/components/verification/VerificationFileDialog.jsx +12 -11
  42. package/src/hooks/index.js +1 -2
  43. package/src/hooks/useBackendHttpClient.js +17 -0
  44. package/src/hooks/useCloningHistoryLoader.js +5 -7
  45. package/src/hooks/useGatewaySites.js +3 -5
  46. package/src/hooks/useInitializeApp.js +3 -4
  47. package/src/hooks/useLoadDatabaseFile.js +2 -2
  48. package/src/hooks/useSequencingAlignment.js +4 -6
  49. package/src/hooks/useServerStaticFiles.js +2 -2
  50. package/src/hooks/{useBackendAPI.js → useSourceRequest.js} +6 -8
  51. package/src/hooks/useStoreEditor.js +2 -1
  52. package/src/hooks/useUrlParamsLoader.js +8 -5
  53. package/src/hooks/useValidateState.js +4 -6
  54. package/src/version.js +1 -1
  55. package/src/hooks/useBackendRoute.js +0 -23
  56. package/src/hooks/useHttpClient.js +0 -20
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { ConfigProvider } from '@opencloning/ui/providers/ConfigProvider';
3
+ import { DatabaseProvider } from '@opencloning/ui/providers/DatabaseContext';
3
4
  import { localFilesHttpClient } from '@opencloning/ui/hooks';
4
5
  import UploadPlasmidsButton from './UploadPlasmidsButton';
5
6
  import mocloSyntax from '../../../../../cypress/test_files/syntax/moclo_syntax.json';
@@ -67,8 +68,10 @@ describe('<UploadPlasmidsButton />', () => {
67
68
  </ConfigProvider>,
68
69
  );
69
70
 
70
- cy.get('button').contains('Load Plasmids from Local Server').should('not.exist');
71
- cy.get('button').contains('Add Plasmids').siblings('input').selectFile([
71
+ cy.get('[data-testid="load-plasmids-button"]').click();
72
+ cy.get('[data-testid="load-plasmids-from-local-server"]').should('not.exist');
73
+ cy.get('body').type('{esc}');
74
+ cy.get('[data-testid="load-plasmids-file-input"]').selectFile([
72
75
  'cypress/test_files/syntax/pYTK002.gb',
73
76
  'cypress/test_files/syntax/moclo_ytk_multi_part.gb',
74
77
  'cypress/test_files/syntax/pYTK095.gb',
@@ -148,7 +151,7 @@ describe('<UploadPlasmidsButton />', () => {
148
151
  <UploadPlasmidsButton addPlasmids={() => {}} syntax={mocloSyntax} />
149
152
  </ConfigProvider>,
150
153
  );
151
- cy.get('button').contains('Add Plasmids').siblings('input').selectFile([
154
+ cy.get('[data-testid="load-plasmids-file-input"]').selectFile([
152
155
  'cypress/test_files/sequencing/locus.gb'],
153
156
  { force: true });
154
157
 
@@ -170,7 +173,7 @@ describe('<UploadPlasmidsButton />', () => {
170
173
  </ConfigProvider>,
171
174
  );
172
175
 
173
- cy.get('button').contains('Add Plasmids').siblings('input').selectFile([
176
+ cy.get('[data-testid="load-plasmids-file-input"]').selectFile([
174
177
  'cypress/test_files/syntax/pYTK002.gb',
175
178
  'cypress/test_files/syntax/moclo_ytk_multi_part.gb',
176
179
  'cypress/test_files/syntax/pYTK095.gb',
@@ -207,7 +210,8 @@ describe('<UploadPlasmidsButton />', () => {
207
210
  <UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
208
211
  </ConfigProvider>,
209
212
  );
210
- cy.get('button').contains('Load Plasmids from Local Server').click();
213
+ cy.get('[data-testid="load-plasmids-button"]').click();
214
+ cy.get('[data-testid="load-plasmids-from-local-server"]').click();
211
215
 
212
216
  cy.get('button').contains('Submit').should('be.disabled');
213
217
  cy.get('#option-select').click();
@@ -248,7 +252,8 @@ describe('<UploadPlasmidsButton />', () => {
248
252
  <UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
249
253
  </ConfigProvider>,
250
254
  );
251
- cy.get('button').contains('Load Plasmids from Local Server').click();
255
+ cy.get('[data-testid="load-plasmids-button"]').click();
256
+ cy.get('[data-testid="load-plasmids-from-local-server"]').click();
252
257
  cy.get('button').contains('Submit').should('be.disabled');
253
258
  cy.get('#option-select').click();
254
259
  cy.contains('Example sequence 1').click();
@@ -267,4 +272,81 @@ describe('<UploadPlasmidsButton />', () => {
267
272
  cy.get('@addPlasmidsSpy').should('not.have.been.called');
268
273
  });
269
274
 
275
+ it('does not show the database button when there is no database or it does not support bulk loading', () => {
276
+ cy.mount(
277
+ <ConfigProvider config={testConfig}>
278
+ <DatabaseProvider value={{ name: 'MockDB' }}>
279
+ <UploadPlasmidsButton addPlasmids={() => {}} syntax={mocloSyntax} />
280
+ </DatabaseProvider>
281
+ </ConfigProvider>,
282
+ );
283
+ cy.get('[data-testid="load-plasmids-button"]').click();
284
+ cy.get('[data-testid="load-plasmids-from-files"]').should('exist');
285
+ cy.get('[data-testid="load-plasmids-from-database"]').should('not.exist');
286
+ });
287
+
288
+ it('loads plasmids from the database as DatabaseSource plasmids', () => {
289
+ cy.readFile('cypress/test_files/syntax/pYTK002.gb').then((pYTK002) => {
290
+ cy.readFile('cypress/test_files/syntax/moclo_ytk_multi_part.gb').then((multiPart) => {
291
+ const entries = [
292
+ { databaseId: 10, name: 'pYTK002_db', textFileSequence: { id: 10, type: 'TextFileSequence', sequence_file_format: 'genbank', overhang_crick_3prime: 0, overhang_watson_3prime: 0, file_content: pYTK002 } },
293
+ { databaseId: 20, name: 'multi_part_db', textFileSequence: { id: 20, type: 'TextFileSequence', sequence_file_format: 'genbank', overhang_crick_3prime: 0, overhang_watson_3prime: 0, file_content: multiPart } },
294
+ ];
295
+ const mockDatabase = {
296
+ name: 'MockDB',
297
+ GetSequencesInBulkComponent: ({ onSequencesLoaded }) => <button type="button" onClick={() => onSequencesLoaded(entries)}>Mock load</button>,
298
+ };
299
+ const addPlasmidsSpy = cy.spy().as('addPlasmidsSpy');
300
+ cy.mount(
301
+ <ConfigProvider config={testConfig}>
302
+ <DatabaseProvider value={mockDatabase}>
303
+ <UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
304
+ </DatabaseProvider>
305
+ </ConfigProvider>,
306
+ );
307
+ cy.get('[data-testid="load-plasmids-button"]').click();
308
+ cy.get('[data-testid="load-plasmids-from-database"]').contains('From MockDB').click();
309
+ cy.contains('button', 'Mock load').click();
310
+ // The bulk dialog closes and the valid / invalid dialog is shown
311
+ cy.contains('button', 'Mock load').should('not.exist');
312
+ cy.get('[data-testid="invalid-plasmids-box"]').contains('multi_part_db');
313
+ cy.get('[data-testid="valid-plasmids-box"]').contains('pYTK002_db');
314
+
315
+ cy.contains('button', 'Import valid plasmids').click();
316
+ cy.get('@addPlasmidsSpy').should('have.been.calledOnce');
317
+ cy.get('@addPlasmidsSpy').then((spy) => {
318
+ const plasmids = spy.getCall(0).args[0];
319
+ expect(plasmids).to.have.length(1);
320
+ const [plasmid] = plasmids;
321
+ expect(plasmid.type).to.equal('databaseSequence');
322
+ expect(plasmid.database_id).to.equal(10);
323
+ expect(plasmid.plasmid_name).to.equal('pYTK002_db (ConS)');
324
+ expect(plasmid.key).to.equal('CCCT-AACG');
325
+ expect(plasmid.userUploaded).to.equal(true);
326
+ // The sequence is kept as returned by the database
327
+ expect(plasmid.textFileSequence).to.deep.equal(entries[0].textFileSequence);
328
+ });
329
+ });
330
+ });
331
+ });
332
+
333
+ it('shows an error if a database sequence cannot be parsed', () => {
334
+ const mockDatabase = {
335
+ name: 'MockDB',
336
+ GetSequencesInBulkComponent: ({ onSequencesLoaded }) => <button type="button" onClick={() => onSequencesLoaded([{ databaseId: 1, name: 'broken', textFileSequence: { file_content: 'wrong_format' } }])}>Mock load</button>,
337
+ };
338
+ const addPlasmidsSpy = cy.spy().as('addPlasmidsSpy');
339
+ cy.mount(
340
+ <ConfigProvider config={testConfig}>
341
+ <DatabaseProvider value={mockDatabase}>
342
+ <UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
343
+ </DatabaseProvider>
344
+ </ConfigProvider>,
345
+ );
346
+ cy.get('[data-testid="load-plasmids-button"]').click();
347
+ cy.get('[data-testid="load-plasmids-from-database"]').contains('From MockDB').click();
348
+ cy.contains('button', 'Mock load').click();
349
+ cy.get('.MuiAlert-colorError').contains('Error loading plasmid broken from database').should('exist');
350
+ cy.get('@addPlasmidsSpy').should('not.have.been.called');
351
+ });
270
352
  });
@@ -1,6 +1,8 @@
1
1
  import React from 'react';
2
- import { Button, Dialog, DialogTitle, DialogContent, DialogActions, Alert, Box, ButtonGroup } from '@mui/material';
2
+ import { Button, Dialog, DialogTitle, DialogContent, DialogActions, Alert, Box, ListItemIcon, Menu, MenuItem } from '@mui/material';
3
+ import { ArrowDropDown as ArrowDropDownIcon, Dns as DnsIcon, Storage as StorageIcon, UploadFile as UploadFileIcon } from '@mui/icons-material';
3
4
  import { useConfig } from '../../providers';
5
+ import useDatabase from '../../hooks/useDatabase';
4
6
  import ServerStaticFileSelect from '../form/ServerStaticFileSelect';
5
7
  import { usePlasmidsLogic } from './usePlasmidsLogic';
6
8
  import PlasmidSyntaxTable from './PlasmidSyntaxTable';
@@ -9,14 +11,28 @@ import { jsonToGenbank } from '@teselagen/bio-parsers';
9
11
  function formatPlasmid(sequenceData) {
10
12
 
11
13
  const { appData } = sequenceData;
12
- const { fileName, correspondingParts, longestFeature } = appData;
14
+ const { fileName, correspondingParts, longestFeature, databaseId, textFileSequence } = appData;
13
15
  const [left_overhang, right_overhang] = correspondingParts[0].split('-');
14
-
16
+
15
17
  let plasmidName = fileName;
16
18
  if (longestFeature[0]?.name) {
17
19
  plasmidName += ` (${longestFeature[0].name})`;
18
20
  }
19
-
21
+
22
+ if (databaseId !== undefined) {
23
+ return {
24
+ type: 'databaseSequence',
25
+ plasmid_name: plasmidName,
26
+ database_id: databaseId,
27
+ left_overhang,
28
+ right_overhang,
29
+ key: `${left_overhang}-${right_overhang}`,
30
+ sequenceData,
31
+ textFileSequence,
32
+ userUploaded: true,
33
+ };
34
+ }
35
+
20
36
  return {
21
37
  type: 'loadedFile',
22
38
  plasmid_name: plasmidName,
@@ -28,95 +44,143 @@ function formatPlasmid(sequenceData) {
28
44
  genbankString: jsonToGenbank(sequenceData),
29
45
  userUploaded: true,
30
46
  };
31
-
47
+
32
48
  }
33
49
 
34
- export function UploadPlasmidsFromLocalServerButton({ handleFileChange }) {
35
- const [dialogOpen, setDialogOpen] = React.useState(false);
50
+ /** Dialog wrapping a component that loads plasmids. children is a function that receives the onLoad callback. */
51
+ function LoadPlasmidsDialog({ title, onLoad, onClose, children }) {
36
52
  const [error, setError] = React.useState(null);
37
- const {staticContentPath} = useConfig();
38
-
39
- const onFileSelected = React.useCallback(async (files) => {
53
+
54
+ const handleLoad = React.useCallback(async (items) => {
40
55
  try {
41
- await handleFileChange(files)
42
- setDialogOpen(false)
56
+ await onLoad(items)
57
+ onClose()
43
58
  } catch (e) {
44
59
  setError(e.message)
45
60
  }
46
- }, [handleFileChange, setError])
47
-
48
- if (!staticContentPath) {
49
- return null;
61
+ }, [onLoad, onClose])
62
+
63
+ return <Dialog open onClose={onClose}>
64
+ <DialogTitle>{title}</DialogTitle>
65
+ <DialogContent sx={{ width: '500px' }}>
66
+ {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
67
+ {children(handleLoad)}
68
+ </DialogContent>
69
+ </Dialog>
70
+ }
71
+
72
+ /**
73
+ * "Load Plasmids" button with a dropdown. The "From files" option is always present (opens a file picker
74
+ * and calls onFilesSelected), extraOptions are appended: [{ id, label, Icon, onClick }]
75
+ */
76
+ function LoadPlasmidsMenuButton({ onFilesSelected, extraOptions }) {
77
+ const [anchorEl, setAnchorEl] = React.useState(null)
78
+ const open = Boolean(anchorEl)
79
+ const fileInputRef = React.useRef(null)
80
+
81
+ const onFileInputChange = (event) => {
82
+ const files = Array.from(event.target.files)
83
+ event.target.value = ''
84
+ onFilesSelected(files)
50
85
  }
51
-
86
+
52
87
  return <>
53
- <Button color="primary" onClick={() => {setError(null); setDialogOpen(true)}}>
54
- Load Plasmids from Local Server
88
+ <input multiple type="file" ref={fileInputRef} style={{ display: 'none' }} onChange={onFileInputChange} accept=".gbk,.gb,.fasta,.fa,.dna" data-testid="load-plasmids-file-input" />
89
+ <Button
90
+ color="primary"
91
+ onClick={(event) => setAnchorEl(event.currentTarget)}
92
+ endIcon={<ArrowDropDownIcon />}
93
+ aria-haspopup="true"
94
+ aria-expanded={open ? 'true' : undefined}
95
+ aria-controls={open ? 'load-plasmids-menu' : undefined}
96
+ data-testid="load-plasmids-button"
97
+ >
98
+ Load Plasmids
55
99
  </Button>
56
- {dialogOpen && <Dialog open onClose={() => {setError(null); setDialogOpen(false)}}>
57
- <DialogTitle>Load Plasmids from Local Server</DialogTitle>
58
- <DialogContent sx={{ width: '500px' }}>
59
- {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
60
- <ServerStaticFileSelect onFileSelected={onFileSelected} type="sequence" multiple />
61
- </DialogContent>
62
- </Dialog>}
100
+ <Menu id="load-plasmids-menu" anchorEl={anchorEl} open={open} onClose={() => setAnchorEl(null)} onClick={() => setAnchorEl(null)}>
101
+ <MenuItem onClick={() => fileInputRef.current.click()} data-testid="load-plasmids-from-files">
102
+ <ListItemIcon><UploadFileIcon fontSize="small" /></ListItemIcon>
103
+ From files
104
+ </MenuItem>
105
+ {extraOptions.map(({ id, label, Icon, onClick }) => (
106
+ <MenuItem key={id} onClick={onClick} data-testid={`load-plasmids-from-${id}`}>
107
+ <ListItemIcon><Icon fontSize="small" /></ListItemIcon>
108
+ {label}
109
+ </MenuItem>
110
+ ))}
111
+ </Menu>
63
112
  </>
64
113
  }
65
-
114
+
115
+ function ImportPlasmidsDialog({ validPlasmids, invalidPlasmids, onImport, onCancel }) {
116
+ return <Dialog
117
+ maxWidth="lg"
118
+ fullWidth
119
+ open={invalidPlasmids.length > 0 || validPlasmids.length > 0}
120
+ onClose={onCancel}
121
+ PaperProps={{ style: { maxHeight: '80vh' } }}
122
+ >
123
+ <DialogActions sx={{ justifyContent: 'center', position: 'sticky', top: 0, zIndex: 99, background: '#fff', py: 2 }}>
124
+ <Button sx={{ p: 1.5, fontSize: '1rem' }} disabled={validPlasmids.length === 0} variant="contained" color="success" onClick={onImport}>Import valid plasmids</Button>
125
+ <Button sx={{ p: 1.5, fontSize: '1rem' }} variant="contained" color="error" onClick={onCancel}>Cancel</Button>
126
+ </DialogActions>
127
+ {invalidPlasmids.length > 0 && (
128
+ <Box data-testid="invalid-plasmids-box">
129
+ <DialogTitle>Invalid Plasmids</DialogTitle>
130
+ <DialogContent>
131
+ <PlasmidSyntaxTable plasmids={invalidPlasmids} />
132
+ </DialogContent>
133
+ </Box>
134
+ )}
135
+ {validPlasmids.length > 0 && (
136
+ <Box data-testid="valid-plasmids-box">
137
+ <DialogTitle>Valid Plasmids</DialogTitle>
138
+ <DialogContent>
139
+ <PlasmidSyntaxTable plasmids={validPlasmids} />
140
+ </DialogContent>
141
+ </Box>
142
+ )}
143
+ </Dialog>
144
+ }
145
+
66
146
  function UploadPlasmidsButton({ addPlasmids, syntax }) {
67
- const { uploadPlasmids, linkedPlasmids, setLinkedPlasmids } = usePlasmidsLogic(syntax)
147
+ const { uploadPlasmids, loadPlasmidsFromDatabase, linkedPlasmids, setLinkedPlasmids } = usePlasmidsLogic(syntax)
68
148
  const validPlasmids = React.useMemo(() => linkedPlasmids.filter((plasmid) => plasmid.appData.correspondingParts.length === 1), [linkedPlasmids])
69
149
  const invalidPlasmids = React.useMemo(() => linkedPlasmids.filter((plasmid) => plasmid.appData.correspondingParts.length !== 1), [linkedPlasmids])
70
- const fileInputRef = React.useRef(null)
71
-
72
- const handleFileChange = async (files) => {
73
- fileInputRef.current.value = ''
74
- await uploadPlasmids(Array.from(files))
75
- }
76
-
150
+ // null | 'local-server' | 'database'
151
+ const [openDialog, setOpenDialog] = React.useState(null)
152
+ const closeDialog = React.useCallback(() => setOpenDialog(null), [])
153
+ const { staticContentPath } = useConfig();
154
+ const database = useDatabase();
155
+
77
156
  const handleImportValidPlasmids = React.useCallback(() => {
78
157
  addPlasmids(validPlasmids.map(formatPlasmid))
79
158
  setLinkedPlasmids([])
80
159
  }, [addPlasmids, validPlasmids, setLinkedPlasmids])
81
-
160
+
161
+ const extraMenuOptions = [
162
+ staticContentPath && { id: 'local-server', label: 'From local server', Icon: DnsIcon, onClick: () => setOpenDialog('local-server') },
163
+ database?.GetSequencesInBulkComponent && { id: 'database', label: `From ${database.name}`, Icon: StorageIcon, onClick: () => setOpenDialog('database') },
164
+ ].filter(Boolean)
165
+
82
166
  return (<>
83
- <Button color="primary" onClick={() => fileInputRef.current.click()}>
84
- Add Plasmids
85
- </Button>
86
- <input multiple type="file" ref={fileInputRef} style={{ display: 'none' }} onChange={(event) => handleFileChange(Array.from(event.target.files))} accept=".gbk,.gb,.fasta,.fa,.dna" />
87
- <UploadPlasmidsFromLocalServerButton handleFileChange={handleFileChange} />
88
- <Dialog
89
- maxWidth="lg"
90
- fullWidth
91
- open={invalidPlasmids.length > 0 || validPlasmids.length > 0}
92
- onClose={() => setLinkedPlasmids([])}
93
- PaperProps={{
94
- style: {
95
- maxHeight: '80vh',
96
- },
97
- }}
98
- >
99
- <DialogActions sx={{ justifyContent: 'center', position: 'sticky', top: 0, zIndex: 99, background: '#fff', py: 2 }}>
100
- <Button sx={{ p: 1.5, fontSize: '1rem' }} disabled={validPlasmids.length === 0} variant="contained" color="success" onClick={handleImportValidPlasmids}>Import valid plasmids</Button>
101
- <Button sx={{ p: 1.5, fontSize: '1rem' }} variant="contained" color="error" onClick={() => setLinkedPlasmids([])}>Cancel</Button>
102
- </DialogActions>
103
- {invalidPlasmids.length > 0 && (
104
- <Box data-testid="invalid-plasmids-box">
105
- <DialogTitle>Invalid Plasmids</DialogTitle>
106
- <DialogContent>
107
- <PlasmidSyntaxTable plasmids={invalidPlasmids} />
108
- </DialogContent>
109
- </Box>
110
- )}
111
- {validPlasmids.length > 0 && (
112
- <Box data-testid="valid-plasmids-box">
113
- <DialogTitle>Valid Plasmids</DialogTitle>
114
- <DialogContent>
115
- <PlasmidSyntaxTable plasmids={validPlasmids} />
116
- </DialogContent>
117
- </Box>
118
- )}
119
- </Dialog>
167
+ <LoadPlasmidsMenuButton onFilesSelected={uploadPlasmids} extraOptions={extraMenuOptions} />
168
+ {openDialog === 'local-server' && (
169
+ <LoadPlasmidsDialog title="Load Plasmids from Local Server" onLoad={uploadPlasmids} onClose={closeDialog}>
170
+ {(onLoad) => <ServerStaticFileSelect onFileSelected={onLoad} type="sequence" multiple />}
171
+ </LoadPlasmidsDialog>
172
+ )}
173
+ {openDialog === 'database' && (
174
+ <LoadPlasmidsDialog title={`Load Plasmids from ${database.name}`} onLoad={loadPlasmidsFromDatabase} onClose={closeDialog}>
175
+ {(onLoad) => <database.GetSequencesInBulkComponent onSequencesLoaded={onLoad} />}
176
+ </LoadPlasmidsDialog>
177
+ )}
178
+ <ImportPlasmidsDialog
179
+ validPlasmids={validPlasmids}
180
+ invalidPlasmids={invalidPlasmids}
181
+ onImport={handleImportValidPlasmids}
182
+ onCancel={() => setLinkedPlasmids([])}
183
+ />
120
184
  </>)
121
185
  }
122
186
 
@@ -203,26 +203,39 @@ export function getFilesToExportFromAssembler({requestedAssemblies, expandedAsse
203
203
  });
204
204
  }
205
205
 
206
- for (let i = 0; i < requestedAssemblies.length; i++) {
206
+ getCloningStrategyFilesFromAssembler({ requestedAssemblies, appInfo, outputNames }).forEach(({ name, content, finalSequence }) => {
207
+ files2Export.push({ name, content });
208
+ files2Export.push({
209
+ name: name.replace(/\.json$/, '.gbk'),
210
+ content: finalSequence.file_content,
211
+ });
212
+ });
213
+ return files2Export;
214
+ }
215
+
216
+ /**
217
+ * One cloning strategy JSON file per assembly, with the output name set on the final sequence.
218
+ * Returns [{ name: '<name>.json', content: '<json string>', finalSequence }]. The input is not modified.
219
+ */
220
+ export function getCloningStrategyFilesFromAssembler({ requestedAssemblies, appInfo, outputNames }) {
221
+ if (outputNames.length !== requestedAssemblies.length) {
222
+ throw new Error('outputNames must be the same length as requestedAssemblies');
223
+ }
224
+ return requestedAssemblies.map((assembly, i) => {
207
225
  const name = sanitizeOutputName(outputNames[i]);
208
- const requestedAssembly = JSON.parse(JSON.stringify(requestedAssemblies[i]));
209
- const jsonContent = formatStateForJsonExport({...requestedAssembly, appInfo});
226
+ const requestedAssembly = JSON.parse(JSON.stringify(assembly));
210
227
  const finalSequenceId = getGraftSequenceId(requestedAssembly)
211
228
  const finalSequence = requestedAssembly.sequences.find(s => s.id === finalSequenceId)
212
229
  finalSequence.file_content = editGenbankSequenceNameFromTextContent(finalSequence.file_content, name);
213
230
  const finalSource = requestedAssembly.sources.find(s => s.id === finalSequenceId)
214
231
  finalSource.output_name = name;
215
- files2Export.push({
232
+ const jsonContent = formatStateForJsonExport({...requestedAssembly, appInfo});
233
+ return {
216
234
  name: `${name}.json`,
217
235
  content: JSON.stringify(jsonContent, null, 2),
218
- });
219
-
220
- files2Export.push({
221
- name: `${name}.gbk`,
222
- content: finalSequence.file_content,
223
- });
224
- }
225
- return files2Export;
236
+ finalSequence,
237
+ };
238
+ });
226
239
  }
227
240
 
228
241
  export async function downloadAssemblerFilesAsZip(files) {
@@ -1,6 +1,6 @@
1
1
  import { aliasedEnzymesByName, getDigestFragmentsForRestrictionEnzymes, getReverseComplementSequenceString, getComplementSequenceString, getReverseComplementSequenceAndAnnotations } from "@teselagen/sequence-utils";
2
2
  import fs from 'fs';
3
- import { assignSequenceToSyntaxPart, simplifyDigestFragment, reverseComplementSimplifiedDigestFragment, tripletsToTranslation, partDataToDisplayData, arrayCombinations, getFilesToExportFromAssembler, getDefaultAssemblyOutputName } from "./assembler_utils";
3
+ import { assignSequenceToSyntaxPart, simplifyDigestFragment, reverseComplementSimplifiedDigestFragment, tripletsToTranslation, partDataToDisplayData, arrayCombinations, getFilesToExportFromAssembler, getCloningStrategyFilesFromAssembler, getDefaultAssemblyOutputName, sanitizeOutputName } from "./assembler_utils";
4
4
  import { partsToEdgesGraph } from "./graph_utils";
5
5
 
6
6
 
@@ -379,3 +379,42 @@ describe('getFilesToExportFromAssembler', () => {
379
379
  expect(files[5].name).toBe('my_assembly_2.gbk');
380
380
  });
381
381
  });
382
+
383
+ describe('getCloningStrategyFilesFromAssembler', () => {
384
+ const appInfo = dummyData.appInfo;
385
+ it('returns one cloning strategy per assembly, with the output name set on the final sequence', () => {
386
+ const outputNames = ['my assembly/1', 'my_assembly_2'];
387
+ const files = getCloningStrategyFilesFromAssembler({ requestedAssemblies: dummyData.requestedAssemblies, appInfo, outputNames });
388
+ expect(files).toHaveLength(2);
389
+ const sanitizedNames = outputNames.map(sanitizeOutputName);
390
+ files.forEach((file, i) => {
391
+ expect(file.name).toBe(`${sanitizedNames[i]}.json`);
392
+ const cloningStrategy = JSON.parse(file.content);
393
+ const originalSequences = dummyData.requestedAssemblies[i].sequences;
394
+ expect(cloningStrategy.sequences).toHaveLength(originalSequences.length);
395
+ expect(cloningStrategy.primers).toEqual(dummyData.requestedAssemblies[i].primers);
396
+ const finalSource = cloningStrategy.sources[cloningStrategy.sources.length - 1];
397
+ expect(finalSource.output_name).toBe(sanitizedNames[i]);
398
+ const finalSequence = cloningStrategy.sequences.find((s) => s.id === finalSource.id);
399
+ expect(finalSequence.file_content.split('\n')[0]).toContain(sanitizedNames[i]);
400
+ expect(file.finalSequence).toEqual(finalSequence);
401
+ });
402
+ });
403
+
404
+ it('keeps the database ids of the sources and does not modify the input', () => {
405
+ const withDatabaseSource = JSON.parse(JSON.stringify(dummyData.requestedAssemblies[0]));
406
+ const firstSource = withDatabaseSource.sources[0];
407
+ withDatabaseSource.sources[0] = { id: firstSource.id, type: 'DatabaseSource', input: [], database_id: 10 };
408
+ const requestedAssemblies = [withDatabaseSource];
409
+ const inputCopy = JSON.parse(JSON.stringify(requestedAssemblies));
410
+
411
+ const [file] = getCloningStrategyFilesFromAssembler({ requestedAssemblies, appInfo, outputNames: ['product'] });
412
+ const cloningStrategy = JSON.parse(file.content);
413
+ expect(cloningStrategy.sources[0]).toEqual({ id: firstSource.id, type: 'DatabaseSource', input: [], database_id: 10 });
414
+ expect(requestedAssemblies).toEqual(inputCopy);
415
+ });
416
+
417
+ it('throws if the number of names does not match the number of assemblies', () => {
418
+ expect(() => getCloningStrategyFilesFromAssembler({ requestedAssemblies: dummyData.requestedAssemblies, appInfo, outputNames: ['a'] })).toThrow();
419
+ });
420
+ });
@@ -1,7 +1,6 @@
1
1
  import { useCallback } from 'react'
2
2
  import { classNameToEndPointMap } from '@opencloning/utils/sourceFunctions'
3
- import useBackendRoute from '../../hooks/useBackendRoute'
4
- import useHttpClient from '../../hooks/useHttpClient'
3
+ import useBackendHttpClient from '../../hooks/useBackendHttpClient'
5
4
  import { arrayCombinations } from './assembler_utils'
6
5
 
7
6
 
@@ -27,9 +26,24 @@ function formatLoadedFile(plasmid, id) {
27
26
  }
28
27
  }
29
28
 
29
+ function formatDatabaseSequence(plasmid, id) {
30
+ return {
31
+ source: {
32
+ id,
33
+ type: 'DatabaseSource',
34
+ input: [],
35
+ database_id: plasmid.database_id,
36
+ },
37
+ sequence: {
38
+ ...plasmid.textFileSequence,
39
+ id,
40
+ },
41
+ plasmid: plasmid,
42
+ }
43
+ }
44
+
30
45
  export const useAssembler = () => {
31
- const httpClient = useHttpClient();
32
- const backendRoute = useBackendRoute();
46
+ const httpClient = useBackendHttpClient();
33
47
 
34
48
  const requestSources = useCallback(async (assemblerOutput) => {
35
49
  const processedOutput = []
@@ -39,9 +53,11 @@ export const useAssembler = () => {
39
53
  try {
40
54
  if (plasmid.type === 'loadedFile') {
41
55
  processedOutput[pos].push(formatLoadedFile(plasmid, pos + 1))
56
+ } else if (plasmid.type === 'databaseSequence') {
57
+ processedOutput[pos].push(formatDatabaseSequence(plasmid, pos + 1))
42
58
  } else {
43
59
  const { source } = plasmid;
44
- const url = backendRoute(classNameToEndPointMap[source.type])
60
+ const url = classNameToEndPointMap[source.type]
45
61
  const {data} = await httpClient.post(url, source)
46
62
  if (data.sources.length !== 1) {
47
63
  console.error('Expected 1 source, got ' + data.sources.length)
@@ -59,7 +75,7 @@ export const useAssembler = () => {
59
75
  }
60
76
  }
61
77
  return processedOutput
62
- }, [ httpClient, backendRoute ])
78
+ }, [ httpClient ])
63
79
 
64
80
 
65
81
  const requestAssemblies = useCallback(async (requestedSources, enzymes=['BsaI']) => {
@@ -67,7 +83,7 @@ export const useAssembler = () => {
67
83
  const assemblies = arrayCombinations(requestedSources);
68
84
  const output = []
69
85
  for (let assembly of assemblies) {
70
- const url = backendRoute('restriction_and_ligation')
86
+ const url = 'restriction_and_ligation'
71
87
  const config = {
72
88
  params: {
73
89
  circular_only: true,
@@ -102,7 +118,7 @@ export const useAssembler = () => {
102
118
  }
103
119
  }
104
120
  return output;
105
- }, [ httpClient, backendRoute ])
121
+ }, [ httpClient ])
106
122
 
107
123
  return { requestSources, requestAssemblies }
108
124
  }
@@ -10,7 +10,7 @@ import { aliasedEnzymesByName } from '@teselagen/sequence-utils';
10
10
  * @param {Array} params.parts - Array of parts
11
11
  * @param {string[]} params.assemblyEnzymes - Assembly enzyme names
12
12
  * @param {Object} params.overhangNames - Mapping of overhangs to names
13
- * @returns {Object} - { linkedPlasmids, setLinkedPlasmids, uploadPlasmids }
13
+ * @returns {Object} - { linkedPlasmids, setLinkedPlasmids, uploadPlasmids, loadPlasmidsFromDatabase }
14
14
  */
15
15
  export function usePlasmidsLogic({ parts, assemblyEnzymes, overhangNames }) {
16
16
  const [linkedPlasmids, setLinkedPlasmidsState] = React.useState([]);
@@ -82,5 +82,23 @@ export function usePlasmidsLogic({ parts, assemblyEnzymes, overhangNames }) {
82
82
  setLinkedPlasmids(plasmids);
83
83
  }, [setLinkedPlasmids]);
84
84
 
85
- return { linkedPlasmids, setLinkedPlasmids, uploadPlasmids };
85
+ // entries: [{ databaseId, name, textFileSequence }], as returned by database.GetSequencesInBulkComponent
86
+ // Topology is not forced to circular, since the sequence is used as stored in the database.
87
+ const loadPlasmidsFromDatabase = React.useCallback(async (entries) => {
88
+ const plasmids = await Promise.all(entries.map(async ({ databaseId, name, textFileSequence }) => {
89
+ try {
90
+ const data = await anyToJson(textFileSequence.file_content, { fileName: 'sequence.gb' });
91
+ if (!data[0]?.success || !data[0].parsedSequence) {
92
+ throw new Error(data[0]?.messages?.join(', ') || 'could not parse sequence');
93
+ }
94
+ const sequenceData = data[0].parsedSequence;
95
+ return { ...sequenceData, appData: { fileName: name, databaseId, textFileSequence, correspondingParts: [], partInfo: [], correspondingPartsNames: [] } };
96
+ } catch (e) {
97
+ throw new Error(`Error loading plasmid ${name} from database: ${e.message}`);
98
+ }
99
+ }));
100
+ setLinkedPlasmids(plasmids);
101
+ }, [setLinkedPlasmids]);
102
+
103
+ return { linkedPlasmids, setLinkedPlasmids, uploadPlasmids, loadPlasmidsFromDatabase };
86
104
  }
@@ -1,3 +1,4 @@
1
+ /* istanbul ignore file -- depends on an external service (GoldenBraid) that is currently down */
1
2
  /* eslint-disable camelcase -- request/response fields match OpenAPI (snake_case) */
2
3
  import React from 'react';
3
4
  import {
@@ -19,8 +20,7 @@ import {
19
20
  TextField,
20
21
  } from '@mui/material';
21
22
  import error2String from '@opencloning/utils/error2String';
22
- import useBackendRoute from '../../hooks/useBackendRoute';
23
- import useHttpClient from '../../hooks/useHttpClient';
23
+ import useBackendHttpClient from '../../hooks/useBackendHttpClient';
24
24
  import { featureToGenbankLocationString, mergeStates } from '@opencloning/utils';
25
25
  import { useDispatch, useStore } from 'react-redux';
26
26
  import { cloningActions } from '@opencloning/store/cloning';
@@ -77,8 +77,7 @@ function categoryPrefixSuffixValid(category, prefix, suffix) {
77
77
  }
78
78
 
79
79
  export default function DomesticateDialog({ open, onClose, initialContext, mainSequenceId }) {
80
- const httpClient = useHttpClient();
81
- const backendRoute = useBackendRoute();
80
+ const httpClient = useBackendHttpClient();
82
81
  const store = useStore();
83
82
 
84
83
  const location = React.useMemo(() => featureToGenbankLocationString(initialContext.annotation), [initialContext]);
@@ -131,7 +130,7 @@ export default function DomesticateDialog({ open, onClose, initialContext, mainS
131
130
  enzymes,
132
131
  };
133
132
  try {
134
- const url = backendRoute('batch_cloning/domesticate');
133
+ const url = 'batch_cloning/domesticate';
135
134
  const { data } = await httpClient.post(url, body);
136
135
 
137
136
  const { mergedState, idShift } = mergeStates(data, store.getState().cloning);
@@ -37,4 +37,10 @@ export default {
37
37
  getSequenceName: null,
38
38
  // Function to get the sequencing files from the database, see docs for what the return value should be
39
39
  getSequencingFiles: null,
40
+ // Component for loading several sequences at once (e.g. in the assembler), calls
41
+ // onSequencesLoaded with an array of { databaseId, name, textFileSequence }
42
+ GetSequencesInBulkComponent: null,
43
+ // Dialog for submitting several cloning strategies at once (e.g. the assembler products), receives
44
+ // { files, onClose, onSuccess }, where files is an array of File (cloning strategy JSON)
45
+ SubmitCloningStrategiesInBulkComponent: null,
40
46
  };