@opencloning/ui 1.5.2 → 1.5.3

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,20 @@
1
1
  # @opencloning/ui
2
2
 
3
+ ## 1.5.3
4
+
5
+ ### Patch Changes
6
+
7
+ - [#655](https://github.com/manulera/OpenCloning_frontend/pull/655) [`b34e83f`](https://github.com/manulera/OpenCloning_frontend/commit/b34e83f0b8813de7744d3d71ad779be0df2f75cf) Thanks [@manulera](https://github.com/manulera)! - Changes to Assembler naming:
8
+
9
+ - Handle slashes in downloaded file names (were being turned into subfolders)
10
+ - Allow users to change names of outputs in assembler
11
+
12
+ - [#655](https://github.com/manulera/OpenCloning_frontend/pull/655) [`b34e83f`](https://github.com/manulera/OpenCloning_frontend/commit/b34e83f0b8813de7744d3d71ad779be0df2f75cf) Thanks [@manulera](https://github.com/manulera)! - In Assembler tab, allow users to see an overview of the Syntax
13
+
14
+ - Updated dependencies [[`b34e83f`](https://github.com/manulera/OpenCloning_frontend/commit/b34e83f0b8813de7744d3d71ad779be0df2f75cf)]:
15
+ - @opencloning/utils@1.5.3
16
+ - @opencloning/store@1.5.3
17
+
3
18
  ## 1.5.2
4
19
 
5
20
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencloning/ui",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
@@ -25,8 +25,9 @@
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.5.2",
29
- "@opencloning/utils": "1.5.2",
28
+ "@mui/x-data-grid": "^8.25.0",
29
+ "@opencloning/store": "1.5.3",
30
+ "@opencloning/utils": "1.5.3",
30
31
  "@teselagen/bio-parsers": "^0.4.34",
31
32
  "@teselagen/ove": "^0.8.34",
32
33
  "@teselagen/range-utils": "^0.3.20",
@@ -84,6 +84,20 @@ const mockPlasmids = [
84
84
  repository_id: '67890',
85
85
  },
86
86
  },
87
+ {
88
+ id: 3,
89
+ plasmid_name: 'Test Plasmid 3',
90
+ left_overhang: 'CCCT',
91
+ right_overhang: 'AACG',
92
+ key: 'CCCT-AACG',
93
+ type: 'AddgeneIdSource',
94
+ source: {
95
+ id: 3,
96
+ type: 'AddgeneIdSource',
97
+ input: [],
98
+ repository_id: '11111',
99
+ },
100
+ },
87
101
  ];
88
102
 
89
103
  const mockCategories = [
@@ -296,5 +310,55 @@ describe('<AssemblerComponent />', () => {
296
310
  expect(stub.firstCall.args[0].severity).to.equal('error');
297
311
  });
298
312
  });
313
+
314
+ it('disables download when output names have duplicates', () => {
315
+ cy.get('[data-testid="plasmid-select"]').first().within(() => { cy.get('input').click(); });
316
+ cy.get('li').contains('Test Plasmid 3').click();
317
+ cy.intercept('POST', 'http://localhost:8000/repository_id*', { statusCode: 200, body: dummyResponse }).as('fetchSourceSuccess');
318
+ cy.intercept('POST', 'http://localhost:8000/restriction_and_ligation*', { statusCode: 200, body: dummyResponse2 }).as('assemblySuccess');
319
+
320
+ cy.get('[data-testid="assembler-submit-button"]').should('be.visible').click();
321
+ cy.wait('@fetchSourceSuccess');
322
+ cy.wait('@assemblySuccess');
323
+
324
+ cy.get('[data-testid="assembler-product-table"]').should('exist');
325
+ cy.get('[data-testid="assembler-download-assemblies-button"]').should('not.be.disabled');
326
+
327
+ cy.get('[data-testid="assembler-product-table-edit-button"]').first().click();
328
+ cy.get('[role="dialog"]').find('input').clear().type('duplicate_name');
329
+ cy.get('[role="dialog"]').contains('Save').click();
330
+
331
+ cy.get('[data-testid="assembler-product-table-edit-button"]').last().click();
332
+ cy.get('[role="dialog"]').find('input').clear().type('duplicate_name');
333
+ cy.get('[role="dialog"]').contains('Save').click();
334
+
335
+ cy.get('[data-testid="assembler-download-assemblies-button"]').should('be.disabled');
336
+ });
337
+
338
+ it('disables download when output name is empty', () => {
339
+ cy.intercept('POST', 'http://localhost:8000/repository_id*', { statusCode: 200, body: dummyResponse }).as('fetchSourceSuccess');
340
+ cy.intercept('POST', 'http://localhost:8000/restriction_and_ligation*', { statusCode: 200, body: dummyResponse2 }).as('assemblySuccess');
341
+
342
+ cy.get('[data-testid="assembler-submit-button"]').should('be.visible').click();
343
+ cy.wait('@fetchSourceSuccess');
344
+ cy.wait('@assemblySuccess');
345
+
346
+ cy.get('[data-testid="assembler-product-table-edit-button"]').first().click();
347
+ cy.get('[role="dialog"]').find('input').clear();
348
+ cy.get('[role="dialog"]').contains('Save').click();
349
+
350
+ cy.get('[data-testid="assembler-download-assemblies-button"]').should('be.disabled');
351
+ });
352
+
353
+ it('enables download when output names are valid and unique', () => {
354
+ cy.intercept('POST', 'http://localhost:8000/repository_id*', { statusCode: 200, body: dummyResponse }).as('fetchSourceSuccess');
355
+ cy.intercept('POST', 'http://localhost:8000/restriction_and_ligation*', { statusCode: 200, body: dummyResponse2 }).as('assemblySuccess');
356
+
357
+ cy.get('[data-testid="assembler-submit-button"]').should('be.visible').click();
358
+ cy.wait('@fetchSourceSuccess');
359
+ cy.wait('@assemblySuccess');
360
+
361
+ cy.get('[data-testid="assembler-download-assemblies-button"]').should('not.be.disabled');
362
+ });
299
363
  });
