@opencloning/ui 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +4 -3
  3. package/src/components/assembler/Assembler.cy.jsx +364 -0
  4. package/src/components/assembler/Assembler.jsx +298 -205
  5. package/src/components/assembler/AssemblerPart.cy.jsx +52 -0
  6. package/src/components/assembler/AssemblerPart.jsx +51 -79
  7. package/src/components/assembler/ExistingSyntaxDialog.cy.jsx +251 -0
  8. package/src/components/assembler/ExistingSyntaxDialog.jsx +104 -0
  9. package/src/components/assembler/PlasmidSyntaxTable.jsx +83 -0
  10. package/src/components/assembler/assembler_utils.js +134 -0
  11. package/src/components/assembler/assembler_utils.test.js +193 -0
  12. package/src/components/assembler/assembly_component.module.css +1 -1
  13. package/src/components/assembler/graph_utils.js +153 -0
  14. package/src/components/assembler/graph_utils.test.js +239 -0
  15. package/src/components/assembler/index.js +9 -0
  16. package/src/components/assembler/useAssembler.js +59 -22
  17. package/src/components/assembler/useCombinatorialAssembly.js +76 -0
  18. package/src/components/assembler/usePlasmidsLogic.js +82 -0
  19. package/src/components/eLabFTW/utils.js +0 -9
  20. package/src/components/index.js +2 -0
  21. package/src/components/navigation/SelectTemplateDialog.jsx +0 -1
  22. package/src/components/primers/DownloadPrimersButton.jsx +0 -1
  23. package/src/components/primers/PrimerList.jsx +4 -3
  24. package/src/components/primers/import_primers/ImportPrimersButton.jsx +0 -1
  25. package/src/version.js +1 -1
  26. package/vitest.config.js +2 -4
  27. package/src/components/DraggableDialogPaper.jsx +0 -16
  28. package/src/components/assembler/AssemblePartWidget.jsx +0 -252
  29. package/src/components/assembler/StopIcon.jsx +0 -34
  30. package/src/components/assembler/assembler_data2.json +0 -50
  31. package/src/components/assembler/moclo.json +0 -110
@@ -1,194 +1,203 @@
1
1
  import React from 'react'
2
- import data2 from './assembler_data2.json'
3
- import { Alert, Autocomplete, Box, Button, CircularProgress, FormControl, IconButton, InputAdornment, InputLabel, MenuItem, Select, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField } from '@mui/material'
2
+ import {
3
+ Alert, Autocomplete, Box, Button, CircularProgress, Dialog, DialogTitle, DialogContent,
4
+ DialogActions, FormControl, IconButton, InputAdornment, InputLabel, MenuItem, Select, Stack, Table,
5
+ TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, ButtonGroup
6
+ } from '@mui/material'
4
7
  import { Clear as ClearIcon, Visibility as VisibilityIcon } from '@mui/icons-material';
5
8
  import { useAssembler } from './useAssembler';
6
- import { arrayCombinations } from '../eLabFTW/utils';
7
9
  import { useDispatch } from 'react-redux';
8
10
  import { cloningActions } from '@opencloning/store/cloning';
9
- import RequestStatusWrapper from '../form/RequestStatusWrapper';
10
- import useHttpClient from '../../hooks/useHttpClient';
11
11
  import AssemblerPart from './AssemblerPart';
12
-
12
+ import { jsonToGenbank } from '@teselagen/bio-parsers';
13
+ import useCombinatorialAssembly from './useCombinatorialAssembly';
14
+ import { usePlasmidsLogic } from './usePlasmidsLogic';
15
+ import PlasmidSyntaxTable from './PlasmidSyntaxTable';
16
+ import ExistingSyntaxDialog from './ExistingSyntaxDialog';
17
+ import error2String from '@opencloning/utils/error2String';
18
+ import { categoryFilter } from './assembler_utils';
13
19
 
14
20
  const { setState: setCloningState, setCurrentTab: setCurrentTabAction } = cloningActions;
15
21
 
