@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
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { DirectedGraph } from 'graphology';
|
|
2
|
+
import {
|
|
3
|
+
GRAPH_SPACER,
|
|
4
|
+
partsToGraph,
|
|
5
|
+
partsToEdgesGraph,
|
|
6
|
+
openCycleAtNode,
|
|
7
|
+
graphToMSA,
|
|
8
|
+
graphHasCycle
|
|
9
|
+
} from './graph_utils';
|
|
10
|
+
|
|
11
|
+
describe('GRAPH_SPACER', () => {
|
|
12
|
+
it('is the expected spacer string', () => {
|
|
13
|
+
expect(GRAPH_SPACER).toBe('---------');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('partsToGraph', () => {
|
|
18
|
+
it('creates nodes for each part', () => {
|
|
19
|
+
const parts = [
|
|
20
|
+
{ left_overhang: 'A', right_overhang: 'B' },
|
|
21
|
+
{ left_overhang: 'C', right_overhang: 'D' }
|
|
22
|
+
];
|
|
23
|
+
const graph = partsToGraph(parts);
|
|
24
|
+
expect(graph.hasNode('A-B')).toBe(true);
|
|
25
|
+
expect(graph.hasNode('C-D')).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('connects nodes that share an overhang', () => {
|
|
29
|
+
const parts = [
|
|
30
|
+
{ left_overhang: 'A', right_overhang: 'B' },
|
|
31
|
+
{ left_overhang: 'B', right_overhang: 'C' }
|
|
32
|
+
];
|
|
33
|
+
const graph = partsToGraph(parts);
|
|
34
|
+
expect(graph.hasEdge('A-B', 'B-C')).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not connect nodes that do not share an overhang', () => {
|
|
38
|
+
const parts = [
|
|
39
|
+
{ left_overhang: 'A', right_overhang: 'B' },
|
|
40
|
+
{ left_overhang: 'C', right_overhang: 'D' }
|
|
41
|
+
];
|
|
42
|
+
const graph = partsToGraph(parts);
|
|
43
|
+
expect(graph.hasEdge('A-B', 'C-D')).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('handles duplicate parts', () => {
|
|
47
|
+
const parts = [
|
|
48
|
+
{ left_overhang: 'A', right_overhang: 'B' },
|
|
49
|
+
{ left_overhang: 'A', right_overhang: 'B' }
|
|
50
|
+
];
|
|
51
|
+
const graph = partsToGraph(parts);
|
|
52
|
+
expect(graph.nodes().length).toBe(1);
|
|
53
|
+
expect(graph.hasNode('A-B')).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('handles empty parts array', () => {
|
|
57
|
+
const graph = partsToGraph([]);
|
|
58
|
+
expect(graph.nodes().length).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('creates a chain of connected nodes', () => {
|
|
62
|
+
const parts = [
|
|
63
|
+
{ left_overhang: 'A', right_overhang: 'B' },
|
|
64
|
+
{ left_overhang: 'B', right_overhang: 'C' },
|
|
65
|
+
{ left_overhang: 'C', right_overhang: 'D' }
|
|
66
|
+
];
|
|
67
|
+
const graph = partsToGraph(parts);
|
|
68
|
+
expect(graph.hasEdge('A-B', 'B-C')).toBe(true);
|
|
69
|
+
expect(graph.hasEdge('B-C', 'C-D')).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('partsToEdgesGraph', () => {
|
|
74
|
+
it('creates nodes for each unique overhang and creates edges between them', () => {
|
|
75
|
+
const parts = [
|
|
76
|
+
{ left_overhang: 'A', right_overhang: 'B' },
|
|
77
|
+
{ left_overhang: 'C', right_overhang: 'D' }
|
|
78
|
+
];
|
|
79
|
+
const graph = partsToEdgesGraph(parts);
|
|
80
|
+
expect(graph.hasNode('A')).toBe(true);
|
|
81
|
+
expect(graph.hasNode('B')).toBe(true);
|
|
82
|
+
expect(graph.hasNode('C')).toBe(true);
|
|
83
|
+
expect(graph.hasNode('D')).toBe(true);
|
|
84
|
+
expect(graph.hasEdge('A', 'B')).toBe(true);
|
|
85
|
+
expect(graph.hasEdge('C', 'D')).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('handles duplicate overhangs', () => {
|
|
89
|
+
const parts = [
|
|
90
|
+
{ left_overhang: 'A', right_overhang: 'B' },
|
|
91
|
+
{ left_overhang: 'A', right_overhang: 'B' }
|
|
92
|
+
];
|
|
93
|
+
const graph = partsToEdgesGraph(parts);
|
|
94
|
+
expect(graph.hasNode('A')).toBe(true);
|
|
95
|
+
expect(graph.hasNode('B')).toBe(true);
|
|
96
|
+
expect(graph.hasEdge('A', 'B')).toBe(true);
|
|
97
|
+
expect(graph.nodes().length).toBe(2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('handles empty parts array', () => {
|
|
101
|
+
const graph = partsToEdgesGraph([]);
|
|
102
|
+
expect(graph.nodes().length).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('openCycleAtNode', () => {
|
|
107
|
+
it('removes incoming edges to the specified node and does not affect outgoing edges', () => {
|
|
108
|
+
const graph = new DirectedGraph();
|
|
109
|
+
graph.addNode('A');
|
|
110
|
+
graph.addNode('B');
|
|
111
|
+
graph.addNode('C');
|
|
112
|
+
graph.addEdge('A', 'B');
|
|
113
|
+
graph.addEdge('B', 'C');
|
|
114
|
+
graph.addEdge('C', 'B'); // Creates cycle
|
|
115
|
+
|
|
116
|
+
expect(graph.inDegree('B')).toBe(2);
|
|
117
|
+
openCycleAtNode(graph, 'B');
|
|
118
|
+
expect(graph.inDegree('B')).toBe(0);
|
|
119
|
+
expect(graph.outDegree('B')).toBe(1);
|
|
120
|
+
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('does not affect outgoing edges', () => {
|
|
124
|
+
const graph = new DirectedGraph();
|
|
125
|
+
graph.addNode('A');
|
|
126
|
+
graph.addNode('B');
|
|
127
|
+
graph.addEdge('A', 'B');
|
|
128
|
+
graph.addEdge('B', 'A'); // Cycle
|
|
129
|
+
|
|
130
|
+
openCycleAtNode(graph, 'A');
|
|
131
|
+
expect(graph.outDegree('A')).toBe(1);
|
|
132
|
+
expect(graph.hasEdge('A', 'B')).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('handles node with no incoming edges', () => {
|
|
136
|
+
const graph = new DirectedGraph();
|
|
137
|
+
graph.addNode('A');
|
|
138
|
+
graph.addNode('B');
|
|
139
|
+
graph.addEdge('A', 'B');
|
|
140
|
+
|
|
141
|
+
openCycleAtNode(graph, 'A');
|
|
142
|
+
expect(graph.inDegree('A')).toBe(0);
|
|
143
|
+
expect(graph.hasEdge('A', 'B')).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('graphToMSA', () => {
|
|
148
|
+
it('converts a simple linear DAG to MSA', () => {
|
|
149
|
+
const graph = new DirectedGraph();
|
|
150
|
+
graph.addNode('A');
|
|
151
|
+
graph.addNode('B');
|
|
152
|
+
graph.addNode('C');
|
|
153
|
+
graph.addEdge('A', 'B');
|
|
154
|
+
graph.addEdge('B', 'C');
|
|
155
|
+
graph.addEdge('C', 'A');
|
|
156
|
+
const msa = graphToMSA(graph);
|
|
157
|
+
expect(msa.length).toBe(1);
|
|
158
|
+
expect(msa[0]).toContain('A');
|
|
159
|
+
expect(msa[0]).toContain('B');
|
|
160
|
+
expect(msa[0]).toContain('C');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('handles branching paths', () => {
|
|
164
|
+
const graph = new DirectedGraph();
|
|
165
|
+
graph.addNode('A');
|
|
166
|
+
graph.addNode('B1');
|
|
167
|
+
graph.addNode('B2');
|
|
168
|
+
graph.addNode('B3');
|
|
169
|
+
graph.addNode('C');
|
|
170
|
+
graph.addEdge('A', 'B1');
|
|
171
|
+
graph.addEdge('A', 'B2');
|
|
172
|
+
graph.addEdge('B1', 'C');
|
|
173
|
+
graph.addEdge('B2', 'B3');
|
|
174
|
+
graph.addEdge('B3', 'C');
|
|
175
|
+
graph.addEdge('C', 'A');
|
|
176
|
+
const msa = graphToMSA(graph);
|
|
177
|
+
expect(msa).toEqual([
|
|
178
|
+
['A', 'B2', 'B3', 'C'],
|
|
179
|
+
['A', 'B1', GRAPH_SPACER, 'C'],
|
|
180
|
+
]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('handles empty graph', () => {
|
|
184
|
+
const graph = new DirectedGraph();
|
|
185
|
+
const msa = graphToMSA(graph);
|
|
186
|
+
expect(msa).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('handles single node', () => {
|
|
190
|
+
const graph = new DirectedGraph();
|
|
191
|
+
graph.addNode('A');
|
|
192
|
+
const msa = graphToMSA(graph);
|
|
193
|
+
expect(msa).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
describe('graphHasCycle', () => {
|
|
199
|
+
it('returns false for acyclic graph', () => {
|
|
200
|
+
const graph = new DirectedGraph();
|
|
201
|
+
graph.addNode('A');
|
|
202
|
+
graph.addNode('B');
|
|
203
|
+
graph.addNode('C');
|
|
204
|
+
graph.addEdge('A', 'B');
|
|
205
|
+
graph.addEdge('B', 'C');
|
|
206
|
+
|
|
207
|
+
expect(graphHasCycle(graph)).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('returns true for cyclic graph', () => {
|
|
211
|
+
const graph = new DirectedGraph();
|
|
212
|
+
graph.addNode('A');
|
|
213
|
+
graph.addNode('B');
|
|
214
|
+
graph.addEdge('A', 'B');
|
|
215
|
+
graph.addEdge('B', 'A');
|
|
216
|
+
|
|
217
|
+
expect(graphHasCycle(graph)).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('handles self-loop', () => {
|
|
221
|
+
const graph = new DirectedGraph();
|
|
222
|
+
graph.addNode('A');
|
|
223
|
+
graph.addEdge('A', 'A');
|
|
224
|
+
|
|
225
|
+
expect(graphHasCycle(graph)).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('handles empty graph', () => {
|
|
229
|
+
const graph = new DirectedGraph();
|
|
230
|
+
expect(graphHasCycle(graph)).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('handles single node with no edges', () => {
|
|
234
|
+
const graph = new DirectedGraph();
|
|
235
|
+
graph.addNode('A');
|
|
236
|
+
|
|
237
|
+
expect(graphHasCycle(graph)).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { default as AssemblerPart } from './AssemblerPart';
|
|
2
|
+
export { AssemblerPartCore } from './AssemblerPart';
|
|
3
|
+
export { getSvgByGlyph } from './sbol_visual_glyphs';
|
|
4
|
+
export { DisplayOverhang, DisplayInside, AssemblerPartContainer } from './AssemblerPart';
|
|
5
|
+
export { partDataToDisplayData } from './assembler_utils.js';
|
|
6
|
+
export { partsToGraph, graphToMSA, graphHasCycle, partsToEdgesGraph, GRAPH_SPACER } from './graph_utils.js';
|
|
7
|
+
export { usePlasmidsLogic } from './usePlasmidsLogic.js';
|
|
8
|
+
export { default as PlasmidSyntaxTable } from './PlasmidSyntaxTable.jsx';
|
|
9
|
+
export { default as ExistingSyntaxDialog } from './ExistingSyntaxDialog.jsx';
|
|
@@ -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>
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Version placeholder - replaced at publish time via prepack script
|
|
2
|
-
export const version = "1.
|
|
2
|
+
export const version = "1.3.1";
|
package/vitest.config.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { defineConfig } from 'vitest/config';
|
|
2
2
|
import { resolve } from 'path';
|
|
3
|
+
import { testConfig } from '../../vitest.common.config';
|
|
3
4
|
|
|
4
5
|
export default defineConfig({
|
|
5
6
|
test: {
|
|
6
|
-
|
|
7
|
-
environment: 'jsdom',
|
|
8
|
-
setupFiles: '../../tests/setup.js',
|
|
9
|
-
include: ['src/**/*.{test,spec}.{js,jsx}'],
|
|
7
|
+
...testConfig,
|
|
10
8
|
},
|
|
11
9
|
resolve: {
|
|
12
10
|
alias: {
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { Paper } from '@mui/material';
|
|
2
|
-
import React from 'react';
|
|
3
|
-
import Draggable from 'react-draggable';
|
|
4
|
-
|
|
5
|
-
function DraggableDialogPaper(props) {
|
|
6
|
-
return (
|
|
7
|
-
<Draggable
|
|
8
|
-
handle="#draggable-dialog-title"
|
|
9
|
-
cancel={'[class*="MuiDialogContent-root"]'}
|
|
10
|
-
>
|
|
11
|
-
<Paper {...props} />
|
|
12
|
-
</Draggable>
|
|
13
|
-
);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export default DraggableDialogPaper;
|