300
364
 
@@ -1,74 +1,155 @@
1
1
  import React from 'react'
2
2
  import {
3
- Alert, Autocomplete, Box, Button, CircularProgress, FormControl, IconButton, InputAdornment, InputLabel, MenuItem, Select, Stack, Table,
4
- TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, ButtonGroup
3
+ Alert, Autocomplete, Box, Button, CircularProgress, FormControl, IconButton, InputAdornment, InputLabel, MenuItem, Select, Stack, TextField, ButtonGroup,
4
+ DialogTitle,
5
+ Dialog,
6
+ DialogContent,
7
+ FormControlLabel,
8
+ Switch
5
9
  } from '@mui/material'
6
- import { Clear as ClearIcon, Visibility as VisibilityIcon } from '@mui/icons-material';
10
+ import { Clear as ClearIcon, Edit as EditIcon, Visibility as VisibilityIcon } from '@mui/icons-material';
11
+ import { DataGrid, GridActionsCellItem } from '@mui/x-data-grid';
7
12
  import { useAssembler } from './useAssembler';
8
13
  import { useDispatch, useSelector } from 'react-redux';
9
14
  import { cloningActions } from '@opencloning/store/cloning';
10
15
  import AssemblerPart from './AssemblerPart';
16
+ import EditTextDialog from '../form/EditTextDialog';
11
17
 
12
18
  import useCombinatorialAssembly from './useCombinatorialAssembly';
13
19
  import ExistingSyntaxDialog from './ExistingSyntaxDialog';
14
20
  import error2String from '@opencloning/utils/error2String';
15
- import { categoryFilter, downloadAssemblerFilesAsZip, getFilesToExportFromAssembler } from './assembler_utils';
21
+ import { categoryFilter, downloadAssemblerFilesAsZip, getFilesToExportFromAssembler, getDefaultAssemblyOutputName, MAX_OUTPUT_NAME_LENGTH, sanitizeOutputName } from './assembler_utils';
16
22
  import useBackendRoute from '../../hooks/useBackendRoute';
17
23
  import useHttpClient from '../../hooks/useHttpClient';
18
24
  import useAlerts from '../../hooks/useAlerts';
19
25
  import UploadPlasmidsButton from './UploadPlasmidsButton';
20
26
  import { useConfig } from '../../providers';
21
27
  import { isEqual } from 'lodash-es';
28
+ import SyntaxOverviewTable from './SyntaxOverviewTable';
29
+ import { graphToMSA, partsToGraph } from './graph_utils';
22
30
 
23
31
 
24
32
  const { setState: setCloningState, setCurrentTab: setCurrentTabAction } = cloningActions;
25
33
 
26
-
27
34
  function formatItemName(item) {
28
35
  // Fallback in case the item is not found (while updating list)
29
36
  return item ? `${item.plasmid_name}` : '-'
30
37
  }
31
38
 
