Skip to content

Commit 8a7d93c

Browse files
authored
Assembler - experimental feature (#527)
* WIP towards assembler * Allow multiple options * better style * add linker, will need refactor * submission of assemblies working * working version * remove console.log * fetch data instead of hard-code it for Assembler * remove assembler from config files
1 parent ccbef59 commit 8a7d93c

File tree

10 files changed

+399
-20
lines changed

10 files changed

+399
-20
lines changed

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,5 @@ ENV BACKEND_URL=http://127.0.0.1:8000
3131
ENV DATABASE=""
3232
ENV SHOW_APP_BAR="true"
3333
ENV NO_EXTERNAL_REQUESTS="false"
34+
ENV ENABLE_ASSEMBLER="false"
3435
CMD ["sh", "docker_entrypoint.sh"]

nginx/nginx.conf

Lines changed: 0 additions & 17 deletions
This file was deleted.

public/config.env.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
"backendUrl": "$BACKEND_URL",
33
"database": "$DATABASE",
44
"showAppBar": $SHOW_APP_BAR,
5-
"noExternalRequests": $NO_EXTERNAL_REQUESTS
5+
"noExternalRequests": $NO_EXTERNAL_REQUESTS,
6+
"enableAssembler": $ENABLE_ASSEMBLER
67
}

src/components/OpenCloning.jsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import DataModelDisplayer from './DataModelDisplayer';
1111
import CloningHistory from './CloningHistory';
1212
import SequenceTab from './SequenceTab';
1313
import AppAlerts from './AppAlerts';
14+
import Assembler from './assembler/Assembler';
1415

1516
const { setCurrentTab } = cloningActions;
1617

@@ -20,6 +21,7 @@ function OpenCloning() {
2021
const tabPanelsRef = useRef(null);
2122
const [smallDevice, setSmallDevice] = useState(window.innerWidth < 600);
2223
const hasAppBar = useSelector((state) => state.cloning.config.showAppBar, isEqual);
24+
const enableAssembler = useSelector((state) => state.cloning.config.enableAssembler);
2325

2426
React.useEffect(() => {
2527
const handleResize = () => {
@@ -57,6 +59,7 @@ function OpenCloning() {
5759
<CustomTab label="Description" index={2} />
5860
<CustomTab label="Sequence" index={3} />
5961
<CustomTab label="Data model" index={4} />
62+
{enableAssembler && <CustomTab label="Assembler" index={5} />}
6063
</Tabs>
6164
<div className="tab-panels-container" ref={tabPanelsRef}>
6265
<TabPanel index={1} value={currentTab} className="primer-tab-pannel">
@@ -83,6 +86,9 @@ function OpenCloning() {
8386
<TabPanel index={4} value={currentTab} className="data-model-tab-pannel">
8487
<DataModelDisplayer />
8588
</TabPanel>
89+
{enableAssembler && <TabPanel index={5} value={currentTab} className="assembler-tab-pannel">
90+
<Assembler />
91+
</TabPanel>}
8692
</div>
8793
</div>
8894
);
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
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
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
[
2+
{
3+
"name": "A",
4+
"overhang": "GGAG"
5+
},
6+
{
7+
"name": "B",
8+
"overhang": "TACT"
9+
},
10+
{
11+
"name": "T1",
12+
"overhang": "CCAT"
13+
},
14+
{
15+
"name": "T2",
16+
"overhang": "GTCA"
17+
},
18+
{
19+
"name": "T3",
20+
"overhang": "TCCA"
21+
},
22+
{
23+
"name": "C",
24+
"overhang": "AATG"
25+
},
26+
{
27+
"name": "D",
28+
"overhang": "AGGT"
29+
},
30+
{
31+
"name": "T4",
32+
"overhang": "TTCG"
33+
},
34+
{
35+
"name": "T5",
36+
"overhang": "CGGC"
37+
},
38+
{
39+
"name": "E",
40+
"overhang": "GCTT"
41+
},
42+
{
43+
"name": "F",
44+
"overhang": "CGCT"
45+
},
46+
{
47+
"name": "backbone",
48+
"overhang": "GGAG"
49+
}
50+
]

0 commit comments

Comments
 (0)