@opencloning/ui 1.4.5 → 1.4.7

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 CHANGED
@@ -1,5 +1,30 @@
1
1
  # @opencloning/ui
2
2
 
3
+ ## 1.4.7
4
+
5
+ ### Patch Changes
6
+
7
+ - [#631](https://github.com/manulera/OpenCloning_frontend/pull/631) [`40dd956`](https://github.com/manulera/OpenCloning_frontend/commit/40dd956d0f6c729ad0690578bf232610a063523f) Thanks [@manulera](https://github.com/manulera)! - Add links to assembler documentation and to create your own syntax in the builder
8
+
9
+ - [#631](https://github.com/manulera/OpenCloning_frontend/pull/631) [`40dd956`](https://github.com/manulera/OpenCloning_frontend/commit/40dd956d0f6c729ad0690578bf232610a063523f) Thanks [@manulera](https://github.com/manulera)! - Add link to documentation for the Assembler, and add deprecation notice for MoClo templates
10
+
11
+ - [#631](https://github.com/manulera/OpenCloning_frontend/pull/631) [`40dd956`](https://github.com/manulera/OpenCloning_frontend/commit/40dd956d0f6c729ad0690578bf232610a063523f) Thanks [@manulera](https://github.com/manulera)! - improve alignment of sequences in the AssemblerPart by improving css
12
+
13
+ - Updated dependencies []:
14
+ - @opencloning/store@1.4.7
15
+ - @opencloning/utils@1.4.7
16
+
17
+ ## 1.4.6
18
+
19
+ ### Patch Changes
20
+
21
+ - [#629](https://github.com/manulera/OpenCloning_frontend/pull/629) [`8820965`](https://github.com/manulera/OpenCloning_frontend/commit/8820965e5a91ec29bb0c9788191123f996a8ecd8) Thanks [@manulera](https://github.com/manulera)! - \* improve category names + clear plasmids when loading new syntax
22
+ - improve handling of files uploaded by user or associated with kit
23
+ - Allow downloading the results from the Assembler.
24
+ - Updated dependencies []:
25
+ - @opencloning/store@1.4.6
26
+ - @opencloning/utils@1.4.6
27
+
3
28
  ## 1.4.5
4
29
 
5
30
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencloning/ui",
3
- "version": "1.4.5",
3
+ "version": "1.4.7",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
@@ -25,8 +25,8 @@
25
25
  "@emotion/styled": "^11.14.0",
26
26
  "@mui/icons-material": "^5.15.17",
27
27
  "@mui/material": "^5.15.17",
28
- "@opencloning/store": "1.4.5",
29
- "@opencloning/utils": "1.4.5",
28
+ "@opencloning/store": "1.4.7",
29
+ "@opencloning/utils": "1.4.7",
30
30
  "@teselagen/bio-parsers": "^0.4.34",
31
31
  "@teselagen/ove": "^0.8.34",
32
32
  "@teselagen/range-utils": "^0.3.20",
@@ -3,6 +3,7 @@ import React from 'react';
3
3
  import { ConfigProvider } from '@opencloning/ui/providers/ConfigProvider';
4
4
  import { AssemblerComponent } from './Assembler';
5
5
 
6
+
6
7
  // Test config
7
8
  const testConfig = {
8
9
  backendUrl: 'http://localhost:8000',
@@ -33,6 +34,25 @@ const dummyResponse = {
33
34
  }
34
35
  ]
35
36
  }
37
+ const dummyResponse2 = {
38
+ sources: [{
39
+ ...dummyResponse.sources[0],
40
+ id: 2,
41
+ type: 'RestrictionAndLigationSource',
42
+ input: [
43
+ {
44
+ sequence: 1,
45
+ },
46
+ {
47
+ sequence: 2,
48
+ }],
49
+ }
50
+ ],
51
+ sequences: [{
52
+ ...dummyResponse.sequences[0],
53
+ id: 2,
54
+ }],
55
+ }
36
56
 
37
57
  // Test data
38
58
  const mockPlasmids = [
@@ -91,12 +111,15 @@ describe('<AssemblerComponent />', () => {
91
111
  win.localStorage.clear();
92
112
  });
93
113
 
114
+ const addAlertStub = cy.stub().as('addAlert')
94
115
  cy.mount(
95
116
  <ConfigProvider config={testConfig}>
96
117
  <AssemblerComponent
97
118
  plasmids={mockPlasmids}
98
119
  categories={mockCategories}
99
120
  assemblyEnzyme="assembly_enzyme"
121
+ addAlert={addAlertStub}
122
+ appInfo={{}}
100
123
  />
101
124
  </ConfigProvider>,
102
125
  );
@@ -203,6 +226,7 @@ describe('<AssemblerComponent />', () => {
203
226
  // Error should be cleared
204
227
  cy.get('.MuiAlert-colorError').should('not.exist');
205
228
  });
229
+
206
230
  it('works in normal case', () => {
207
231
  // Mock successful source fetching
208
232
  cy.intercept('POST', 'http://localhost:8000/repository_id*', {
@@ -217,19 +241,57 @@ describe('<AssemblerComponent />', () => {
217
241
  expect(req.body.source.restriction_enzymes).to.include('assembly_enzyme');
218
242
  req.reply({
219
243
  statusCode: 200,
220
- body: dummyResponse,
244
+ body: dummyResponse2,
221
245
  });
222
246
  }).as('assemblySuccess');
223
247
 
224
248
  // Click submit button
225
249
  cy.get('[data-testid="assembler-submit-button"]').should('be.visible').click();
226
250
  cy.wait('@fetchSourceSuccess');
251
+ cy.wait('@assemblySuccess');
227
252
 
228
253
  // Check that the table displays the name
229
254
  cy.get('[data-testid="assembler-product-table"]').contains('Category 1').should('exist');
230
255
  cy.get('[data-testid="assembler-product-table"]').contains('Category 2').should('exist');
231
256
  cy.get('[data-testid="assembler-product-table"]').contains('Test Plasmid 1').should('exist');
232
257
  cy.get('[data-testid="assembler-product-table"]').contains('Test Plasmid 2').should('exist');
258
+
259
+ // Stub URL.createObjectURL before clicking (it's called with a Blob, not the filename)
260
+ cy.window().then((win) => {
261
+ cy.stub(win.URL, 'createObjectURL').callsFake((blob) => `blob:mock-${blob?.size ?? 0}`).as('createObjectURL');
262
+ });
263
+
264
+ // Click download assemblies button
265
+ cy.get('[data-testid="assembler-download-assemblies-button"]').click();
266
+
267
+ // Check that the download was triggered (createObjectURL receives the zip Blob)
268
+
269
+ cy.get('@createObjectURL').should((stub) => {
270
+ expect(stub).to.have.been.calledOnce;
271
+ expect(stub.firstCall.args[0]).to.be.instanceOf(Blob);
272
+ expect(stub.firstCall.args[0].type).to.equal('application/zip');
273
+ });
274
+
275
+ cy.get('@addAlert').should('have.not.been.called');
276
+
277
+ });
278
+ it('displays error message when downloading assemblies fails', () => {
279
+
280
+ cy.intercept('POST', 'http://localhost:8000/repository_id*', { statusCode: 200, body: dummyResponse }).as('fetchSourceSuccess');
281
+ cy.intercept('POST', 'http://localhost:8000/restriction_and_ligation*', { statusCode: 200, body: dummyResponse }).as('assemblySuccess');
282
+
283
+ cy.get('[data-testid="assembler-submit-button"]').should('be.visible').click();
284
+ cy.wait('@fetchSourceSuccess');
285
+ cy.wait('@assemblySuccess');
286
+
287
+
288
+ cy.get('[data-testid="assembler-download-assemblies-button"]').click();
289
+
290
+ cy.get('@addAlert').should('have.been.calledOnce');
291
+ cy.get('@addAlert').should((stub) => {
292
+ expect(stub.firstCall.args[0].message).to.include('Error downloading assemblies:');
293
+ expect(stub.firstCall.args[0].severity).to.equal('error');
294
+ });
233
295
  });
234
296
  });
235
297
 
@@ -5,19 +5,20 @@ import {
5
5
  } from '@mui/material'
6
6
  import { Clear as ClearIcon, Visibility as VisibilityIcon } from '@mui/icons-material';
7
7
  import { useAssembler } from './useAssembler';
8
- import { useDispatch } from 'react-redux';
8
+ import { useDispatch, useSelector } from 'react-redux';
9
9
  import { cloningActions } from '@opencloning/store/cloning';
10
10
  import AssemblerPart from './AssemblerPart';
11
11
 
12
12
  import useCombinatorialAssembly from './useCombinatorialAssembly';
13
13
  import ExistingSyntaxDialog from './ExistingSyntaxDialog';
14
14
  import error2String from '@opencloning/utils/error2String';
15
- import { categoryFilter } from './assembler_utils';
15
+ import { categoryFilter, downloadAssemblerFilesAsZip, getFilesToExportFromAssembler } from './assembler_utils';
16
16
  import useBackendRoute from '../../hooks/useBackendRoute';
17
17
  import useHttpClient from '../../hooks/useHttpClient';
18
18
  import useAlerts from '../../hooks/useAlerts';
19
19
  import UploadPlasmidsButton from './UploadPlasmidsButton';
20
20
  import { useConfig } from '../../providers';
21
+ import { isEqual } from 'lodash-es';
21
22
 
22
23
 
23
24
  const { setState: setCloningState, setCurrentTab: setCurrentTabAction } = cloningActions;
@@ -108,7 +109,7 @@ function AssemblerBox({ item, index, setCategory, setId, categories, plasmids, a
108
109
  const { key, ...restProps } = props
109
110
  const plasmid = plasmids.find((d) => d.id === option)
110
111
  return (
111
- <MenuItem key={key} {...restProps} sx={{ backgroundColor: plasmid.type === 'loadedFile' ? 'success.light' : undefined }}>
112
+ <MenuItem key={key} {...restProps} sx={{ backgroundColor: plasmid.userUploaded === true ? '#dcedc8' : undefined }}>
112
113
  {formatItemName(plasmid)}
113
114
  </MenuItem>
114
115
  )}}
@@ -123,7 +124,7 @@ function AssemblerBox({ item, index, setCategory, setId, categories, plasmids, a
123
124
  )
124
125
  }
125
126
 
126
- export function AssemblerComponent({ plasmids, categories, assemblyEnzyme }) {
127
+ export function AssemblerComponent({ plasmids, categories, assemblyEnzyme, addAlert, appInfo }) {
127
128
 
128
129
  const [requestedAssemblies, setRequestedAssemblies] = React.useState([])
129
130
  const [errorMessage, setErrorMessage] = React.useState('')
@@ -137,7 +138,7 @@ export function AssemblerComponent({ plasmids, categories, assemblyEnzyme }) {
137
138
  const { assembly, setCategory, setId, expandedAssemblies, assemblyComplete, canBeSubmitted, currentCategories } = useCombinatorialAssembly({ onValueChange: clearAssemblySelection, categories, plasmids })
138
139
  const { requestSources, requestAssemblies } = useAssembler()
139
140
 
140
- const onSubmitAssembly = async () => {
141
+ const onSubmitAssembly = React.useCallback(async () => {
141
142
  clearAssemblySelection()
142
143
  const selectedPlasmids = assembly.map(({ plasmidIds }) => plasmidIds.map((id) => (plasmids.find((item) => item.id === id))))
143
144
 
@@ -159,7 +160,20 @@ export function AssemblerComponent({ plasmids, categories, assemblyEnzyme }) {
159
160
  } finally {
160
161
  setLoadingMessage(false)
161
162
  }
162
- }
163
+ }, [assemblyEnzyme, assembly, plasmids, requestSources, requestAssemblies, clearAssemblySelection])
164
+
165
+ const onDownloadAssemblies = React.useCallback(async () => {
166
+ try {
167
+ const files = getFilesToExportFromAssembler({requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories, appInfo})
168
+ await downloadAssemblerFilesAsZip(files);
169
+ } catch (error) {
170
+ console.error('Error downloading assemblies:', error);
171
+ addAlert({
172
+ message: `Error downloading assemblies: ${error.message}`,
173
+ severity: 'error',
174
+ });
175
+ }
176
+ }, [requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories, appInfo, addAlert])
163
177
 
164
178
  const options = React.useMemo(() => assemblyComplete ? assembly : [...assembly, { category: '', plasmidIds: [] }], [assembly, assemblyComplete])
165
179
 
@@ -171,17 +185,26 @@ export function AssemblerComponent({ plasmids, categories, assemblyEnzyme }) {
171
185
  <AssemblerBox key={index} {...{item, index, setCategory, setId, categories, plasmids, assembly}} />
172
186
  )}
173
187
  </Stack>
174
- {canBeSubmitted && <>
175
- <Button
176
- sx={{ p: 2, px: 4, my: 2, fontSize: '1.2rem' }}
188
+ <Box sx={{ display: 'flex', justifyContent: 'center', gap: 2, my: 2 }}>
189
+ {canBeSubmitted && <>
190
+ <Button
191
+ sx={{ p: 2, fontSize: '1.2rem' }}
192
+ variant="contained"
193
+ color="primary"
194
+ data-testid="assembler-submit-button"
195
+ onClick={onSubmitAssembly}
196
+ disabled={Boolean(loadingMessage)}>
197
+ {loadingMessage ? <><CircularProgress /> {loadingMessage}</> : 'Submit'}
198
+ </Button>
199
+ </>}
200
+ {requestedAssemblies.length > 0 && <Button
201
+ color="success"
177
202
  variant="contained"
178
- color="primary"
179
- data-testid="assembler-submit-button"
180
- onClick={onSubmitAssembly}
181
- disabled={Boolean(loadingMessage)}>
182
- {loadingMessage ? <><CircularProgress /> {loadingMessage}</> : 'Submit'}
183
- </Button>
184
- </>}
203
+ data-testid="assembler-download-assemblies-button"
204
+ sx={{ p: 2, fontSize: '1.2rem' }}
205
+ onClick={onDownloadAssemblies}>Download Assemblies
206
+ </Button>}
207
+ </Box>
185
208
  {errorMessage && <Alert severity="error" sx={{ my: 2, maxWidth: 300, margin: 'auto', fontSize: '1.2rem' }}>{errorMessage}</Alert>}
186
209
  {requestedAssemblies.length > 0 &&
187
210
  <AssemblerProductTable {...{requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories}} />
@@ -196,10 +219,10 @@ function displayNameFromCategory(category) {
196
219
  if (category.name) {
197
220
  name = category.name
198
221
  if (category.info)
199
- name += ` (${category.info}) `
222
+ name += ` (${category.info})`
200
223
  }
201
224
  if (category.left_name && category.right_name) {
202
- name += `${category.left_name}_${category.right_name}`
225
+ name += ` (${category.left_name}_${category.right_name})`
203
226
  }
204
227
  if (name === '') {
205
228
  name = category.key
@@ -238,7 +261,7 @@ function categoriesFromSyntaxAndPlasmids(syntax, plasmids) {
238
261
  return newCategories
239
262
  }
240
263
 
241
- function LoadSyntaxButton({ setSyntax, addPlasmids }) {
264
+ function LoadSyntaxButton({ setSyntax, addPlasmids, clearPlasmids }) {
242
265
  const [existingSyntaxDialogOpen, setExistingSyntaxDialogOpen] = React.useState(false)
243
266
  const httpClient = useHttpClient();
244
267
  const { staticContentPath } = useConfig();
@@ -249,6 +272,7 @@ function LoadSyntaxButton({ setSyntax, addPlasmids }) {
249
272
  try {
250
273
  await httpClient.post(url, syntax);
251
274
  setSyntax(syntax)
275
+ clearPlasmids()
252
276
  addPlasmids(plasmids)
253
277
  } catch (error) {
254
278
  addAlert({
@@ -256,10 +280,15 @@ function LoadSyntaxButton({ setSyntax, addPlasmids }) {
256
280
  severity: 'error',
257
281
  });
258
282
  }
259
- }, [setSyntax, addPlasmids, httpClient, backendRoute, addAlert])
283
+ }, [setSyntax, addPlasmids, clearPlasmids, httpClient, backendRoute, addAlert])
260
284
  return <>
261
285
  <Button color="success" onClick={() => setExistingSyntaxDialogOpen(true)}>Load Syntax</Button>
262
- {existingSyntaxDialogOpen && <ExistingSyntaxDialog staticContentPath={staticContentPath} onClose={() => setExistingSyntaxDialogOpen(false)} onSyntaxSelect={onSyntaxSelect}/>}
286
+ {existingSyntaxDialogOpen && <ExistingSyntaxDialog
287
+ staticContentPath={staticContentPath}
288
+ onClose={() => setExistingSyntaxDialogOpen(false)}
289
+ onSyntaxSelect={onSyntaxSelect}
290
+ displayCreateYourOwnMessage={true}
291
+ />}
263
292
  </>
264
293
  }
265
294
 
@@ -268,6 +297,8 @@ function LoadSyntaxButton({ setSyntax, addPlasmids }) {
268
297
  function Assembler() {
269
298
  const [syntax, setSyntax] = React.useState(null);
270
299
  const [plasmids, setPlasmids] = React.useState([])
300
+ const { addAlert } = useAlerts();
301
+ const appInfo = useSelector(({ cloning }) => cloning.appInfo, isEqual);
271
302
 
272
303
  const categories = React.useMemo(() => {
273
304
  return categoriesFromSyntaxAndPlasmids(syntax, plasmids)
@@ -280,21 +311,25 @@ function Assembler() {
280
311
  })
281
312
  }, [])
282
313
 
314
+ const clearLoadedPlasmids = React.useCallback(() => {
315
+ setPlasmids(prev => prev.filter((plasmid) => plasmid.userUploaded !== true))
316
+ }, [])
317
+
283
318
  const clearPlasmids = React.useCallback(() => {
284
- setPlasmids(prev => prev.filter((plasmid) => plasmid.type !== 'loadedFile'))
319
+ setPlasmids([])
285
320
  }, [])
286
321
 
287
322
  return (
288
323
  <>
289
324
  <Alert severity="warning" sx={{ maxWidth: '400px', margin: 'auto', fontSize: '.9rem', mb: 2 }}>
290
- The Assembler is experimental. Use with caution.
325
+ The Assembler is experimental. Use with caution. Visit <a href="https://docs.opencloning.org/assembler" target="_blank">the documentation</a> for more information.
291
326
  </Alert>
292
327
  <ButtonGroup>
293
- <LoadSyntaxButton setSyntax={setSyntax} addPlasmids={addPlasmids} />
328
+ <LoadSyntaxButton setSyntax={setSyntax} addPlasmids={addPlasmids} clearPlasmids={clearPlasmids} />
294
329
  {syntax && <UploadPlasmidsButton addPlasmids={addPlasmids} syntax={syntax} />}
295
- {syntax && <Button color="error" onClick={clearPlasmids}>Remove uploaded plasmids</Button>}
330
+ {syntax && <Button color="error" onClick={clearLoadedPlasmids}>Remove uploaded plasmids</Button>}
296
331
  </ButtonGroup>
297
- {syntax && <AssemblerComponent plasmids={plasmids} syntax={syntax} categories={categories} assemblyEnzyme={syntax.assemblyEnzyme} />}
332
+ {syntax && <AssemblerComponent plasmids={plasmids} syntax={syntax} categories={categories} assemblyEnzyme={syntax.assemblyEnzyme} addAlert={addAlert} appInfo={appInfo} />}
298
333
  </>
299
334
  )
300
335
  }
@@ -2,10 +2,10 @@ import React from 'react'
2
2
  import { Dialog, DialogTitle, DialogContent, List, ListItem, ListItemText, ListItemButton, Alert, Button, Box, ButtonGroup, Accordion, AccordionSummary, AccordionDetails } from '@mui/material'
3
3
  import getHttpClient from '@opencloning/utils/getHttpClient';
4
4
  import RequestStatusWrapper from '../form/RequestStatusWrapper';
5
- import { useConfig } from '../../providers';
6
5
  import ServerStaticFileSelect from '../form/ServerStaticFileSelect';
7
6
  import { readSubmittedTextFile } from '@opencloning/utils/readNwrite';
8
7
  import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material';
8
+ import { IconButton } from '@mui/material';
9
9
 
10
10
  const httpClient = getHttpClient();
11
11
  const baseURL = 'https://assets.opencloning.org/syntaxes/syntaxes/';
@@ -71,7 +71,7 @@ function SyntaxListItem({ syntax, onSyntaxClick }) {
71
71
  )
72
72
  }
73
73
 
74
- function ExistingSyntaxDialog({ staticContentPath, onClose, onSyntaxSelect }) {
74
+ function ExistingSyntaxDialog({ staticContentPath, onClose, onSyntaxSelect, displayCreateYourOwnMessage = false }) {
75
75
  const [syntaxes, setSyntaxes] = React.useState([]);
76
76
  const [connectAttempt, setConnectAttempt] = React.useState(0);
77
77
  const [requestStatus, setRequestStatus] = React.useState({ status: 'loading' });
@@ -139,6 +139,10 @@ function ExistingSyntaxDialog({ staticContentPath, onClose, onSyntaxSelect }) {
139
139
  {syntaxes.map((syntax) => (
140
140
  <SyntaxListItem key={syntax.path} syntax={syntax} onSyntaxClick={onSyntaxClick} />
141
141
  ))}
142
+ {displayCreateYourOwnMessage && <ListItem>
143
+ <ListItemText primary="🔎 Can't find your favourite syntax?" secondary="Add it yourself! It's very easy!" />
144
+ <Button type="a" color="success" href="https://syntax.opencloning.org" target="_blank">Create a syntax</Button>
145
+ </ListItem>}
142
146
  </List>
143
147
  </RequestStatusWrapper>
144
148
  <Box sx={{ mb: 2 }}>
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { Button, Dialog, DialogTitle, DialogContent, DialogActions, Alert, Box } from '@mui/material';
2
+ import { Button, Dialog, DialogTitle, DialogContent, DialogActions, Alert, Box, ButtonGroup } from '@mui/material';
3
3
  import { useConfig } from '../../providers';
4
4
  import ServerStaticFileSelect from '../form/ServerStaticFileSelect';
5
5
  import { usePlasmidsLogic } from './usePlasmidsLogic';
@@ -26,6 +26,7 @@ function formatPlasmid(sequenceData) {
26
26
  key: `${left_overhang}-${right_overhang}`,
27
27
  sequenceData,
28
28
  genbankString: jsonToGenbank(sequenceData),
29
+ userUploaded: true,
29
30
  };
30
31
 
31
32
  }
@@ -94,9 +95,9 @@ function UploadPlasmidsButton({ addPlasmids, syntax }) {
94
95
  },
95
96
  }}
96
97
  >
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>
98
+ <DialogActions sx={{ justifyContent: 'center', position: 'sticky', top: 0, zIndex: 99, background: '#fff', py: 2 }}>
99
+ <Button sx={{ p: 1.5, fontSize: '1rem' }} disabled={validPlasmids.length === 0} variant="contained" color="success" onClick={handleImportValidPlasmids}>Import valid plasmids</Button>
100
+ <Button sx={{ p: 1.5, fontSize: '1rem' }} variant="contained" color="error" onClick={() => setLinkedPlasmids([])}>Cancel</Button>
100
101
  </DialogActions>
101
102
  {invalidPlasmids.length > 0 && (
102
103
  <Box data-testid="invalid-plasmids-box">
@@ -2,6 +2,9 @@ import { isRangeWithinRange } from '@teselagen/range-utils';
2
2
  import { getComplementSequenceString, getAminoAcidFromSequenceTriplet, getDigestFragmentsForRestrictionEnzymes, getReverseComplementSequenceString } from '@teselagen/sequence-utils';
3
3
  import { allSimplePaths } from 'graphology-simple-path';
4
4
  import { openCycleAtNode } from './graph_utils';
5
+ import { downloadBlob, formatStateForJsonExport, getZipFileBlob } from '@opencloning/utils/readNwrite';
6
+ import { getGraftSequenceId } from '@opencloning/utils/network';
7
+ import { TextReader} from '@zip.js/zip.js';
5
8
 
6
9
  export function tripletsToTranslation(triplets) {
7
10
  if (!triplets) return ''
@@ -146,3 +149,45 @@ export function categoryFilter(category, categories, previousCategoryId) {
146
149
  const previousCategory = categories.find((category) => category.id === previousCategoryId)
147
150
  return previousCategory?.right_overhang === category.left_overhang
148
151
  }
152
+
153
+ export function getFilesToExportFromAssembler({requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories, appInfo}) {
154
+ const files2Export = [];
155
+ const categoryNames = ['Assembly', ...currentCategories.map(categoryId => categories.find(c => c.id === categoryId).displayName)];
156
+ const assemblyNames = expandedAssemblies.map((assembly, index) => {
157
+ return [index + 1, ...assembly.map(part => plasmids.find(p => p.id === part).plasmid_name)];
158
+ });
159
+ for (const delimiter of ['\t', ',']) {
160
+ const tableHeader = categoryNames.join(delimiter);
161
+ const tableRows = assemblyNames.map(assemblyName => assemblyName.join(delimiter));
162
+ const table = [tableHeader, ...tableRows].join('\n');
163
+ const extension = delimiter === '\t' ? 'tsv' : 'csv';
164
+ files2Export.push({
165
+ name: `assemblies.${extension}`,
166
+ content: table,
167
+ });
168
+ }
169
+
170
+ for (let i = 0; i < requestedAssemblies.length; i++) {
171
+ const name = `${String(i + 1).padStart(3, '0')}_${assemblyNames[i].slice(1).join('+')}`;
172
+ const requestedAssembly = requestedAssemblies[i];
173
+ const jsonContent = formatStateForJsonExport({...requestedAssembly, appInfo});
174
+ files2Export.push({
175
+ name: `${name}.json`,
176
+ content: JSON.stringify(jsonContent, null, 2),
177
+ });
178
+ const finalSequenceId = getGraftSequenceId(requestedAssembly)
179
+ const finalSequence = requestedAssembly.sequences.find(s => s.id === finalSequenceId)
180
+ files2Export.push({
181
+ name: `${name}.gbk`,
182
+ content: finalSequence.file_content,
183
+ });
184
+ }
185
+ return files2Export;
186
+ }
187
+
188
+ export async function downloadAssemblerFilesAsZip(files) {
189
+ const files2write = files.map(({name, content}) => ({name, reader: new TextReader(content)}));
190
+
191
+ const blob = await getZipFileBlob(files2write);
192
+ downloadBlob(blob, 'assemblies.zip');
193
+ }
@@ -1,8 +1,8 @@
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, getSimplifiedDigestFragments } from "./assembler_utils";
3
+ import { assignSequenceToSyntaxPart, simplifyDigestFragment, reverseComplementSimplifiedDigestFragment, tripletsToTranslation, partDataToDisplayData, arrayCombinations, getFilesToExportFromAssembler } from "./assembler_utils";
4
4
  import { partsToEdgesGraph } from "./graph_utils";
5
- import { genbankToJson } from '@teselagen/bio-parsers';
5
+
6
6
 
7
7
  const sequenceBsaI = 'tgggtctcaTACTagagtcacacaggactactaAATGagagacctac';
8
8
  const sequenceBsaI2 = 'tgggtctcaAATGagagtcacacaggactactaAGGTagagacctac'
@@ -257,3 +257,57 @@ describe('arrayCombinations', () => {
257
257
  expect(arrayCombinations([[1, 2], [3, 4], [5, 6]])).toEqual([[1, 3, 5], [1, 3, 6], [1, 4, 5], [1, 4, 6], [2, 3, 5], [2, 3, 6], [2, 4, 5], [2, 4, 6]]);
258
258
  });
259
259
  });
260
+
261
+
262
+ const goldenGateCloningStrategy = JSON.parse(fs.readFileSync('apps/opencloning/public/examples/golden_gate.json', 'utf8'));
263
+ const gatewayCloningStrategy = JSON.parse(fs.readFileSync('apps/opencloning/public/examples/gateway.json', 'utf8'));
264
+
265
+ const dummyData = {
266
+ requestedAssemblies: [ goldenGateCloningStrategy, gatewayCloningStrategy],
267
+ expandedAssemblies: [[1, 5, 7], [1, 2, 3]],
268
+ plasmids: [
269
+ {id: 1, plasmid_name: 'p1'},
270
+ {id: 2, plasmid_name: 'p2'},
271
+ {id: 3, plasmid_name: 'p3'},
272
+ {id: 4, plasmid_name: 'p4'},
273
+ {id: 5, plasmid_name: 'p5'},
274
+ {id: 6, plasmid_name: 'p6'},
275
+ {id: 7, plasmid_name: 'p7'},
276
+ ],
277
+ currentCategories: [1, 2, 3],
278
+ categories: [
279
+ {id: 1, displayName: 'Category 1'},
280
+ {id: 2, displayName: 'Category 2'},
281
+ {id: 3, displayName: 'Category 3'},
282
+ ],
283
+ appInfo: {backendVersion: '0.5.1', schemaVersion: '0.4.9', frontendVersion: '__VERSION__'},
284
+ };
285
+
286
+
287
+
288
+ describe('getZipFileFromAssemblies', () => {
289
+ it('returns a zip file', () => {
290
+ const files = getFilesToExportFromAssembler(dummyData);
291
+ expect(files[0].name).toBe('assemblies.tsv');
292
+ expect(files[0].content).toBe('Assembly\tCategory 1\tCategory 2\tCategory 3\n1\tp1\tp5\tp7\n2\tp1\tp2\tp3');
293
+ expect(files[1].name).toBe('assemblies.csv');
294
+ expect(files[1].content).toBe('Assembly,Category 1,Category 2,Category 3\n1,p1,p5,p7\n2,p1,p2,p3');
295
+
296
+ const fileNames = ['001_p1+p5+p7', '002_p1+p2+p3'];
297
+ for (let i = 0; i < 2; i++) {
298
+ const fileIndex1 = i * 2 + 2;
299
+ const fileIndex2 = fileIndex1 + 1;
300
+ expect(files[fileIndex1].name).toBe(`${fileNames[i]}.json`);
301
+ const cloningStrategy = JSON.parse(files[fileIndex1].content);
302
+ expect(cloningStrategy.sequences).toEqual(dummyData.requestedAssemblies[i].sequences);
303
+ expect(cloningStrategy.sources).toEqual(dummyData.requestedAssemblies[i].sources);
304
+ expect(cloningStrategy.primers).toEqual(dummyData.requestedAssemblies[i].primers);
305
+
306
+ expect(files[fileIndex2].name).toBe(`${fileNames[i]}.gbk`);
307
+ const genbankContent = files[fileIndex2].content;
308
+ expect(genbankContent).toBe(dummyData.requestedAssemblies[i].sequences[dummyData.requestedAssemblies[i].sequences.length - 1].file_content);
309
+ }
310
+
311
+
312
+ })
313
+ })
@@ -14,10 +14,6 @@
14
14
  min-height: 1em;
15
15
  }
16
16
 
17
- .overhang div {
18
- padding-left: calc(2px + var(--border-width));
19
- padding-right: 2px;
20
- }
21
17
 
22
18
  .container {
23
19
  display: flex;
@@ -57,19 +53,19 @@
57
53
  .overhang .watson {
58
54
  border-left: var(--border-width) var(--border-style) var(--border-color);
59
55
  border-bottom: var(--border-width) var(--border-style) var(--border-color);
60
- padding-left: 2px;
61
56
  }
62
57
 
63
58
  .overhang .crick {
64
59
  border-right: var(--border-width) var(--border-style) var(--border-color);
65
60
  }
66
61
 
67
- .insideLeft {
68
- margin-left: .2em;
62
+ .inside .watson {
63
+ padding-bottom: var(--border-width);
69
64
  }
70
65
 
71
- .insideRight {
72
- margin-right: .2em;
66
+ .overhang {
67
+ padding-left: var(--border-width);
68
+ padding-right: var(--border-width);
73
69
  }
74
70
 
75
71
  .overhang.left .watson {
@@ -79,3 +75,12 @@
79
75
  .overhang.right .crick {
80
76
  font-weight: bold;
81
77
  }
78
+
79
+ .overhang .top {
80
+ padding-left: var(--border-width);
81
+ padding-right: var(--border-width);
82
+ }
83
+
84
+ .overhang .crick {
85
+ padding-left: var(--border-width);
86
+ }
@@ -27,6 +27,8 @@ function SelectTemplateDialog({ onClose, open }) {
27
27
  return (
28
28
  <Dialog open={open} onClose={() => onClose('')} className="load-template-dialog">
29
29
  <DialogTitle>Load a template</DialogTitle>
30
+ <Alert severity="warning" sx={{ maxWidth: '400px', margin: 'auto', fontSize: '.9rem', mb: 2 }}>
31
+ MoClo templates will soon be replaced by the new Assembler. Go to the Assembler tab to learn more.</Alert>
30
32
  {!templates
31
33
  && (
32
34
  <DialogContent>
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Version placeholder - replaced at publish time via prepack script
2
- export const version = "1.4.5";
2
+ export const version = "1.4.7";