32
- function AssemblerProductTable({ requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories }) {
39
+ function isRowInvalid(rowIndex, assemblyOutputNames) {
40
+ const name = assemblyOutputNames[rowIndex] ?? '';
41
+ const trimmed = name.trim();
42
+ const isEmpty = trimmed.length === 0;
43
+ const isTooLong = name.length > MAX_OUTPUT_NAME_LENGTH;
44
+ const isDuplicate = assemblyOutputNames.filter((n) => n === name).length > 1;
45
+ return isEmpty || isTooLong || isDuplicate;
46
+ }
47
+
48
+ function AssemblerProductTable({
49
+ requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories,
50
+ assemblyOutputNames, onOutputNameChange,
51
+ }) {
52
+ const dispatch = useDispatch();
53
+ const [editDialog, setEditDialog] = React.useState({ open: false, rowIndex: null, value: '' });
33
54
 
34
- const dispatch = useDispatch()
35
55
  const handleViewAssembly = (index) => {
36
- const newState = requestedAssemblies[index]
37
- dispatch(setCloningState(newState))
38
- dispatch(setCurrentTabAction(0))
39
- }
56
+ const newState = requestedAssemblies[index];
57
+ dispatch(setCloningState(newState));
58
+ dispatch(setCurrentTabAction(0));
59
+ };
60
+
61
+ const handleEdit = (rowIndex) => () => {
62
+ setEditDialog({ open: true, rowIndex, value: assemblyOutputNames[rowIndex] ?? '' });
63
+ };
64
+
65
+ const handleEditSave = (newValue) => {
66
+ if (editDialog.rowIndex !== null) {
67
+ onOutputNameChange(editDialog.rowIndex, sanitizeOutputName(newValue));
68
+ }
69
+ setEditDialog({ open: false, rowIndex: null, value: '' });
70
+ };
71
+
72
+ const rows = expandedAssemblies.map((parts, rowIndex) => ({
73
+ id: rowIndex,
74
+ outputName: assemblyOutputNames[rowIndex] ?? '',
75
+ rowIndex,
76
+ parts,
77
+ }));
78
+
79
+ const columns = [
80
+ {
81
+ field: 'actions',
82
+ type: 'actions',
83
+ headerName: '',
84
+ width: 60,
85
+ getActions: (params) => [
86
+ <GridActionsCellItem
87
+ key="view"
88
+ icon={<VisibilityIcon />}
89
+ label="View"
90
+ onClick={() => handleViewAssembly(params.row.rowIndex)}
91
+ data-testid="assembler-product-table-view-button"
92
+ />,
93
+ ],
94
+ },
95
+ {
96
+ field: 'outputName',
97
+ headerName: 'Output name',
98
+ flex: 1,
99
+ minWidth: 200,
100
+ renderCell: (params) => (
101
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
102
+ <Box sx={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
103
+ {params.value || 'Click to edit...'}
104
+ </Box>
105
+ <IconButton size="small" onClick={(e) => { e.stopPropagation(); handleEdit(params.row.rowIndex)(); }} data-testid="assembler-product-table-edit-button">
106
+ <EditIcon fontSize="small" />
107
+ </IconButton>
108
+ </Box>
109
+ ),
110
+ },
111
+ ...currentCategories.map((categoryId, idx) => {
112
+ const category = categories.find((c) => c.id === categoryId);
113
+ return {
114
+ field: `category_${categoryId}`,
115
+ headerName: category?.displayName ?? '',
116
+ flex: 1,
117
+ valueGetter: (value, row) => formatItemName(plasmids.find((d) => d.id === row.parts[idx])),
118
+ };
119
+ }),
120
+ ];
121
+
40
122
  return (
41
- <TableContainer sx={{ '& td': { fontSize: '1.2rem' }, '& th': { fontSize: '1.2rem' } }}>
42
- <Table size="small" data-testid="assembler-product-table">
43
- <TableHead>
44
- <TableRow>
45
- <TableCell padding="checkbox" />
46
- {currentCategories.map(category => (
47
- <TableCell key={category} sx={{ fontWeight: 'bold' }}>
48
- {categories.find((c) => c.id === category)?.displayName}
49
- </TableCell>
50
- ))}
51
- </TableRow>
52
- </TableHead>
53
- <TableBody>
54
- {expandedAssemblies.map((parts, rowIndex) => (
55
- <TableRow key={rowIndex}>
56
- <TableCell padding="checkbox">
57
- <IconButton data-testid="assembler-product-table-view-button" onClick={() => handleViewAssembly(rowIndex)} size="small">
58
- <VisibilityIcon />
59
- </IconButton>
60
- </TableCell>
61
- {parts.map((part, colIndex) => (
62
- <TableCell key={colIndex}>
63
- {formatItemName(plasmids.find((d) => d.id === part))}
64
- </TableCell>
65
- ))}
66
- </TableRow>
67
- ))}
68
- </TableBody>
69
- </Table>
70
- </TableContainer>
71
- )
123
+ <Box data-testid="assembler-product-table" sx={{ '& .MuiDataGrid-cell': { fontSize: '1.2rem' }, '& .MuiDataGrid-columnHeader': { fontSize: '1.2rem', fontWeight: 'bold' } }}>
124
+ <DataGrid
125
+ rows={rows}
126
+ columns={columns}
127
+ getRowClassName={(params) => (isRowInvalid(params.row.rowIndex, assemblyOutputNames) ? 'error-row' : '')}
128
+ density="compact"
129
+ disableRowSelectionOnClick
130
+ disableColumnSorting
131
+ disableColumnFilter
132
+ disableColumnMenu
133
+ hideFooter
134
+ autoHeight
135
+ sx={{
136
+ '& .error-row': {
137
+ backgroundColor: 'rgba(255, 0, 0, 0.15)',
138
+ '&:hover': { backgroundColor: 'rgba(255, 0, 0, 0.25)' },
139
+ },
140
+ }}
141
+ />
142
+ <EditTextDialog
143
+ open={editDialog.open}
144
+ value={editDialog.value}
145
+ onClose={() => setEditDialog({ open: false, rowIndex: null, value: '' })}
146
+ onSave={handleEditSave}
147
+ title="Edit Output Name"
148
+ placeholder="Enter output name..."
149
+ maxLength={MAX_OUTPUT_NAME_LENGTH}
150
+ />
151
+ </Box>
152
+ );
72
153
  }
73
154
 
