|
| 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' |
| 4 | +import ClearIcon from '@mui/icons-material/Clear'; |
| 5 | +import { useAssembler } from './useAssembler'; |
| 6 | +import { arrayCombinations } from '../eLabFTW/utils'; |
| 7 | +import VisibilityIcon from '@mui/icons-material/Visibility'; |
| 8 | +import { useDispatch } from 'react-redux'; |
| 9 | +import { cloningActions } from '../../store/cloning'; |
| 10 | +import RequestStatusWrapper from '../form/RequestStatusWrapper'; |
| 11 | +import useHttpClient from '../../hooks/useHttpClient'; |
| 12 | + |
| 13 | + |
| 14 | +const { setState: setCloningState, setCurrentTab: setCurrentTabAction } = cloningActions; |
| 15 | + |
| 16 | +const categoryFilter = (category, previousCategory) => { |
| 17 | + if (previousCategory === '') { |
| 18 | + return category.startsWith('A_') |
| 19 | + } |
| 20 | + return previousCategory.split('_')[1] === category.split('_')[0] |
| 21 | +} |
| 22 | + |
| 23 | +function AssemblerLink({ overhang }) { |
| 24 | + 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> |
| 32 | + ) |
| 33 | +} |
| 34 | + |
| 35 | +function AssemblerComponent({ data, categories }) { |
| 36 | + |
| 37 | + const [assembly, setAssembly] = React.useState([{ category: '', id: [] }]) |
| 38 | + const { requestSources, requestAssemblies } = useAssembler() |
| 39 | + const [requestedAssemblies, setRequestedAssemblies] = React.useState([]) |
| 40 | + const [loadingMessage, setLoadingMessage] = React.useState('') |
| 41 | + const [errorMessage, setErrorMessage] = React.useState('') |
| 42 | + const dispatch = useDispatch() |
| 43 | + const onSubmitAssembly = async () => { |
| 44 | + clearAssembly() |
| 45 | + const sources = assembly.map(({ id }) => id.map((id) => (data.find((item) => item.id === id).source))) |
| 46 | + let errorMessage = 'Error fetching sequences' |
| 47 | + try { |
| 48 | + setLoadingMessage('Requesting sequences...') |
| 49 | + const resp = await requestSources(sources) |
| 50 | + errorMessage = 'Error assembling sequences' |
| 51 | + setLoadingMessage('Assembling...') |
| 52 | + const assemblies = await requestAssemblies(resp) |
| 53 | + setRequestedAssemblies(assemblies) |
| 54 | + } catch (e) { |
| 55 | + setErrorMessage(errorMessage) |
| 56 | + } finally { |
| 57 | + setLoadingMessage(false) |
| 58 | + } |
| 59 | + } |
| 60 | + |
| 61 | + const clearAssembly = () => { |
| 62 | + setRequestedAssemblies([]) |
| 63 | + setErrorMessage('') |
| 64 | + } |
| 65 | + |
| 66 | + const setCategory = (category, index) => { |
| 67 | + clearAssembly() |
| 68 | + if (category === '') { |
| 69 | + const newAssembly = assembly.slice(0, index) |
| 70 | + newAssembly[index] = { category: '', id: [] } |
| 71 | + setAssembly(newAssembly) |
| 72 | + return |
| 73 | + } |
| 74 | + setAssembly(assembly.map((item, i) => i === index ? { category, id: [] } : item)) |
| 75 | + } |
| 76 | + const setId = (idArray, index) => { |
| 77 | + clearAssembly() |
| 78 | + // Handle case where user clears all selections (empty array) |
| 79 | + if (!idArray || idArray.length === 0) { |
| 80 | + setAssembly(assembly.map((item, i) => i === index ? { ...item, id: [] } : item)) |
| 81 | + return |
| 82 | + } |
| 83 | + |
| 84 | + // For multiple selection, we need to determine the category based on the first selected item |
| 85 | + // or maintain the current category if it's already set |
| 86 | + const currentItem = assembly[index] |
| 87 | + const firstOption = data.find((item) => item.id === idArray[0]) |
| 88 | + const category = currentItem.category || firstOption?.category || '' |
| 89 | + |
| 90 | + setAssembly(assembly.map((item, i) => i === index ? { id: idArray, category } : item)) |
| 91 | + } |
| 92 | + |
| 93 | + const handleViewAssembly = (index) => { |
| 94 | + const newState = requestedAssemblies[index] |
| 95 | + dispatch(setCloningState(newState)) |
| 96 | + dispatch(setCurrentTabAction(0)) |
| 97 | + } |
| 98 | + |
| 99 | + React.useEffect(() => { |
| 100 | + const lastPosition = assembly.length - 1 |
| 101 | + if (assembly[lastPosition].category.endsWith('A')) { |
| 102 | + return |
| 103 | + } |
| 104 | + if (assembly[lastPosition].category !== '') { |
| 105 | + const newAssembly = [...assembly, { category: '', id: [] }] |
| 106 | + setAssembly(newAssembly) |
| 107 | + } |
| 108 | + }, [assembly]) |
| 109 | + |
| 110 | + const expandedAssemblies = arrayCombinations(assembly.map(({ id }) => id)) |
| 111 | + const assemblyComplete = assembly.every((item) => item.category !== '' && item.id.length > 0) |
| 112 | + const currentCategories = assembly.map((item) => item.category) |
| 113 | + |
| 114 | + return ( |
| 115 | + <Box className="assembler-container" sx={{ width: '80%', margin: 'auto', mb: 4 }}> |
| 116 | + <Alert severity="warning" sx={{ maxWidth: '400px', margin: 'auto', fontSize: '.9rem' }}> |
| 117 | + The Assembler is experimental. Use with caution. |
| 118 | + </Alert> |
| 119 | + |
| 120 | + <Stack direction="row" alignItems="center" spacing={1} sx={{ overflowX: 'auto', my: 2 }}> |
| 121 | + {assembly.map((item, index) => { |
| 122 | + const allowedCategories = item.category ? [item.category] : categories.filter((category) => categoryFilter(category, index === 0 ? '' : assembly[index - 1].category)) |
| 123 | + const isCompleted = item.category !== '' && item.id.length > 0 |
| 124 | + const borderColor = isCompleted ? 'success.main' : 'primary.main' |
| 125 | + |
| 126 | + return ( |
| 127 | + <React.Fragment key={index}> |
| 128 | + {/* Link before first box */} |
| 129 | + {index === 0 && item.category !== '' && ( |
| 130 | + <AssemblerLink overhang={data.find((d) => d.category === item.category).left_overhang} /> |
| 131 | + )} |
| 132 | + |
| 133 | + <Box sx={{ width: '250px', border: 3, borderColor, borderRadius: 4, p: 2 }}> |
| 134 | + <FormControl fullWidth sx={{ mb: 2 }}> |
| 135 | + <InputLabel>Category</InputLabel> |
| 136 | + <Select |
| 137 | + endAdornment={item.category && (<InputAdornment position="end"><IconButton onClick={() => setCategory('', index)}><ClearIcon /></IconButton></InputAdornment>)} |
| 138 | + value={item.category} |
| 139 | + onChange={(e) => setCategory(e.target.value, index)} |
| 140 | + label="Category" |
| 141 | + disabled={index < assembly.length - 1} |
| 142 | + > |
| 143 | + {allowedCategories.map((category) => ( |
| 144 | + <MenuItem value={category}>{category === 'F_A' ? 'Backbone' : category}</MenuItem> |
| 145 | + ))} |
| 146 | + </Select> |
| 147 | + </FormControl> |
| 148 | + <FormControl fullWidth> |
| 149 | + <Autocomplete |
| 150 | + multiple |
| 151 | + value={item.id} |
| 152 | + onChange={(e, value) => setId(value, index)} |
| 153 | + label="ID" |
| 154 | + options={data.filter((d) => allowedCategories.includes(d.category)).map((item) => item.id)} |
| 155 | + renderInput={(params) => <TextField {...params} label="ID" />} |
| 156 | + /> |
| 157 | + </FormControl> |
| 158 | + </Box> |
| 159 | + |
| 160 | + {/* Link between boxes */} |
| 161 | + {index < assembly.length - 1 && item.category !== '' && ( |
| 162 | + <AssemblerLink overhang={data.find((d) => d.category === item.category).right_overhang} /> |
| 163 | + )} |
| 164 | + |
| 165 | + {/* Link after last box */} |
| 166 | + {index === assembly.length - 1 && item.category !== '' && ( |
| 167 | + <AssemblerLink overhang={data.find((d) => d.category === item.category).right_overhang} /> |
| 168 | + )} |
| 169 | + </React.Fragment> |
| 170 | + ) |
| 171 | + })} |
| 172 | + </Stack> |
| 173 | + {assemblyComplete && <> |
| 174 | + <Button |
| 175 | + sx={{ p: 2, px: 4, my: 2, fontSize: '1.2rem' }} |
| 176 | + variant="contained" |
| 177 | + color="primary" |
| 178 | + onClick={onSubmitAssembly} |
| 179 | + disabled={Boolean(loadingMessage)}> |
| 180 | + {loadingMessage ? <><CircularProgress /> {loadingMessage}</> : 'Submit'} |
| 181 | + </Button> |
| 182 | + </>} |
| 183 | + {errorMessage && <Alert severity="error" sx={{ my: 2, maxWidth: 300, margin: 'auto', fontSize: '1.2rem' }}>{errorMessage}</Alert>} |
| 184 | + {requestedAssemblies.length > 0 && |
| 185 | + <TableContainer sx={{ '& td': { fontSize: '1.2rem' }, '& th': { fontSize: '1.2rem' } }}> |
| 186 | + <Table size="small"> |
| 187 | + <TableHead> |
| 188 | + <TableRow> |
| 189 | + <TableCell padding="checkbox" /> |
| 190 | + {currentCategories.map(category => ( |
| 191 | + <TableCell key={category} sx={{ fontWeight: 'bold' }}> |
| 192 | + {category === 'F_A' ? 'Backbone' : category} |
| 193 | + </TableCell> |
| 194 | + ))} |
| 195 | + </TableRow> |
| 196 | + </TableHead> |
| 197 | + <TableBody> |
| 198 | + {expandedAssemblies.map((parts, rowIndex) => ( |
| 199 | + <TableRow key={rowIndex}> |
| 200 | + <TableCell padding="checkbox"> |
| 201 | + <IconButton onClick={() => handleViewAssembly(rowIndex)} size="small"> |
| 202 | + <VisibilityIcon /> |
| 203 | + </IconButton> |
| 204 | + </TableCell> |
| 205 | + {parts.map((part, colIndex) => ( |
| 206 | + <TableCell key={colIndex}> |
| 207 | + {part} |
| 208 | + </TableCell> |
| 209 | + ))} |
| 210 | + |
| 211 | + |
| 212 | + </TableRow> |
| 213 | + ))} |
| 214 | + </TableBody> |
| 215 | + </Table> |
| 216 | + </TableContainer> |
| 217 | + } |
| 218 | + |
| 219 | + </Box > |
| 220 | + ) |
| 221 | +} |
| 222 | + |
| 223 | +function Assembler() { |
| 224 | + const [requestStatus, setRequestStatus] = React.useState({ status: 'loading' }) |
| 225 | + const [retry, setRetry] = React.useState(0) |
| 226 | + const [data, setData] = React.useState([]) |
| 227 | + const [categories, setCategories] = React.useState([]) |
| 228 | + const httpClient = useHttpClient() |
| 229 | + React.useEffect(() => { |
| 230 | + setRequestStatus({ status: 'loading' }) |
| 231 | + const fetchData = async () => { |
| 232 | + try { |
| 233 | + const { data } = await httpClient.get('https://assets.opencloning.org/open-dna-collections/scripts/index_overhangs.json') |
| 234 | + const formattedData = data.map((item) => ({ |
| 235 | + ...item, |
| 236 | + category: data2.find((item2) => item2.overhang === item.left_overhang).name + '_' + data2.find((item2) => item2.overhang === item.right_overhang).name |
| 237 | + })) |
| 238 | + |
| 239 | + const categories = [...new Set(formattedData.map((item) => item.category))].sort() |
| 240 | + setData(formattedData) |
| 241 | + setCategories(categories) |
| 242 | + setRequestStatus({ status: 'success' }) |
| 243 | + } catch (error) { |
| 244 | + setRequestStatus({ status: 'error', message: 'Could not load assembler data' }) |
| 245 | + } |
| 246 | + } |
| 247 | + fetchData() |
| 248 | + |
| 249 | + }, [retry]) |
| 250 | + return ( |
| 251 | + <RequestStatusWrapper requestStatus={requestStatus} retry={() => setRetry((prev) => prev + 1)}> |
| 252 | + <AssemblerComponent data={data} categories={categories} /> |
| 253 | + </RequestStatusWrapper> |
| 254 | + ) |
| 255 | +} |
| 256 | + |
| 257 | +export default Assembler |
0 commit comments