@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.
- package/CHANGELOG.md +27 -0
- package/package.json +4 -3
- package/src/components/assembler/Assembler.cy.jsx +364 -0
- package/src/components/assembler/Assembler.jsx +298 -205
- package/src/components/assembler/AssemblerPart.cy.jsx +52 -0
- package/src/components/assembler/AssemblerPart.jsx +51 -79
- package/src/components/assembler/ExistingSyntaxDialog.cy.jsx +251 -0
- package/src/components/assembler/ExistingSyntaxDialog.jsx +104 -0
- package/src/components/assembler/PlasmidSyntaxTable.jsx +83 -0
- package/src/components/assembler/assembler_utils.js +134 -0
- package/src/components/assembler/assembler_utils.test.js +193 -0
- package/src/components/assembler/assembly_component.module.css +1 -1
- package/src/components/assembler/graph_utils.js +153 -0
- package/src/components/assembler/graph_utils.test.js +239 -0
- package/src/components/assembler/index.js +9 -0
- package/src/components/assembler/useAssembler.js +59 -22
- package/src/components/assembler/useCombinatorialAssembly.js +76 -0
- package/src/components/assembler/usePlasmidsLogic.js +82 -0
- package/src/components/eLabFTW/utils.js +0 -9
- package/src/components/index.js +2 -0
- package/src/components/navigation/SelectTemplateDialog.jsx +0 -1
- package/src/components/primers/DownloadPrimersButton.jsx +0 -1
- package/src/components/primers/PrimerList.jsx +4 -3
- package/src/components/primers/import_primers/ImportPrimersButton.jsx +0 -1
- package/src/version.js +1 -1
- package/vitest.config.js +2 -4
- package/src/components/DraggableDialogPaper.jsx +0 -16
- package/src/components/assembler/AssemblePartWidget.jsx +0 -252
- package/src/components/assembler/StopIcon.jsx +0 -34
- package/src/components/assembler/assembler_data2.json +0 -50
- package/src/components/assembler/moclo.json +0 -110
|
@@ -1,194 +1,203 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import
|
|
3
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
52
|
-
const
|
|
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(
|
|
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
|
|
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
|
-
{
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
<
|
|
267
|
-
|
|
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
|
+
});
|