@opencloning/ui 1.1.2 → 1.3.0
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 +36 -0
- package/package.json +5 -4
- package/src/components/MainSequenceEditor.jsx +2 -0
- package/src/components/assembler/Assembler.cy.jsx +364 -0
- package/src/components/assembler/Assembler.jsx +298 -206
- package/src/components/assembler/AssemblerPart.cy.jsx +52 -0
- package/src/components/assembler/AssemblerPart.jsx +51 -79
- package/src/components/assembler/ExistingSyntaxDialog.cy.jsx +194 -0
- package/src/components/assembler/ExistingSyntaxDialog.jsx +65 -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/components/primers/primer_design/SequenceTabComponents/PrimerDesignContext.jsx +110 -91
- package/src/components/primers/primer_design/SequenceTabComponents/PrimerDesignGatewayBP.jsx +1 -1
- package/src/components/primers/primer_design/SequenceTabComponents/PrimerDesignGibsonAssembly.jsx +2 -2
- package/src/components/primers/primer_design/SequenceTabComponents/PrimerDesignHomologousRecombination.jsx +1 -1
- package/src/components/primers/primer_design/SequenceTabComponents/PrimerDesignRestriction.jsx +1 -1
- package/src/components/primers/primer_design/SequenceTabComponents/PrimerDesignSimplePair.jsx +1 -1
- package/src/components/primers/primer_design/SequenceTabComponents/PrimerSpacerForm.jsx +5 -22
- package/src/components/primers/primer_design/SequenceTabComponents/TabPannelSettings.jsx +6 -4
- package/src/hooks/useStoreEditor.js +4 -0
- 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
|
@@ -2,9 +2,31 @@ import { useCallback } from 'react'
|
|
|
2
2
|
import { classNameToEndPointMap } from '@opencloning/utils/sourceFunctions'
|
|
3
3
|
import useBackendRoute from '../../hooks/useBackendRoute'
|
|
4
4
|
import useHttpClient from '../../hooks/useHttpClient'
|
|
5
|
-
import { arrayCombinations } from '
|
|
5
|
+
import { arrayCombinations } from './assembler_utils'
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
function formatLoadedFile(plasmid, id) {
|
|
9
|
+
return {
|
|
10
|
+
source: {
|
|
11
|
+
id,
|
|
12
|
+
type: 'UploadedFileSource',
|
|
13
|
+
input: [],
|
|
14
|
+
sequence_file_format: "genbank",
|
|
15
|
+
file_name: plasmid.file_name,
|
|
16
|
+
index_in_file: 0,
|
|
17
|
+
},
|
|
18
|
+
sequence: {
|
|
19
|
+
id,
|
|
20
|
+
type: 'TextFileSequence',
|
|
21
|
+
sequence_file_format: "genbank",
|
|
22
|
+
overhang_crick_3prime: 0,
|
|
23
|
+
overhang_watson_3prime: 0,
|
|
24
|
+
file_content: plasmid.genbankString,
|
|
25
|
+
},
|
|
26
|
+
plasmid: plasmid,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
8
30
|
export const useAssembler = () => {
|
|
9
31
|
const httpClient = useHttpClient();
|
|
10
32
|
const backendRoute = useBackendRoute();
|
|
@@ -12,25 +34,35 @@ export const useAssembler = () => {
|
|
|
12
34
|
const requestSources = useCallback(async (assemblerOutput) => {
|
|
13
35
|
const processedOutput = []
|
|
14
36
|
for (let pos = 0; pos < assemblerOutput.length; pos++) {
|
|
15
|
-
|
|
16
|
-
|
|
37
|
+
processedOutput.push([])
|
|
38
|
+
for (let plasmid of assemblerOutput[pos]) {
|
|
39
|
+
try {
|
|
40
|
+
if (plasmid.type === 'loadedFile') {
|
|
41
|
+
processedOutput[pos].push(formatLoadedFile(plasmid, pos + 1))
|
|
42
|
+
} else {
|
|
43
|
+
const { source } = plasmid;
|
|
17
44
|
const url = backendRoute(classNameToEndPointMap[source.type])
|
|
18
45
|
const {data} = await httpClient.post(url, source)
|
|
19
46
|
if (data.sources.length !== 1) {
|
|
20
|
-
|
|
47
|
+
console.error('Expected 1 source, got ' + data.sources.length)
|
|
21
48
|
}
|
|
22
49
|
const thisData = {
|
|
23
|
-
|
|
24
|
-
|
|
50
|
+
source: {...data.sources[0], id: pos + 1},
|
|
51
|
+
sequence: {...data.sequences[0], id: pos + 1},
|
|
52
|
+
plasmid: plasmid,
|
|
25
53
|
}
|
|
26
54
|
processedOutput[pos].push(thisData)
|
|
55
|
+
}} catch (e) {
|
|
56
|
+
e.plasmid = plasmid;
|
|
57
|
+
throw e;
|
|
27
58
|
}
|
|
59
|
+
}
|
|
28
60
|
}
|
|
29
61
|
return processedOutput
|
|
30
|
-
}, [])
|
|
62
|
+
}, [ httpClient, backendRoute ])
|
|
31
63
|
|
|
32
64
|
|
|
33
|
-
const requestAssemblies = useCallback(async (requestedSources) => {
|
|
65
|
+
const requestAssemblies = useCallback(async (requestedSources, enzyme='BsaI') => {
|
|
34
66
|
|
|
35
67
|
const assemblies = arrayCombinations(requestedSources);
|
|
36
68
|
const output = []
|
|
@@ -43,28 +75,33 @@ export const useAssembler = () => {
|
|
|
43
75
|
}
|
|
44
76
|
const requestData = {
|
|
45
77
|
source: {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
78
|
+
type: 'RestrictionAndLigationSource',
|
|
79
|
+
restriction_enzymes: [enzyme],
|
|
80
|
+
id: assembly.length + 1,
|
|
49
81
|
},
|
|
50
82
|
sequences: assembly.map((p) => p.sequence),
|
|
51
83
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
84
|
+
try {
|
|
85
|
+
const {data} = await httpClient.post(url, requestData, config)
|
|
86
|
+
const thisSource = data.sources[0]
|
|
87
|
+
const thisSequence = data.sequences[0]
|
|
88
|
+
thisSource.id = assembly.length + 1
|
|
89
|
+
thisSequence.id = assembly.length + 1
|
|
57
90
|
|
|
58
91
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
92
|
+
const cloningStrategy = {
|
|
93
|
+
sources: [thisSource, ...assembly.map((p) => p.source)],
|
|
94
|
+
sequences: [thisSequence, ...assembly.map((p) => p.sequence)],
|
|
95
|
+
primers: [],
|
|
96
|
+
}
|
|
97
|
+
output.push(cloningStrategy)
|
|
98
|
+
} catch (e) {
|
|
99
|
+
e.assembly = assembly;
|
|
100
|
+
throw e;
|
|
63
101
|
}
|
|
64
|
-
output.push(cloningStrategy)
|
|
65
102
|
}
|
|
66
103
|
return output;
|
|
67
|
-
}, [])
|
|
104
|
+
}, [ httpClient, backendRoute ])
|
|
68
105
|
|
|
69
106
|
return { requestSources, requestAssemblies }
|
|
70
107
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { arrayCombinations, categoryFilter } from './assembler_utils'
|
|
3
|
+
|
|
4
|
+
function isAssemblyComplete(assembly, categories) {
|
|
5
|
+
const lastPosition = assembly.length - 1
|
|
6
|
+
if (lastPosition === -1) {
|
|
7
|
+
return false
|
|
8
|
+
}
|
|
9
|
+
const lastCategory = categories.find((category) => category.id === assembly[lastPosition].category)
|
|
10
|
+
return lastCategory?.right_overhang === categories[0].left_overhang
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
export default function useCombinatorialAssembly( { onValueChange, categories, plasmids }) {
|
|
15
|
+
|
|
16
|
+
const [assembly, setAssembly] = React.useState([])
|
|
17
|
+
|
|
18
|
+
const setCategory = React.useCallback((category, index) => {
|
|
19
|
+
onValueChange()
|
|
20
|
+
if (category === '') {
|
|
21
|
+
setAssembly(prev => prev.slice(0, index))
|
|
22
|
+
} else {
|
|
23
|
+
setAssembly(prev => [...prev.slice(0, index), { category, plasmidIds: [] }])
|
|
24
|
+
}
|
|
25
|
+
}, [onValueChange])
|
|
26
|
+
|
|
27
|
+
const setId = React.useCallback((plasmidIds, index) => {
|
|
28
|
+
onValueChange()
|
|
29
|
+
// Handle case where user clears all selections (empty array)
|
|
30
|
+
const value = (!plasmidIds || plasmidIds.length === 0) ? [] : plasmidIds
|
|
31
|
+
setAssembly( prev => prev.map((item, i) => i === index ? { ...item, plasmidIds: value } : item))
|
|
32
|
+
}, [onValueChange])
|
|
33
|
+
|
|
34
|
+
// If the next category of the assembly can only be one, add it to the assembly
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
const newAssembly = [...assembly]
|
|
37
|
+
while (true) {
|
|
38
|
+
if (isAssemblyComplete(newAssembly, categories)) {
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
let lastPosition = newAssembly.length - 1
|
|
42
|
+
const previousCategoryId = lastPosition === -1 ? null : newAssembly[lastPosition].category
|
|
43
|
+
let nextCategories = categories.filter((category) => categoryFilter(category, categories, previousCategoryId))
|
|
44
|
+
if (nextCategories.length !== 1) {
|
|
45
|
+
break
|
|
46
|
+
} else if (nextCategories.length === 1) {
|
|
47
|
+
newAssembly.push({ category: nextCategories[0].id, plasmidIds: [] })
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (newAssembly.length !== assembly.length) {
|
|
51
|
+
setAssembly(newAssembly)
|
|
52
|
+
}
|
|
53
|
+
}, [assembly, categories])
|
|
54
|
+
|
|
55
|
+
// If plasmids are removed, remove them from the assembly
|
|
56
|
+
React.useEffect(() => {
|
|
57
|
+
onValueChange()
|
|
58
|
+
setAssembly(prev => {
|
|
59
|
+
const existingPlasmidIds = plasmids.map((plasmid) => plasmid.id)
|
|
60
|
+
return prev.map((item) => ({ ...item, plasmidIds: item.plasmidIds.filter((id) => existingPlasmidIds.includes(id)) }))
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
}, [plasmids, onValueChange])
|
|
64
|
+
|
|
65
|
+
// If categories are changed, clear the assembly
|
|
66
|
+
React.useEffect(() => {
|
|
67
|
+
setAssembly([])
|
|
68
|
+
}, [categories])
|
|
69
|
+
|
|
70
|
+
const expandedAssemblies = React.useMemo(() => arrayCombinations(assembly.map(({ plasmidIds }) => plasmidIds)), [assembly])
|
|
71
|
+
const assemblyComplete = isAssemblyComplete(assembly, categories);
|
|
72
|
+
const canBeSubmitted = assemblyComplete && assembly.every((item) => item.plasmidIds.length > 0)
|
|
73
|
+
const currentCategories = React.useMemo(() => assembly.map((item) => item.category), [assembly])
|
|
74
|
+
|
|
75
|
+
return React.useMemo(() => ({ assembly, setCategory, setId, expandedAssemblies, assemblyComplete, canBeSubmitted, currentCategories }), [assembly, setCategory, setId, expandedAssemblies, assemblyComplete, canBeSubmitted, currentCategories])
|
|
76
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { anyToJson } from '@teselagen/bio-parsers';
|
|
3
|
+
import { partsToEdgesGraph } from '@opencloning/ui/components/assembler';
|
|
4
|
+
import { assignSequenceToSyntaxPart } from './assembler_utils';
|
|
5
|
+
import { aliasedEnzymesByName } from '@teselagen/sequence-utils';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Custom hook that manages plasmids state and logic
|
|
9
|
+
* @param {Object} params - Dependencies from FormDataContext
|
|
10
|
+
* @param {Array} params.parts - Array of parts
|
|
11
|
+
* @param {string} params.assemblyEnzyme - Assembly enzyme name
|
|
12
|
+
* @param {Object} params.overhangNames - Mapping of overhangs to names
|
|
13
|
+
* @returns {Object} - { linkedPlasmids, setLinkedPlasmids, uploadPlasmids }
|
|
14
|
+
*/
|
|
15
|
+
export function usePlasmidsLogic({ parts, assemblyEnzyme, overhangNames }) {
|
|
16
|
+
const [linkedPlasmids, setLinkedPlasmidsState] = React.useState([]);
|
|
17
|
+
|
|
18
|
+
const graphForPlasmids = React.useMemo(() => partsToEdgesGraph(parts), [parts]);
|
|
19
|
+
const partDictionary = React.useMemo(() => parts.reduce((acc, part) => {
|
|
20
|
+
acc[`${part.left_overhang}-${part.right_overhang}`] = part;
|
|
21
|
+
return acc;
|
|
22
|
+
}, {}), [parts]);
|
|
23
|
+
|
|
24
|
+
const assignPlasmids = React.useCallback((plasmids) => plasmids.map(plasmid => {
|
|
25
|
+
const enzymes = [aliasedEnzymesByName[assemblyEnzyme.toLowerCase()]];
|
|
26
|
+
const correspondingParts = assignSequenceToSyntaxPart(plasmid, enzymes, graphForPlasmids);
|
|
27
|
+
const correspondingPartsStr = correspondingParts.map(part => `${part.left_overhang}-${part.right_overhang}`);
|
|
28
|
+
const correspondingPartsNames = correspondingParts.map(part => {
|
|
29
|
+
let namePart = '';
|
|
30
|
+
const leftName = overhangNames[part.left_overhang];
|
|
31
|
+
const rightName = overhangNames[part.right_overhang];
|
|
32
|
+
if (leftName || rightName) {
|
|
33
|
+
namePart = `${leftName || part.left_overhang}-${rightName || part.right_overhang}`;
|
|
34
|
+
}
|
|
35
|
+
return namePart;
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
...plasmid,
|
|
39
|
+
appData: {
|
|
40
|
+
...plasmid.appData,
|
|
41
|
+
correspondingParts: correspondingPartsStr,
|
|
42
|
+
partInfo: correspondingPartsStr.map(partStr => partDictionary[partStr]),
|
|
43
|
+
correspondingPartsNames: correspondingPartsNames,
|
|
44
|
+
longestFeature: correspondingParts.map(part => part.longestFeature)
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}), [graphForPlasmids, partDictionary, assemblyEnzyme, overhangNames]);
|
|
48
|
+
|
|
49
|
+
// Wrapper for setLinkedPlasmids that automatically assigns plasmids if enzyme is available
|
|
50
|
+
const setLinkedPlasmids = React.useCallback((plasmids) => {
|
|
51
|
+
if (assemblyEnzyme && Array.isArray(plasmids) && plasmids.length > 0) {
|
|
52
|
+
setLinkedPlasmidsState(assignPlasmids(plasmids));
|
|
53
|
+
} else {
|
|
54
|
+
setLinkedPlasmidsState(plasmids);
|
|
55
|
+
}
|
|
56
|
+
}, [assemblyEnzyme, assignPlasmids]);
|
|
57
|
+
|
|
58
|
+
// Update existing plasmids when enzyme or assignment logic changes
|
|
59
|
+
React.useEffect(() => {
|
|
60
|
+
if (assemblyEnzyme) {
|
|
61
|
+
setLinkedPlasmidsState((prevLinkedPlasmids) => {
|
|
62
|
+
if (prevLinkedPlasmids.length > 0) {
|
|
63
|
+
return assignPlasmids(prevLinkedPlasmids);
|
|
64
|
+
}
|
|
65
|
+
return prevLinkedPlasmids;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}, [assignPlasmids, assemblyEnzyme]); // Note: intentionally not including linkedPlasmids to avoid infinite loop
|
|
69
|
+
|
|
70
|
+
const uploadPlasmids = React.useCallback(async (files) => {
|
|
71
|
+
const plasmids = await Promise.all(files.map(async (file) => {
|
|
72
|
+
const data = await anyToJson(file);
|
|
73
|
+
const sequenceData = data[0].parsedSequence;
|
|
74
|
+
// Force circular
|
|
75
|
+
sequenceData.circular = true;
|
|
76
|
+
return {...sequenceData, appData: { fileName: file.name, correspondingParts: [], partInfo: [], correspondingPartsNames: [] } };
|
|
77
|
+
}));
|
|
78
|
+
setLinkedPlasmids(plasmids);
|
|
79
|
+
}, [setLinkedPlasmids]);
|
|
80
|
+
|
|
81
|
+
return { linkedPlasmids, setLinkedPlasmids, uploadPlasmids };
|
|
82
|
+
}
|
|
@@ -28,12 +28,3 @@ export const getFileFromELabFTW = async (itemId, fileInfo) => {
|
|
|
28
28
|
throw new Error(`${error2String(e)}`);
|
|
29
29
|
}
|
|
30
30
|
};
|
|
31
|
-
|
|
32
|
-
export function arrayCombinations(sets) {
|
|
33
|
-
if (sets.length === 1) {
|
|
34
|
-
return sets[0].map((el) => [el]);
|
|
35
|
-
} else
|
|
36
|
-
return sets[0].flatMap((val) =>
|
|
37
|
-
arrayCombinations(sets.slice(1)).map((c) => [val].concat(c))
|
|
38
|
-
);
|
|
39
|
-
};
|
package/src/components/index.js
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { default as OpenCloning } from './OpenCloning';
|
|
2
2
|
export { default as MainAppBar } from './navigation/MainAppBar';
|
|
3
|
+
export { default as useUrlParamsLoader } from '../hooks/useUrlParamsLoader';
|
|
4
|
+
export { default as useInitializeApp } from '../hooks/useInitializeApp';
|
|
@@ -11,7 +11,6 @@ function SelectTemplateDialog({ onClose, open }) {
|
|
|
11
11
|
const baseUrl = 'https://assets.opencloning.org/OpenCloning-submission';
|
|
12
12
|
const httpClient = useHttpClient();
|
|
13
13
|
|
|
14
|
-
// const baseUrl = '';
|
|
15
14
|
React.useEffect(() => {
|
|
16
15
|
const fetchData = async () => {
|
|
17
16
|
try {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
|
3
|
-
import { Button } from '@mui/material';
|
|
3
|
+
import { Button, ButtonGroup } from '@mui/material';
|
|
4
4
|
import PrimerForm from './PrimerForm';
|
|
5
5
|
import PrimerTableRow from './PrimerTableRow';
|
|
6
6
|
import './PrimerList.css';
|
|
@@ -99,8 +99,8 @@ function PrimerList() {
|
|
|
99
99
|
/>
|
|
100
100
|
)) || (
|
|
101
101
|
<div className="primer-add-container">
|
|
102
|
+
<ButtonGroup>
|
|
102
103
|
<Button
|
|
103
|
-
variant="contained"
|
|
104
104
|
onClick={switchAddingPrimer}
|
|
105
105
|
>
|
|
106
106
|
Add Primer
|
|
@@ -109,12 +109,13 @@ function PrimerList() {
|
|
|
109
109
|
<DownloadPrimersButton primers={primers} />
|
|
110
110
|
{database && (
|
|
111
111
|
<Button
|
|
112
|
-
variant="contained"
|
|
113
112
|
onClick={() => setImportingPrimer(true)}
|
|
114
113
|
>
|
|
115
114
|
{`Import from ${database.name}`}
|
|
116
115
|
</Button>
|
|
116
|
+
|
|
117
117
|
)}
|
|
118
|
+
</ButtonGroup>
|
|
118
119
|
</div>
|
|
119
120
|
)}
|
|
120
121
|
</div>
|
|
@@ -52,7 +52,6 @@ function ImportPrimersButton({ addPrimer }) {
|
|
|
52
52
|
<Tooltip arrow title={<span style={{ fontSize: '1.4em' }}>Upload a .csv or .tsv file with headers 'name' and 'sequence'</span>}>
|
|
53
53
|
<Button
|
|
54
54
|
onClick={handleUploadClick}
|
|
55
|
-
variant="contained"
|
|
56
55
|
>
|
|
57
56
|
Import from file
|
|
58
57
|
</Button>
|