16
- const categoryFilter = (category, previousCategory) => {
17
- if (previousCategory === '') {
18
- return category.startsWith('A_')
22
+ function formatPlasmid(sequenceData) {
23
+
24
+ const { appData } = sequenceData;
25
+ const { fileName, correspondingParts, longestFeature } = appData;
26
+ const [left_overhang, right_overhang] = correspondingParts[0].split('-');
27
+
28
+ let plasmidName = fileName;
29
+ if (longestFeature[0]?.name) {
30
+ plasmidName += ` (${longestFeature[0].name})`;
19
31
  }
20
- return previousCategory.split('_')[1] === category.split('_')[0]
32
+
33
+ return {
34
+ type: 'loadedFile',
35
+ plasmid_name: plasmidName,
36
+ file_name: fileName,
37
+ left_overhang,
38
+ right_overhang,
39
+ key: `${left_overhang}-${right_overhang}`,
40
+ sequenceData,
41
+ genbankString: jsonToGenbank(sequenceData),
42
+ };
43
+
21
44
  }
22
45
 
23
- function AssemblerLink({ overhang }) {
46
+
47
+ function formatItemName(item) {
48
+ // Fallback in case the item is not found (while updating list)
49
+ return item ? `${item.plasmid_name}` : '-'
50
+ }
51
+
52
+ function AssemblerProductTable({ requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories }) {
53
+
54
+ const dispatch = useDispatch()
55
+ const handleViewAssembly = (index) => {
56
+ const newState = requestedAssemblies[index]
57
+ dispatch(setCloningState(newState))
58
+ dispatch(setCurrentTabAction(0))
59
+ }
24
60
  return (
25
- <Box sx={{ display: 'flex', alignItems: 'center', minWidth: '80px' }}>
26
- <Box sx={{ flex: 1, height: '2px', bgcolor: 'primary.main' }} />
27
- <Box sx={{ mx: 1, px: 1, py: 0.5, bgcolor: 'background.paper', border: 1, borderColor: 'primary.main', borderRadius: 1, fontSize: '0.75rem', fontWeight: 'bold' }}>
28
- {overhang}
29
- </Box>
30
- <Box sx={{ flex: 1, height: '2px', bgcolor: 'primary.main' }} />
31
- </Box>
61
+ <TableContainer sx={{ '& td': { fontSize: '1.2rem' }, '& th': { fontSize: '1.2rem' } }}>
62
+ <Table size="small" data-testid="assembler-product-table">
63
+ <TableHead>
64
+ <TableRow>
65
+ <TableCell padding="checkbox" />
66
+ {currentCategories.map(category => (
67
+ <TableCell key={category} sx={{ fontWeight: 'bold' }}>
68
+ {categories.find((c) => c.id === category)?.displayName}
69
+ </TableCell>
70
+ ))}
71
+ </TableRow>
72
+ </TableHead>
73
+ <TableBody>
74
+ {expandedAssemblies.map((parts, rowIndex) => (
75
+ <TableRow key={rowIndex}>
76
+ <TableCell padding="checkbox">
77
+ <IconButton data-testid="assembler-product-table-view-button" onClick={() => handleViewAssembly(rowIndex)} size="small">
78
+ <VisibilityIcon />
79
+ </IconButton>
80
+ </TableCell>
81
+ {parts.map((part, colIndex) => (
82
+ <TableCell key={colIndex}>
83
+ {formatItemName(plasmids.find((d) => d.id === part))}
84
+ </TableCell>
85
+ ))}
86
+ </TableRow>
87
+ ))}
88
+ </TableBody>
89
+ </Table>
90
+ </TableContainer>
32
91
  )
33
92
  }
34
93
 
35
- function formatItemName(item) {
36
- if (item.plasmid_name && item.id !== item.plasmid_name) {
37
- return `${item.id} (${item.plasmid_name})`
38
- }
39
- return item.id
94
+ function AssemblerBox({ item, index, setCategory, setId, categories, plasmids, assembly }) {
95
+
96
+ const allowedCategories = categories.filter((category) => categoryFilter(category, categories, index === 0 ? null : assembly[index - 1].category))
97
+ const isCompleted = item.category !== '' && item.plasmidIds.length > 0
98
+ const borderColor = isCompleted ? 'success.main' : 'primary.main'
99
+ const thisCategory = categories.find((category) => category.id === item.category)
100
+ const allowedPlasmids = thisCategory ? plasmids.filter((d) => d.key === thisCategory.key) : [];
101
+
102
+ return(
103
+ <Box sx={{ width: '250px', border: 3, borderColor, borderRadius: 4, p: 2 }}>
104
+ <FormControl data-testid="category-select" fullWidth sx={{ mb: 2 }}>
105
+ <InputLabel>Category</InputLabel>
106
+ <Select
107
+ endAdornment={item.category && allowedCategories.length > 1 && (<InputAdornment position="end"><IconButton onClick={() => setCategory('', index)}><ClearIcon /></IconButton></InputAdornment>)}
108
+ value={item.category}
109
+ onChange={(e) => setCategory(e.target.value, index)}
110
+ label="Category"
111
+ disabled={index < assembly.length}
112
+ >
113
+ {allowedCategories.map((category) => (
114
+ <MenuItem key={category.id} value={category.id}>{category.displayName}</MenuItem>
115
+ ))}
116
+ </Select>
117
+ </FormControl>
118
+ {thisCategory && (
119
+ <>
120
+ <FormControl data-testid="plasmid-select" fullWidth>
121
+ <Autocomplete
122
+ multiple
123
+ value={item.plasmidIds}
124
+ onChange={(e, value) => setId(value, index)}
125
+ options={allowedPlasmids.map((item) => item.id)}
126
+ getOptionLabel={(id) => formatItemName(plasmids.find((d) => d.id === id))}
127
+ renderInput={(params) => <TextField {...params} label="Plasmids" />}
128
+ renderOption={(props, option) => {
129
+ const { key, ...restProps } = props
130
+ const plasmid = plasmids.find((d) => d.id === option)
131
+ return (
132
+ <MenuItem key={key} {...restProps} sx={{ backgroundColor: plasmid.type === 'loadedFile' ? 'success.light' : undefined }}>
133
+ {formatItemName(plasmid)}
134
+ </MenuItem>
135
+ )}}
136
+ />
137
+ </FormControl>
138
+ <Box sx={{ display: 'flex', justifyContent: 'center' }}>
139
+ <AssemblerPart data={ thisCategory }/>
140
+ </Box>
141
+ </>
142
+ )}
143
+ </Box>
144
+ )
40
145
  }
41
146
 
42
- function AssemblerComponent({ data, categories }) {
147
+ export function AssemblerComponent({ plasmids, categories }) {
43
148
 
44
- const [assembly, setAssembly] = React.useState([{ category: '', id: [] }])
45
- const { requestSources, requestAssemblies } = useAssembler()
46
149
  const [requestedAssemblies, setRequestedAssemblies] = React.useState([])
47
- const [loadingMessage, setLoadingMessage] = React.useState('')
48
150
  const [errorMessage, setErrorMessage] = React.useState('')
49
- const dispatch = useDispatch()
151
+ const [loadingMessage, setLoadingMessage] = React.useState('')
152
+
153
+ const clearAssemblySelection = React.useCallback(() => {
154
+ setRequestedAssemblies([])
155
+ setErrorMessage('')
156
+ }, [])
157
+
158
+ const { assembly, setCategory, setId, expandedAssemblies, assemblyComplete, canBeSubmitted, currentCategories } = useCombinatorialAssembly({ onValueChange: clearAssemblySelection, categories, plasmids })
159
+ const { requestSources, requestAssemblies } = useAssembler()
160
+
50
161
  const onSubmitAssembly = async () => {
51
- clearAssembly()
52
- const sources = assembly.map(({ id }) => id.map((id) => (data.find((item) => item.id === id).source)))
162
+ clearAssemblySelection()
163
+ const selectedPlasmids = assembly.map(({ plasmidIds }) => plasmidIds.map((id) => (plasmids.find((item) => item.id === id))))
164
+
53
165
  let errorMessage = 'Error fetching sequences'
54
166
  try {
55
167
  setLoadingMessage('Requesting sequences...')
56
- const resp = await requestSources(sources)
168
+ const resp = await requestSources(selectedPlasmids)
57
169
  errorMessage = 'Error assembling sequences'
58
170
  setLoadingMessage('Assembling...')
59
171
  const assemblies = await requestAssemblies(resp)
60
172
  setRequestedAssemblies(assemblies)
61
173
  } catch (e) {
174
+ if (e.assembly) {
175
+ errorMessage = (<><div style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>{error2String(e)}</div><div>Error assembling {e.assembly.map((p) => formatItemName(p.plasmid)).join(', ')}</div></>)
176
+ } else if (e.plasmid) {
177
+ errorMessage = (<><div style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>{error2String(e)}</div><div>Error fetching sequence for {formatItemName(e.plasmid)}</div></>)
178
+ }
62
179
  setErrorMessage(errorMessage)
63
180
  } finally {
64
181
  setLoadingMessage(false)
65
182
  }
66
183
  }
67
184
 
68
- const clearAssembly = () => {
69
- setRequestedAssemblies([])
70
- setErrorMessage('')
71
- }
72
-
73
- const setCategory = (category, index) => {
74
- clearAssembly()
75
- if (category === '') {
76
- const newAssembly = assembly.slice(0, index)
77
- newAssembly[index] = { category: '', id: [] }
78
- setAssembly(newAssembly)
79
- return
80
- }
81
- setAssembly(assembly.map((item, i) => i === index ? { category, id: [] } : item))
82
- }
83
- const setId = (idArray, index) => {
84
- clearAssembly()
85
- // Handle case where user clears all selections (empty array)
86
- if (!idArray || idArray.length === 0) {
87
- setAssembly(assembly.map((item, i) => i === index ? { ...item, id: [] } : item))
88
- return
89
- }
90
-
91
- // For multiple selection, we need to determine the category based on the first selected item
92
- // or maintain the current category if it's already set
93
- const currentItem = assembly[index]
94
- const firstOption = data.find((item) => item.id === idArray[0])
95
- const category = currentItem.category || firstOption?.category || ''
96
-
97
- setAssembly(assembly.map((item, i) => i === index ? { id: idArray, category } : item))
98
- }
99
-
100
- const handleViewAssembly = (index) => {
101
- const newState = requestedAssemblies[index]
102
- dispatch(setCloningState(newState))
103
- dispatch(setCurrentTabAction(0))
104
- }
105
-
106
- React.useEffect(() => {
107
- const lastPosition = assembly.length - 1
108
- if (assembly[lastPosition].category.endsWith('A')) {
109
- return
110
- }
111
- if (assembly[lastPosition].category !== '') {
112
- const newAssembly = [...assembly, { category: '', id: [] }]
113
- setAssembly(newAssembly)
114
- }
115
- }, [assembly])
116
-
117
- const expandedAssemblies = arrayCombinations(assembly.map(({ id }) => id))
118
- const assemblyComplete = assembly.every((item) => item.category !== '' && item.id.length > 0)
119
- const currentCategories = assembly.map((item) => item.category)
185
+ const options = React.useMemo(() => assemblyComplete ? assembly : [...assembly, { category: '', plasmidIds: [] }], [assembly, assemblyComplete])
120
186
 
121
187
  return (
122
188
  <Box className="assembler-container" sx={{ width: '80%', margin: 'auto', mb: 4 }}>
123
- <Alert severity="warning" sx={{ maxWidth: '400px', margin: 'auto', fontSize: '.9rem' }}>
124
- The Assembler is experimental. Use with caution.
125
- </Alert>
126
189
 
127
190
  <Stack direction="row" alignItems="center" spacing={1} sx={{ overflowX: 'auto', my: 2 }}>
128
- {assembly.map((item, index) => {
129
- const allowedCategories = item.category ? [item.category] : categories.filter((category) => categoryFilter(category, index === 0 ? '' : assembly[index - 1].category))
130
- const isCompleted = item.category !== '' && item.id.length > 0
131
- const borderColor = isCompleted ? 'success.main' : 'primary.main'
132
- const leftOverhang = data.find((d) => d.category === item.category)?.left_overhang
133
- const rightOverhang = data.find((d) => d.category === item.category)?.right_overhang
134
-
135
- return (
136
- <React.Fragment key={index}>
137
- {/* Link before first box */}
138
- {index === 0 && item.category !== '' && (
139
- <AssemblerLink overhang={leftOverhang} />
140
- )}
141
- <Box sx={{ width: '250px', border: 3, borderColor, borderRadius: 4, p: 2 }}>
142
- <FormControl fullWidth sx={{ mb: 2 }}>
143
- <InputLabel>Category</InputLabel>
144
- <Select
145
- endAdornment={item.category && (<InputAdornment position="end"><IconButton onClick={() => setCategory('', index)}><ClearIcon /></IconButton></InputAdornment>)}
146
- value={item.category}
147
- onChange={(e) => setCategory(e.target.value, index)}
148
- label="Category"
149
- disabled={index < assembly.length - 1}
150
- >
151
- {allowedCategories.map((category) => (
152
- <MenuItem key={category} value={category}>{category === 'F_A' ? 'Backbone' : category}</MenuItem>
153
- ))}
154
- </Select>
155
- </FormControl>
156
- <FormControl fullWidth>
157
- <Autocomplete
158
- multiple
159
- value={item.id}
160
- onChange={(e, value) => setId(value, index)}
161
- label="ID"
162
- options={data.filter((d) => allowedCategories.includes(d.category)).map((item) => item.id)}
163
- getOptionLabel={(id) => formatItemName(data.find((d) => d.id === id))}
164
- renderInput={(params) => <TextField {...params} label="ID" />}
165
- />
166
- </FormControl>
167
- {leftOverhang && rightOverhang && (
168
- <Box sx={{ display: 'flex', justifyContent: 'center' }}>
169
- <AssemblerPart data={ { left_overhang: leftOverhang, right_overhang: rightOverhang }}/>
170
- </Box>
171
- )}
172
- </Box>
173
-
174
- {/* Link between boxes */}
175
- {index < assembly.length - 1 && item.category !== '' && (
176
- <AssemblerLink overhang={rightOverhang} />
177
- )}
178
-
179
- {/* Link after last box */}
180
- {index === assembly.length - 1 && item.category !== '' && (
181
- <AssemblerLink overhang={rightOverhang} />
182
- )}
183
- </React.Fragment>
184
- )
185
- })}
191
+ {options.map((item, index) =>
192
+ <AssemblerBox key={index} {...{item, index, setCategory, setId, categories, plasmids, assembly}} />
193
+ )}
186
194
  </Stack>
187
- {assemblyComplete && <>
195
+ {canBeSubmitted && <>
188
196
  <Button
189
197
  sx={{ p: 2, px: 4, my: 2, fontSize: '1.2rem' }}
190
198
  variant="contained"
191
199
  color="primary"
200
+ data-testid="assembler-submit-button"
192
201
  onClick={onSubmitAssembly}
193
202
  disabled={Boolean(loadingMessage)}>
194
203
  {loadingMessage ? <><CircularProgress /> {loadingMessage}</> : 'Submit'}
@@ -196,75 +205,159 @@ function AssemblerComponent({ data, categories }) {
196
205
  </>}
197
206
  {errorMessage && <Alert severity="error" sx={{ my: 2, maxWidth: 300, margin: 'auto', fontSize: '1.2rem' }}>{errorMessage}</Alert>}
198
207
  {requestedAssemblies.length > 0 &&
199
- <TableContainer sx={{ '& td': { fontSize: '1.2rem' }, '& th': { fontSize: '1.2rem' } }}>
200
- <Table size="small">
201
- <TableHead>
202
- <TableRow>
203
- <TableCell padding="checkbox" />
204
- {currentCategories.map(category => (
205
- <TableCell key={category} sx={{ fontWeight: 'bold' }}>
206
- {category === 'F_A' ? 'Backbone' : category}
207
- </TableCell>
208
- ))}
209
- </TableRow>
210
- </TableHead>
211
- <TableBody>
212
- {expandedAssemblies.map((parts, rowIndex) => (
213
- <TableRow key={rowIndex}>
214
- <TableCell padding="checkbox">
215
- <IconButton onClick={() => handleViewAssembly(rowIndex)} size="small">
216
- <VisibilityIcon />
217
- </IconButton>
218
- </TableCell>
219
- {parts.map((part, colIndex) => (
220
- <TableCell key={colIndex}>
221
- {formatItemName(data.find((d) => d.id === part))}
222
- </TableCell>
223
- ))}
224
-
225
-
226
- </TableRow>
227
- ))}
228
- </TableBody>
229
- </Table>
230
- </TableContainer>
208
+ <AssemblerProductTable {...{requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories}} />
231
209
  }
232
210
 
233
211
  </Box >
234
212
  )
235
213
  }
236
214
 
237
- function Assembler() {
238
- const [requestStatus, setRequestStatus] = React.useState({ status: 'loading' })
239
- const [retry, setRetry] = React.useState(0)
240
- const [data, setData] = React.useState([])
241
- const [categories, setCategories] = React.useState([])
242
- const httpClient = useHttpClient()
243
- React.useEffect(() => {
244
- setRequestStatus({ status: 'loading' })
245
- const fetchData = async () => {
246
- try {
247
- const { data } = await httpClient.get('https://assets.opencloning.org/open-dna-collections/scripts/index_overhangs.json')
248
- const formattedData = data.map((item) => ({
249
- ...item,
250
- category: data2.find((item2) => item2.overhang === item.left_overhang).name + '_' + data2.find((item2) => item2.overhang === item.right_overhang).name
251
- }))
252
-
253
- const categories = [...new Set(formattedData.map((item) => item.category))].sort()
254
- setData(formattedData)
255
- setCategories(categories)
256
- setRequestStatus({ status: 'success' })
257
- } catch (error) {
258
- setRequestStatus({ status: 'error', message: 'Could not load assembler data' })
259
- }
215
+ function displayNameFromCategory(category) {
216
+ let name = ''
217
+ if (category.name) {
218
+ name = category.name
219
+ if (category.info)
220
+ name += ` (${category.info}) `
221
+ }
222
+ if (category.left_name && category.right_name) {
223
+ name += `${category.left_name}_${category.right_name}`
224
+ }
225
+ if (name === '') {
226
+ name = category.key
227
+ }
228
+ return name.trim()
229
+ }
230
+
231
+ function categoriesFromSyntaxAndPlasmids(syntax, plasmids) {
232
+ if (!syntax) {
233
+ return []
234
+ }
235
+ const newCategories = syntax.parts.map((part) => ({
236
+ ...part,
237
+ left_name: syntax.overhangNames[part.left_overhang] || null,
238
+ right_name: syntax.overhangNames[part.right_overhang] || null,
239
+ key: `${part.left_overhang}-${part.right_overhang}`,
240
+ }))
241
+ let newCategoryKeys = newCategories.map((category) => category.key)
242
+ plasmids.forEach((plasmid) => {
243
+ if (!newCategoryKeys.includes(plasmid.key)) {
244
+ const {left_overhang, right_overhang} = plasmid
245
+ newCategories.push({
246
+ left_overhang,
247
+ right_overhang,
248
+ left_name: syntax.overhangNames[left_overhang] || null,
249
+ right_name: syntax.overhangNames[right_overhang] || null,
250
+ key: `${left_overhang}-${right_overhang}`,
251
+ })
252
+ newCategoryKeys.push(`${left_overhang}-${right_overhang}`)
260
253
  }
261
- fetchData()
254
+ })
255
+ newCategories.forEach((category, index) => {
256
+ category.id = index + 1
257
+ category.displayName = displayNameFromCategory(category)
258
+ })
259
+ return newCategories
260
+ }
261
+
262
+ function LoadSyntaxButton({ setSyntax, addPlasmids }) {
263
+ const [existingSyntaxDialogOpen, setExistingSyntaxDialogOpen] = React.useState(false)
264
+ const onSyntaxSelect = React.useCallback((syntax, plasmids) => {
265
+ setSyntax(syntax)
266
+ addPlasmids(plasmids.filter((plasmid) => plasmid.appData.correspondingParts.length === 1).map(formatPlasmid))
267
+ }, [setSyntax, addPlasmids])
268
+ return <>
269
+ <Button color="success" onClick={() => setExistingSyntaxDialogOpen(true)}>Load Syntax</Button>
270
+ {existingSyntaxDialogOpen && <ExistingSyntaxDialog onClose={() => setExistingSyntaxDialogOpen(false)} onSyntaxSelect={onSyntaxSelect}/>}
271
+ </>
272
+ }
273
+
274
+ export function UploadPlasmidsButton({ addPlasmids, syntax }) {
275
+ const { uploadPlasmids, linkedPlasmids, setLinkedPlasmids } = usePlasmidsLogic(syntax)
276
+ const validPlasmids = React.useMemo(() => linkedPlasmids.filter((plasmid) => plasmid.appData.correspondingParts.length === 1), [linkedPlasmids])
277
+ const invalidPlasmids = React.useMemo(() => linkedPlasmids.filter((plasmid) => plasmid.appData.correspondingParts.length !== 1), [linkedPlasmids])
278
+ const fileInputRef = React.useRef(null)
279
+
280
+ const handleFileChange = (event) => {
281
+ uploadPlasmids(Array.from(event.target.files))
282
+ fileInputRef.current.value = ''
283
+ }
284
+
285
+ const handleImportValidPlasmids = React.useCallback(() => {
286
+ addPlasmids(validPlasmids.map(formatPlasmid))
287
+ setLinkedPlasmids([])
288
+ }, [addPlasmids, validPlasmids, setLinkedPlasmids])
289
+
290
+ return (<>
291
+ <Button color="primary" onClick={() => fileInputRef.current.click()}>
292
+ Add Plasmids
293
+ </Button>
294
+ <input multiple type="file" ref={fileInputRef} style={{ display: 'none' }} onChange={handleFileChange} accept=".gbk,.gb,.fasta,.fa,.dna" />
295
+ <Dialog
296
+ maxWidth="lg"
297
+ fullWidth
298
+ open={invalidPlasmids.length > 0 || validPlasmids.length > 0}
299
+ onClose={() => setLinkedPlasmids([])}
300
+ PaperProps={{
301
+ style: {
302
+ maxHeight: '80vh',
303
+ },
304
+ }}
305
+ >
306
+ <DialogActions sx={{ justifyContent: 'center', position: 'sticky', top: 0, zIndex: 99, background: '#fff' }}>
307
+ <Button disabled={validPlasmids.length === 0} variant="contained" color="success" onClick={handleImportValidPlasmids}>Import valid plasmids</Button>
308
+ <Button variant="contained" color="error" onClick={() => setLinkedPlasmids([])}>Cancel</Button>
309
+ </DialogActions>
310
+ {invalidPlasmids.length > 0 && (
311
+ <Box data-testid="invalid-plasmids-box">
312
+ <DialogTitle>Invalid Plasmids</DialogTitle>
313
+ <DialogContent>
314
+ <PlasmidSyntaxTable plasmids={invalidPlasmids} />
315
+ </DialogContent>
316
+ </Box>
317
+ )}
318
+ {validPlasmids.length > 0 && (
319
+ <Box data-testid="valid-plasmids-box">
320
+ <DialogTitle>Valid Plasmids</DialogTitle>
321
+ <DialogContent>
322
+ <PlasmidSyntaxTable plasmids={validPlasmids} />
323
+ </DialogContent>
324
+ </Box>
325
+ )}
326
+ </Dialog>
327
+ </>)
328
+ }
329
+
330
+ function Assembler() {
331
+ const [syntax, setSyntax] = React.useState(null);
332
+ const [plasmids, setPlasmids] = React.useState([])
333
+
334
+ const categories = React.useMemo(() => {
335
+ return categoriesFromSyntaxAndPlasmids(syntax, plasmids)
336
+ }, [syntax, plasmids])
337
+
338
+ const addPlasmids = React.useCallback((newPlasmids) => {
339
+ setPlasmids((prevPlasmids) => {
340
+ const maxId = Math.max(...prevPlasmids.map((plasmid) => plasmid.id), 0)
341
+ return [...prevPlasmids, ...newPlasmids.map((plasmid, index) => ({ ...plasmid, id: maxId + index + 1 }))]
342
+ })
343
+ }, [])
344
+
345
+ const clearPlasmids = React.useCallback(() => {
346
+ setPlasmids(prev => prev.filter((plasmid) => plasmid.type !== 'loadedFile'))
347
+ }, [])
262
348
 
263
- }, [retry])
264
349
  return (
265
- <RequestStatusWrapper requestStatus={requestStatus} retry={() => setRetry((prev) => prev + 1)}>
266
- <AssemblerComponent data={data} categories={categories} />
267
- </RequestStatusWrapper>
350
+ <>
351
+ <Alert severity="warning" sx={{ maxWidth: '400px', margin: 'auto', fontSize: '.9rem', mb: 2 }}>
352
+ The Assembler is experimental. Use with caution.
353
+ </Alert>
354
+ <ButtonGroup>
355
+ <LoadSyntaxButton setSyntax={setSyntax} addPlasmids={addPlasmids} />
356
+ {syntax && <UploadPlasmidsButton addPlasmids={addPlasmids} syntax={syntax} />}
357
+ {syntax && <Button color="error" onClick={clearPlasmids}>Remove uploaded plasmids</Button>}
358
+ </ButtonGroup>
359
+ {syntax && <AssemblerComponent plasmids={plasmids} syntax={syntax} categories={categories} />}
360
+ </>
268
361
  )
269
362
  }
270
363
 
@@ -0,0 +1,52 @@
1
+ /* eslint-disable camelcase */
2
+ import React from 'react';
3
+ import AssemblerPart from './AssemblerPart';
4
+
5
+ describe('<AssemblerPart />', () => {
6
+ it('displays everything correctly with all values', () => {
7
+ const partData = {
8
+ left_overhang: 'CCCT',
9
+ right_overhang: 'AACG',
10
+ left_inside: 'ATGCATGC',
11
+ right_inside: 'GCATGCAT',
12
+ left_codon_start: 1,
13
+ right_codon_start: 2,
14
+ color: 'red',
15
+ glyph: 'cds',
16
+ };
17
+
18
+ cy.mount(<AssemblerPart data={partData} />);
19
+
20
+ // Check that container exists
21
+ cy.get('[class*="container"]').should('exist');
22
+
23
+ // Check that left overhang is displayed
24
+ cy.get('[data-testid="display-overhang"]').first().find('div').eq(0).should('have.text', 'ProT');
25
+ cy.get('[data-testid="display-overhang"]').first().find('div').eq(1).should('have.text', 'CCCT');
26
+ cy.get('[data-testid="display-overhang"]').first().find('div').eq(2).should('have.text', 'GGGA');
27
+ cy.get('[data-testid="display-overhang"]').first().find('div').eq(3).should('have.text', ' ');
28
+
29
+ cy.get('[data-testid="display-inside"]').first().find('div').eq(0).should('have.text', 'yrAlaCys');
30
+ cy.get('[data-testid="display-inside"]').first().find('div').eq(1).should('have.text', 'ATGCATGC');
31
+ cy.get('[data-testid="display-inside"]').first().find('div').eq(2).should('have.text', 'TACGTACG');
32
+ cy.get('[data-testid="display-inside"]').first().find('div').eq(3).should('have.text', ' ');
33
+
34
+ cy.get('[data-testid="display-inside"]').eq(1).find('div').eq(0).should('have.text', ' HisAla*');
35
+ cy.get('[data-testid="display-inside"]').eq(1).find('div').eq(1).should('have.text', 'GCATGCAT');
36
+ cy.get('[data-testid="display-inside"]').eq(1).find('div').eq(2).should('have.text', 'CGTACGTA');
37
+ cy.get('[data-testid="display-inside"]').eq(1).find('div').eq(3).should('have.text', ' ');
38
+
39
+ cy.get('[data-testid="display-overhang"]').eq(1).find('div').eq(0).should('have.text', '**');
40
+ cy.get('[data-testid="display-overhang"]').eq(1).find('div').eq(1).should('have.text', 'AACG');
41
+ cy.get('[data-testid="display-overhang"]').eq(1).find('div').eq(2).should('have.text', 'TTGC');
42
+ cy.get('[data-testid="display-overhang"]').eq(1).find('div').eq(3).should('have.text', ' ');
43
+
44
+
45
+ cy.get('img[alt="cds.svg"]').should('exist');
46
+ cy.get('img').parent().then(($el) => {
47
+ const bgColor = window.getComputedStyle($el[0]).backgroundColor;
48
+ cy.wrap(bgColor).should('equal', 'rgb(255, 0, 0)');
49
+ });
50
+
51
+ });
52
+ });