74
155
  function AssemblerBox({ item, index, setCategory, setId, categories, plasmids, assembly }) {
@@ -132,17 +213,32 @@ function AssemblerBox({ item, index, setCategory, setId, categories, plasmids, a
132
213
  export function AssemblerComponent({ plasmids, categories, assemblyEnzymes, addAlert, appInfo }) {
133
214
 
134
215
  const [requestedAssemblies, setRequestedAssemblies] = React.useState([])
216
+ const [assemblyOutputNames, setAssemblyOutputNames] = React.useState([])
135
217
  const [errorMessage, setErrorMessage] = React.useState('')
136
218
  const [loadingMessage, setLoadingMessage] = React.useState('')
137
219
 
138
220
  const clearAssemblySelection = React.useCallback(() => {
139
221
  setRequestedAssemblies([])
222
+ setAssemblyOutputNames([])
140
223
  setErrorMessage('')
141
224
  }, [])
142
225
 
143
226
  const { assembly, setCategory, setId, expandedAssemblies, assemblyComplete, canBeSubmitted, currentCategories } = useCombinatorialAssembly({ onValueChange: clearAssemblySelection, categories, plasmids })
144
227
  const { requestSources, requestAssemblies } = useAssembler()
145
228
 
229
+ const onOutputNameChange = React.useCallback((index, name) => {
230
+ setAssemblyOutputNames((prev) => {
231
+ const next = [...prev];
232
+ next[index] = name;
233
+ return next;
234
+ });
235
+ }, []);
236
+
237
+ const namesAreUnique = new Set(assemblyOutputNames).size === assemblyOutputNames.length;
238
+ const namesNonEmpty = assemblyOutputNames.length > 0 && assemblyOutputNames.every((n) => n.trim().length > 0);
239
+ const namesNotTooLong = assemblyOutputNames.every((n) => n.length <= MAX_OUTPUT_NAME_LENGTH);
240
+ const canDownload = requestedAssemblies.length > 0 && namesAreUnique && namesNonEmpty && namesNotTooLong;
241
+
146
242
  const onSubmitAssembly = React.useCallback(async () => {
147
243
  clearAssemblySelection()
148
244
  const selectedPlasmids = assembly.map(({ plasmidIds }) => plasmidIds.map((id) => (plasmids.find((item) => item.id === id))))
@@ -155,6 +251,11 @@ export function AssemblerComponent({ plasmids, categories, assemblyEnzymes, addA
155
251
  setLoadingMessage('Assembling...')
156
252
  const assemblies = await requestAssemblies(resp, assemblyEnzymes)
157
253
  setRequestedAssemblies(assemblies)
254
+ if (expandedAssemblies && expandedAssemblies.length > 0) {
255
+ setAssemblyOutputNames(
256
+ expandedAssemblies.map((_, i) => getDefaultAssemblyOutputName(i, expandedAssemblies, plasmids)),
257
+ )
258
+ }
158
259
  } catch (e) {
159
260
  if (e.assembly) {
160
261
  errorMessage = (<><div style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>{error2String(e)}</div><div>Error assembling {e.assembly.map((p) => formatItemName(p.plasmid)).join(', ')}</div></>)
@@ -169,7 +270,9 @@ export function AssemblerComponent({ plasmids, categories, assemblyEnzymes, addA
169
270
 
170
271
  const onDownloadAssemblies = React.useCallback(async () => {
171
272
  try {
172
- const files = getFilesToExportFromAssembler({requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories, appInfo})
273
+ const files = getFilesToExportFromAssembler({
274
+ requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories, appInfo, outputNames: assemblyOutputNames,
275
+ });
173
276
  await downloadAssemblerFilesAsZip(files);
174
277
  } catch (error) {
175
278
  console.error('Error downloading assemblies:', error);
@@ -178,7 +281,7 @@ export function AssemblerComponent({ plasmids, categories, assemblyEnzymes, addA
178
281
  severity: 'error',
179
282
  });
180
283
  }
181
- }, [requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories, appInfo, addAlert])
284
+ }, [requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories, appInfo, addAlert, assemblyOutputNames])
182
285
 
183
286
  const options = React.useMemo(() => assemblyComplete ? assembly : [...assembly, { category: '', plasmidIds: [] }], [assembly, assemblyComplete])
184
287
 
@@ -207,12 +310,17 @@ export function AssemblerComponent({ plasmids, categories, assemblyEnzymes, addA
207
310
  variant="contained"
208
311
  data-testid="assembler-download-assemblies-button"
209
312
  sx={{ p: 2, fontSize: '1.2rem' }}
210
- onClick={onDownloadAssemblies}>Download Assemblies
313
+ onClick={onDownloadAssemblies}
314
+ disabled={!canDownload}
315
+ title={!canDownload ? 'Output names must be unique, non-empty, and at most 255 characters' : ''}>Download Assemblies
211
316
  </Button>}
212
317
  </Box>
213
318
  {errorMessage && <Alert severity="error" sx={{ my: 2, maxWidth: 300, margin: 'auto', fontSize: '1.2rem' }}>{errorMessage}</Alert>}
214
319
  {requestedAssemblies.length > 0 &&
215
- <AssemblerProductTable {...{requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories}} />
320
+ <AssemblerProductTable {...{
321
+ requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories,
322
+ assemblyOutputNames, onOutputNameChange,
323
+ }} />
216
324
  }
217
325
 
218
326
  </Box >
@@ -298,6 +406,35 @@ function LoadSyntaxButton({ setSyntax, addPlasmids, clearPlasmids }) {
298
406
  }
299
407
 
300
408
 
409
+ function SyntaxOverviewButton({ syntax }) {
410
+ const [open, setOpen] = React.useState(false)
411
+ const [mode, setMode] = React.useState('detailed')
412
+ const msa = React.useMemo(() => syntax ? graphToMSA(partsToGraph(syntax.parts)) : [], [syntax])
413
+ return <>
414
+ <Button color="success" onClick={() => setOpen(true)} data-testid="assembler-syntax-overview-button">Syntax Overview</Button>
415
+ {open && <Dialog
416
+ open={open}
417
+ onClose={() => setOpen(false)}
418
+ fullWidth
419
+ maxWidth="xl"
420
+ PaperProps={{ sx: { height: '90vh' } }}
421
+ >
422
+ <DialogTitle>Syntax overview</DialogTitle>
423
+ <DialogContent>
424
+ <FormControlLabel
425
+ control={
426
+ <Switch
427
+ checked={mode === 'detailed'}
428
+ onChange={(e) => setMode(e.target.checked ? 'detailed' : 'compact')}
429
+ />
430
+ }
431
+ label={mode === 'compact' ? 'Compact' : 'Detailed'}
432
+ />
433
+ <SyntaxOverviewTable msa={msa} mode={mode} parts={syntax.parts} />
434
+ </DialogContent>
435
+ </Dialog>}
436
+ </>
437
+ }
301
438
 
302
439
  function Assembler() {
303
440
  const [syntax, setSyntax] = React.useState(null);
@@ -331,6 +468,7 @@ function Assembler() {
331
468
  </Alert>
332
469
  <ButtonGroup>
333
470
  <LoadSyntaxButton setSyntax={setSyntax} addPlasmids={addPlasmids} clearPlasmids={clearPlasmids} />
471
+ {syntax && <SyntaxOverviewButton syntax={syntax} />}
334
472
  {syntax && <UploadPlasmidsButton addPlasmids={addPlasmids} syntax={syntax} />}
335
473
  {syntax && <Button color="error" onClick={clearLoadedPlasmids}>Remove uploaded plasmids</Button>}
336
474
  </ButtonGroup>
@@ -0,0 +1,103 @@
1
+ import React from 'react'
2
+ import { Box, Typography, Table, TableBody, TableRow, TableCell } from '@mui/material';
3
+ import { partDataToDisplayData } from './assembler_utils';
4
+ import { AssemblerPartContainer, AssemblerPartCore, DisplayInside, DisplayOverhang } from './AssemblerPart';
5
+ import AssemblerPart from './AssemblerPart';
6
+ import { GRAPH_SPACER } from './graph_utils';
7
+
8
+ function OverhangRow({ row, parts, mode = 'detailed' }) {
9
+
10
+ const rows2iterate = [...row];
11
+ const actualRows =[];
12
+
13
+ let currentCell = [rows2iterate.shift(), 1];
14
+ while (currentCell[0] !== undefined) {
15
+ if (rows2iterate[0] === GRAPH_SPACER) {
16
+ currentCell[1]++;
17
+ rows2iterate.shift();
18
+ } else {
19
+ actualRows.push(currentCell);
20
+ currentCell = [rows2iterate.shift(), 1];
21
+ }
22
+ }
23
+ actualRows.forEach(cell => {
24
+ const [leftOverhang, rightOverhang] = cell[0].split('-');
25
+ const data = parts.find(part => part.left_overhang === leftOverhang && part.right_overhang === rightOverhang) ||
26
+ {
27
+ left_overhang: leftOverhang,
28
+ right_overhang: rightOverhang,
29
+ left_inside: '',
30
+ right_inside: '',
31
+ left_codon_start: 0,
32
+ right_codon_start: 0,
33
+ color: 'lightgray',
34
+ glyph: 'engineered-region',
35
+ }
36
+ cell.push(data);
37
+ });
38
+ return (
39
+ <TableRow >
40
+ {actualRows.flatMap(
41
+ (cell, index) => {
42
+ const showRight = index === actualRows.length - 1;
43
+
44
+ const colSpan = (cell[1]-1)*2 + 1;
45
+ const { left_overhang, right_overhang, left_inside, color, glyph } = cell[2];
46
+ const { leftTranslationOverhang, leftTranslationInside, leftOverhangRc, rightOverhangRc, leftInsideRc } = partDataToDisplayData(cell[2]);
47
+ if (mode === 'compact') {
48
+ return <React.Fragment key={`row-${index}-compact`}>
49
+ <TableCell sx={{padding: 0, borderRight: 'solid 1px gray'}} >
50
+ <AssemblerPartContainer>
51
+ <DisplayOverhang overhang={left_overhang} overhangRc={leftOverhangRc} translation={leftTranslationOverhang} isRight={false} />
52
+ {left_inside && <DisplayInside inside={left_inside} insideRc={leftInsideRc} translation={leftTranslationInside} isRight={false} />}
53
+ </AssemblerPartContainer>
54
+ </TableCell>
55
+ <TableCell sx={{ padding: 0, textAlign: "center", borderRight: 'solid 1px gray' }} colSpan={colSpan}>
56
+ <AssemblerPartContainer>
57
+ <AssemblerPartCore color={color} glyph={glyph} />
58
+ </AssemblerPartContainer>
59
+ </TableCell>
60
+ {showRight && (
61
+ <TableCell
62
+ key={index}
63
+ sx={{ padding: 0, borderRight: 'solid 1px gray' }}
64
+ >
65
+ <AssemblerPartContainer>
66
+ <DisplayOverhang overhang={right_overhang} overhangRc={rightOverhangRc} isRight={true} />
67
+ </AssemblerPartContainer>
68
+ </TableCell>
69
+ )}
70
+ </React.Fragment>
71
+ }
72
+ if (mode === 'detailed') {
73
+ return (
74
+ <TableCell colSpan={cell[1]} key={`row-${index}-detailed`} sx={{ border: 1, py: 0 }}>
75
+ <Box sx={{ display: 'flex', justifyContent: 'center', flexDirection: 'column', alignItems: 'center' }}>
76
+ <Typography variant="h6" >{cell[2].name}</Typography>
77
+ <Typography variant="body2" >{cell[2].info}</Typography>
78
+ <AssemblerPart data={cell[2]} />
79
+ </Box>
80
+ </TableCell>
81
+ )
82
+ }
83
+ }
84
+ )}
85
+ </TableRow>
86
+ );
87
+ }
88
+
89
+ function SyntaxOverviewTable({ msa, mode, parts }) {
90
+ return (
91
+ <Box sx={{ overflowY: 'auto', overflowX: 'auto' }}>
92
+ <Table data-testid="overhangs-preview-table" sx={{ borderCollapse: 'separate', borderSpacing: 0 }}>
93
+ <TableBody>
94
+ {msa.map((row, index) => (
95
+ <OverhangRow key={index} row={row} mode={mode} parts={parts} />
96
+ ))}
97
+ </TableBody>
98
+ </Table>
99
+ </Box>
100
+ )
101
+ }
102
+
103
+ export default SyntaxOverviewTable
@@ -5,6 +5,9 @@ import { openCycleAtNode } from './graph_utils';
5
5
  import { downloadBlob, formatStateForJsonExport, getZipFileBlob } from '@opencloning/utils/readNwrite';
6
6
  import { getGraftSequenceId } from '@opencloning/utils/network';
7
7
  import { TextReader} from '@zip.js/zip.js';
8
+ import { editGenbankSequenceNameFromTextContent } from '@opencloning/utils';
9
+
10
+ export const MAX_OUTPUT_NAME_LENGTH = 250;
8
11
 
9
12
  export function tripletsToTranslation(triplets) {
10
13
  if (!triplets) return ''
@@ -161,11 +164,33 @@ export function categoryFilter(category, categories, previousCategoryId) {
161
164
  return previousCategory?.right_overhang === category.left_overhang
162
165
  }
163
166
 
164
- export function getFilesToExportFromAssembler({requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories, appInfo}) {
167
+ export function getDefaultAssemblyOutputName(assemblyIndex, expandedAssemblies, plasmids) {
168
+ const assembly = expandedAssemblies[assemblyIndex];
169
+ const plasmidNames = assembly.map(part => plasmids.find(p => p.id === part)?.plasmid_name ?? '');
170
+ let name = `${String(assemblyIndex + 1).padStart(3, '0')}_${plasmidNames.join('+')}`.replaceAll('/', '_').replaceAll(' ', '_');
171
+ if (name.length > MAX_OUTPUT_NAME_LENGTH) {
172
+ name = `${String(assemblyIndex + 1).padStart(3, '0')}_construct`;
173
+ }
174
+ return name;
175
+ }
176
+
177
+ export function sanitizeOutputName(name) {
178
+ let sanitized = name.replaceAll('/', '_').replaceAll(' ', '_');
179
+ if (sanitized.length > MAX_OUTPUT_NAME_LENGTH) {
180
+ sanitized = sanitized.slice(0, MAX_OUTPUT_NAME_LENGTH);
181
+ }
182
+ return sanitized;
183
+ }
184
+
185
+ export function getFilesToExportFromAssembler({requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories, appInfo, outputNames}) {
186
+ if (outputNames.length !== requestedAssemblies.length) {
187
+ throw new Error('outputNames must be the same length as requestedAssemblies');
188
+ }
165
189
  const files2Export = [];
166
- const categoryNames = ['Assembly', ...currentCategories.map(categoryId => categories.find(c => c.id === categoryId).displayName)];
190
+ const categoryNames = ['Assembly', 'Name', ...currentCategories.map(categoryId => categories.find(c => c.id === categoryId).displayName)];
167
191
  const assemblyNames = expandedAssemblies.map((assembly, index) => {
168
- return [index + 1, ...assembly.map(part => plasmids.find(p => p.id === part).plasmid_name)];
192
+ const plasmidNames = assembly.map(part => plasmids.find(p => p.id === part).plasmid_name);
193
+ return [index + 1, outputNames[index], ...plasmidNames];
169
194
  });
170
195
  for (const delimiter of ['\t', ',']) {
171
196
  const tableHeader = categoryNames.join(delimiter);
@@ -179,18 +204,19 @@ export function getFilesToExportFromAssembler({requestedAssemblies, expandedAsse
179
204
  }
180
205
 
181
206
  for (let i = 0; i < requestedAssemblies.length; i++) {
182
- let name = `${String(i + 1).padStart(3, '0')}_${assemblyNames[i].slice(1).join('+')}`;
183
- if (name.length > 255) {
184
- name = `${String(i + 1).padStart(3, '0')}_construct`;
185
- }
186
- const requestedAssembly = requestedAssemblies[i];
207
+ const name = sanitizeOutputName(outputNames[i]);
208
+ const requestedAssembly = JSON.parse(JSON.stringify(requestedAssemblies[i]));
187
209
  const jsonContent = formatStateForJsonExport({...requestedAssembly, appInfo});
210
+ const finalSequenceId = getGraftSequenceId(requestedAssembly)
211
+ const finalSequence = requestedAssembly.sequences.find(s => s.id === finalSequenceId)
212
+ finalSequence.file_content = editGenbankSequenceNameFromTextContent(finalSequence.file_content, name);
213
+ const finalSource = requestedAssembly.sources.find(s => s.id === finalSequenceId)
214
+ finalSource.output_name = name;
188
215
  files2Export.push({
189
216
  name: `${name}.json`,
190
217
  content: JSON.stringify(jsonContent, null, 2),
191
218
  });
192
- const finalSequenceId = getGraftSequenceId(requestedAssembly)
193
- const finalSequence = requestedAssembly.sequences.find(s => s.id === finalSequenceId)
219
+
194
220
  files2Export.push({
195
221
  name: `${name}.gbk`,
196
222
  content: finalSequence.file_content,
@@ -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 } from "./assembler_utils";
3
+ import { assignSequenceToSyntaxPart, simplifyDigestFragment, reverseComplementSimplifiedDigestFragment, tripletsToTranslation, partDataToDisplayData, arrayCombinations, getFilesToExportFromAssembler, getDefaultAssemblyOutputName } from "./assembler_utils";
4
4
  import { partsToEdgesGraph } from "./graph_utils";
5
5
 
6
6
 
@@ -314,13 +314,25 @@ const dummyData = {
314
314
 
315
315
 
316
316
 
317
+ describe('getDefaultAssemblyOutputName', () => {
318
+ it('returns default name from plasmid names', () => {
319
+ expect(getDefaultAssemblyOutputName(0, dummyData.expandedAssemblies, dummyData.plasmids)).toBe('001_p1+p5+p7');
320
+ expect(getDefaultAssemblyOutputName(1, dummyData.expandedAssemblies, dummyData.plasmids)).toBe('002_p1+p2+p3');
321
+ });
322
+ it('returns _construct suffix when name is too long', () => {
323
+ const longPlasmids = dummyData.plasmids.map(plasmid => ({ ...plasmid, plasmid_name: 'p1'.repeat(100) }));
324
+ expect(getDefaultAssemblyOutputName(0, dummyData.expandedAssemblies, longPlasmids)).toBe('001_construct');
325
+ });
326
+ });
327
+
317
328
  describe('getFilesToExportFromAssembler', () => {
318
329
  it('returns the correct files', () => {
319
- const files = getFilesToExportFromAssembler(dummyData);
330
+ const outputNames = dummyData.expandedAssemblies.map((_, i) => getDefaultAssemblyOutputName(i, dummyData.expandedAssemblies, dummyData.plasmids));
331
+ const files = getFilesToExportFromAssembler({ ...dummyData, outputNames });
320
332
  expect(files[0].name).toBe('assemblies.tsv');
321
- expect(files[0].content).toBe('Assembly\tCategory 1\tCategory 2\tCategory 3\n1\tp1\tp5\tp7\n2\tp1\tp2\tp3');
333
+ expect(files[0].content).toBe('Assembly\tName\tCategory 1\tCategory 2\tCategory 3\n1\t001_p1+p5+p7\tp1\tp5\tp7\n2\t002_p1+p2+p3\tp1\tp2\tp3');
322
334
  expect(files[1].name).toBe('assemblies.csv');
323
- expect(files[1].content).toBe('Assembly,Category 1,Category 2,Category 3\n1,p1,p5,p7\n2,p1,p2,p3');
335
+ expect(files[1].content).toBe('Assembly,Name,Category 1,Category 2,Category 3\n1,001_p1+p5+p7,p1,p5,p7\n2,002_p1+p2+p3,p1,p2,p3');
324
336
 
325
337
  const fileNames = ['001_p1+p5+p7', '002_p1+p2+p3'];
326
338
  for (let i = 0; i < 2; i++) {
@@ -328,13 +340,20 @@ describe('getFilesToExportFromAssembler', () => {
328
340
  const fileIndex2 = fileIndex1 + 1;
329
341
  expect(files[fileIndex1].name).toBe(`${fileNames[i]}.json`);
330
342
  const cloningStrategy = JSON.parse(files[fileIndex1].content);
331
- expect(cloningStrategy.sequences).toEqual(dummyData.requestedAssemblies[i].sequences);
332
- expect(cloningStrategy.sources).toEqual(dummyData.requestedAssemblies[i].sources);
343
+ // Messed up by change of name, not worth checking
344
+ // expect(cloningStrategy.sequences).toEqual(dummyData.requestedAssemblies[i].sequences);
345
+ // expect(cloningStrategy.sources).toEqual(dummyData.requestedAssemblies[i].sources);
333
346
  expect(cloningStrategy.primers).toEqual(dummyData.requestedAssemblies[i].primers);
347
+ expect(cloningStrategy.sources[cloningStrategy.sources.length - 1].output_name).toBe(fileNames[i]);
348
+ const firstline = cloningStrategy.sequences[cloningStrategy.sequences.length - 1].file_content.split('\n')[0];
349
+ expect(firstline).toContain(fileNames[i]);
334
350
 
335
351
  expect(files[fileIndex2].name).toBe(`${fileNames[i]}.gbk`);
336
352
  const genbankContent = files[fileIndex2].content;
337
- expect(genbankContent).toBe(dummyData.requestedAssemblies[i].sequences[dummyData.requestedAssemblies[i].sequences.length - 1].file_content);
353
+ const expectedFileContent = dummyData.requestedAssemblies[i].sequences[dummyData.requestedAssemblies[i].sequences.length - 1].file_content
354
+ expect(genbankContent).toBe(expectedFileContent.replaceAll('final_product', fileNames[i]).replaceAll('expression_clone_lacZ', fileNames[i]));
355
+ const firstline2 = genbankContent.split('\n')[0];
356
+ expect(firstline2).toContain(fileNames[i]);
338
357
  }
339
358
 
340
359
 
@@ -343,11 +362,20 @@ describe('getFilesToExportFromAssembler', () => {
343
362
  const dummyData2 = {
344
363
  ...dummyData,
345
364
  plasmids: dummyData.plasmids.map(plasmid => ({...plasmid, plasmid_name: 'p1'.repeat(100)})),
346
- }
347
- const files = getFilesToExportFromAssembler(dummyData2);
365
+ };
366
+ const outputNames = dummyData2.expandedAssemblies.map((_, i) => getDefaultAssemblyOutputName(i, dummyData2.expandedAssemblies, dummyData2.plasmids));
367
+ const files = getFilesToExportFromAssembler({ ...dummyData2, outputNames });
348
368
  expect(files[2].name).toBe('001_construct.json');
349
369
  expect(files[3].name).toBe('001_construct.gbk');
350
370
  expect(files[4].name).toBe('002_construct.json');
351
371
  expect(files[5].name).toBe('002_construct.gbk');
352
- })
353
- })
372
+ });
373
+ it('uses custom outputNames when provided', () => {
374
+ const customNames = ['my_assembly_1', 'my_assembly_2'];
375
+ const files = getFilesToExportFromAssembler({ ...dummyData, outputNames: customNames });
376
+ expect(files[2].name).toBe('my_assembly_1.json');
377
+ expect(files[3].name).toBe('my_assembly_1.gbk');
378
+ expect(files[4].name).toBe('my_assembly_2.json');
379
+ expect(files[5].name).toBe('my_assembly_2.gbk');
380
+ });
381
+ });
@@ -1,3 +1,4 @@
1
+ export { default as EditTextDialog } from '../form/EditTextDialog';
1
2
  export { default as AssemblerPart } from './AssemblerPart';
2
3
  export { AssemblerPartCore } from './AssemblerPart';
3
4
  export { getSvgByGlyph } from './sbol_visual_glyphs';
@@ -7,3 +8,4 @@ export { partsToGraph, graphToMSA, graphHasCycle, partsToEdgesGraph, GRAPH_SPACE
7
8
  export { usePlasmidsLogic } from './usePlasmidsLogic.js';
8
9
  export { default as PlasmidSyntaxTable } from './PlasmidSyntaxTable.jsx';
9
10
  export { default as ExistingSyntaxDialog } from './ExistingSyntaxDialog.jsx';
11
+ export { default as SyntaxOverviewTable } from './SyntaxOverviewTable.jsx';
@@ -0,0 +1,48 @@
1
+ import React from 'react';
2
+ import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Alert } from '@mui/material';
3
+
4
+ export default function EditTextDialog({
5
+ open,
6
+ value,
7
+ onClose,
8
+ onSave,
9
+ title = 'Edit',
10
+ placeholder = 'Enter text...',
11
+ multiline = false,
12
+ maxLength,
13
+ }) {
14
+ const [tempValue, setTempValue] = React.useState(value || '');
15
+
16
+ React.useEffect(() => {
17
+ if (open) setTempValue(value || '');
18
+ }, [open, value]);
19
+
20
+ const handleChange = (e) => {
21
+ const newValue = multiline ? e.target.value.replace(/\n/g, '') : e.target.value;
22
+ setTempValue(maxLength ? newValue.slice(0, maxLength) : newValue);
23
+ };
24
+
25
+ return (
26
+ <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
27
+ <DialogTitle>{title}</DialogTitle>
28
+ <DialogContent>
29
+ {maxLength && <Alert severity="info" sx={{ mb: 2 }}>Maximum input length is {maxLength} characters</Alert>}
30
+ <TextField
31
+ autoFocus
32
+ multiline={multiline}
33
+ rows={multiline ? 6 : undefined}
34
+ fullWidth
35
+ value={tempValue}
36
+ onChange={handleChange}
37
+ placeholder={placeholder}
38
+ inputProps={maxLength ? { maxLength } : undefined}
39
+ sx={{ mt: 1 }}
40
+ />
41
+ </DialogContent>
42
+ <DialogActions>
43
+ <Button onClick={onClose}>Cancel</Button>
44
+ <Button onClick={() => onSave(tempValue)} variant="contained">Save</Button>
45
+ </DialogActions>
46
+ </Dialog>
47
+ );
48
+ }
@@ -75,7 +75,6 @@ function ServerStaticFileSelect({ onFileSelected, multiple = false, type = 'sequ
75
75
  const label = type === 'sequence' ? 'Sequence' : 'Syntax';
76
76
 
77
77
  const onOptionsChange = React.useCallback((event, value) => {
78
- console.log('onOptionsChange', value);
79
78
  if (multiple && type === 'sequence') {
80
79
  if (value.includes('__all__')) {
81
80
  const allSequences = options.filter((option) => option !== '__all__');
@@ -1,4 +1,5 @@
1
1
  export { default as OpenCloning } from './OpenCloning';
2
2
  export { default as MainAppBar } from './navigation/MainAppBar';
3
+ export { default as EditTextDialog } from './form/EditTextDialog';
3
4
  export { default as useUrlParamsLoader } from '../hooks/useUrlParamsLoader';
4
5
  export { default as useInitializeApp } from '../hooks/useInitializeApp';
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.5.2";
2
+ export const version = "1.5.3";