@opencloning/store 1.0.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 +11 -0
- package/package.json +28 -0
- package/src/cloning.js +456 -0
- package/src/cloning_utils.js +298 -0
- package/src/cloning_utils.test.js +66 -0
- package/src/index.js +34 -0
- package/vitest.config.js +17 -0
package/CHANGELOG.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opencloning/store",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js",
|
|
8
|
+
"./cloning": "./src/cloning.js",
|
|
9
|
+
"./cloning_utils": "./src/cloning_utils.js"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/manulera/OpenCloning_frontend.git",
|
|
14
|
+
"directory": "packages/store"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@reduxjs/toolkit": "^1.9.7",
|
|
18
|
+
"@teselagen/bio-parsers": "^0.4.32",
|
|
19
|
+
"@teselagen/range-utils": "^0.3.13",
|
|
20
|
+
"redux": "^4.2.1",
|
|
21
|
+
"redux-form": "^8.3.10",
|
|
22
|
+
"redux-thunk": "^2.4.2"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"react": "^18.3.1",
|
|
26
|
+
"react-redux": "^8.1.3"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/cloning.js
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { createSlice } from '@reduxjs/toolkit';
|
|
2
|
+
import { doesSourceHaveOutput, getNextUniqueId } from './cloning_utils';
|
|
3
|
+
import { convertToTeselaJson } from '@opencloning/utils/readNwrite';
|
|
4
|
+
|
|
5
|
+
function deleteFilesFromSessionStorage(sequenceId, fileName = null) {
|
|
6
|
+
Object.keys(sessionStorage)
|
|
7
|
+
.filter((key) => {
|
|
8
|
+
let query = `verification-${sequenceId}-`;
|
|
9
|
+
if (fileName) {
|
|
10
|
+
query += fileName;
|
|
11
|
+
}
|
|
12
|
+
return key.startsWith(query);
|
|
13
|
+
}).forEach((key) => sessionStorage.removeItem(key));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const initialState = {
|
|
17
|
+
mainSequenceId: null,
|
|
18
|
+
mainSequenceSelection: {},
|
|
19
|
+
sources: [
|
|
20
|
+
{
|
|
21
|
+
id: 1,
|
|
22
|
+
input: [],
|
|
23
|
+
type: null,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
sequences: [],
|
|
27
|
+
network: null,
|
|
28
|
+
currentTab: 0,
|
|
29
|
+
description: '',
|
|
30
|
+
selectedRegions: [],
|
|
31
|
+
knownErrors: {},
|
|
32
|
+
primers: [],
|
|
33
|
+
config: {
|
|
34
|
+
loaded: false,
|
|
35
|
+
backendUrl: null,
|
|
36
|
+
enableAssembler: false,
|
|
37
|
+
enablePlannotate: false,
|
|
38
|
+
},
|
|
39
|
+
sourcesWithHiddenAncestors: [],
|
|
40
|
+
teselaJsonCache: {},
|
|
41
|
+
alerts: [],
|
|
42
|
+
files: [],
|
|
43
|
+
appInfo: {},
|
|
44
|
+
// Global primer design settings affecting Tm calculations
|
|
45
|
+
globalPrimerSettings: {
|
|
46
|
+
primer_dna_conc: 50, // default 50 nM
|
|
47
|
+
primer_salt_monovalent: 50, // e.g. Na+ / K+
|
|
48
|
+
primer_salt_divalent: 1.5, // e.g. Mg2+
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
const reducer = {
|
|
54
|
+
|
|
55
|
+
setCurrentTab(state, action) {
|
|
56
|
+
state.currentTab = action.payload;
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
setMainSequenceId(state, action) {
|
|
60
|
+
state.mainSequenceId = action.payload;
|
|
61
|
+
state.mainSequenceSelection = {};
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
setMainSequenceSelection(state, action) {
|
|
65
|
+
state.mainSequenceSelection = action.payload;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
addEmptySource(state, action) {
|
|
69
|
+
const inputSequencesIds = action.payload;
|
|
70
|
+
const { sources } = state;
|
|
71
|
+
const nextUniqueId = getNextUniqueId(state);
|
|
72
|
+
sources.push({
|
|
73
|
+
id: nextUniqueId,
|
|
74
|
+
input: inputSequencesIds.map((id) => ({ sequence: id })),
|
|
75
|
+
type: null,
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
restoreSource(state, action) {
|
|
80
|
+
// This is used to roll back a source that was deleted
|
|
81
|
+
const source = action.payload;
|
|
82
|
+
const { sources } = state;
|
|
83
|
+
sources.push(source);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
addTemplateChildAndSubsequentSource(state, action) {
|
|
87
|
+
// This is used by the Hom. Rec. primer design. You pass a
|
|
88
|
+
// sourceId for which you want to add a template sequence as
|
|
89
|
+
// an output, and then a source that will take that template
|
|
90
|
+
// sequence as input. The source can also have existing sequences
|
|
91
|
+
// as input.
|
|
92
|
+
const { sourceId, newSequence, newSource } = action.payload;
|
|
93
|
+
const { sources, sequences } = state;
|
|
94
|
+
const source2update = sources.find((s) => s.id === sourceId);
|
|
95
|
+
if (source2update === undefined) {
|
|
96
|
+
throw new Error('Source not found');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Add the template sequence
|
|
100
|
+
sequences.push({
|
|
101
|
+
id: sourceId,
|
|
102
|
+
...newSequence,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Add the source that will take the template sequence as input
|
|
106
|
+
sources.push({
|
|
107
|
+
id: getNextUniqueId(state),
|
|
108
|
+
...newSource,
|
|
109
|
+
input: [...(newSource.input || []), { sequence: sourceId }],
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
addPCRsAndSubsequentSourcesForAssembly(state, action) {
|
|
114
|
+
// This is used by the PCR primer design for Gibson Assemblies. You pass a
|
|
115
|
+
// sourceId (PCR from which the primer design was started),
|
|
116
|
+
// and a list of templateIds to be amplified by PCR. Their outputs
|
|
117
|
+
// will be used as input for a subsequent assembly reaction.
|
|
118
|
+
const { sourceId, templateIds, sourceType, newSequence } = action.payload;
|
|
119
|
+
const { sources, sequences } = state;
|
|
120
|
+
|
|
121
|
+
if (sources.find((s) => s.id === sourceId) === undefined) {
|
|
122
|
+
throw new Error('Source not found');
|
|
123
|
+
}
|
|
124
|
+
const sources2update = [sourceId];
|
|
125
|
+
// Add the PCR sources
|
|
126
|
+
templateIds.forEach((templateId) => {
|
|
127
|
+
const nextId = getNextUniqueId(state);
|
|
128
|
+
const newSource = {
|
|
129
|
+
id: nextId,
|
|
130
|
+
input: [{ sequence: templateId }],
|
|
131
|
+
type: 'PCRSource',
|
|
132
|
+
};
|
|
133
|
+
sources.push(newSource);
|
|
134
|
+
sources2update.push(newSource.id);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Add the output sequences
|
|
138
|
+
sources2update.forEach((id) => {
|
|
139
|
+
sequences.push({
|
|
140
|
+
...newSequence,
|
|
141
|
+
id,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (sourceType !== null) {
|
|
146
|
+
// Add the Assembly that takes the PCR outputs as input
|
|
147
|
+
sources.push({
|
|
148
|
+
id: getNextUniqueId(state),
|
|
149
|
+
input: sources2update.map((id) => ({ sequence: id })),
|
|
150
|
+
type: sourceType,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
addSourceAndItsOutputSequence(state, action) {
|
|
156
|
+
const { source, sequence } = action.payload;
|
|
157
|
+
const { sources, sequences } = state;
|
|
158
|
+
|
|
159
|
+
const sourceId = getNextUniqueId(state);
|
|
160
|
+
const sequenceId = sourceId;
|
|
161
|
+
const newSequence = {
|
|
162
|
+
...sequence,
|
|
163
|
+
id: sequenceId,
|
|
164
|
+
};
|
|
165
|
+
const newSource = {
|
|
166
|
+
...source,
|
|
167
|
+
id: sourceId
|
|
168
|
+
};
|
|
169
|
+
sequences.push(newSequence);
|
|
170
|
+
sources.push(newSource);
|
|
171
|
+
state.teselaJsonCache[sequenceId] = convertToTeselaJson(newSequence);
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
addSequenceInBetween(state, action) {
|
|
175
|
+
const existingSourceId = action.payload;
|
|
176
|
+
const existingSource = state.sources.find((s) => s.id === existingSourceId);
|
|
177
|
+
const newSourceId = getNextUniqueId(state);
|
|
178
|
+
const newSequence = {
|
|
179
|
+
id: newSourceId,
|
|
180
|
+
type: 'TemplateSequence',
|
|
181
|
+
};
|
|
182
|
+
const newSource = {
|
|
183
|
+
id: newSourceId,
|
|
184
|
+
input: existingSource.input,
|
|
185
|
+
type: null,
|
|
186
|
+
};
|
|
187
|
+
existingSource.input = [{ sequence: newSequence.id }];
|
|
188
|
+
state.sources.push(newSource);
|
|
189
|
+
state.sequences.push(newSequence);
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
addSequenceAndUpdateItsSource(state, action) {
|
|
193
|
+
const { newSequence, newSource } = action.payload;
|
|
194
|
+
const { sequences, sources } = state;
|
|
195
|
+
newSequence.id = newSource.id;
|
|
196
|
+
|
|
197
|
+
const sourceIndex = sources.findIndex((s) => s.id === newSource.id);
|
|
198
|
+
if (sourceIndex === -1) {
|
|
199
|
+
throw new Error('Source not found');
|
|
200
|
+
}
|
|
201
|
+
sources.splice(sourceIndex, 1, newSource);
|
|
202
|
+
sequences.push(newSequence);
|
|
203
|
+
state.teselaJsonCache[newSequence.id] = convertToTeselaJson(newSequence);
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
updateSequenceAndItsSource(state, action) {
|
|
207
|
+
const { newSequence, newSource } = action.payload;
|
|
208
|
+
const { sequences, sources } = state;
|
|
209
|
+
|
|
210
|
+
const sourceIndex = sources.findIndex((s) => s.id === newSource.id);
|
|
211
|
+
if (sourceIndex === -1) {
|
|
212
|
+
throw new Error('Source not found');
|
|
213
|
+
}
|
|
214
|
+
sources.splice(sourceIndex, 1, newSource);
|
|
215
|
+
|
|
216
|
+
newSequence.id = newSource.id;
|
|
217
|
+
const sequenceIndex = sequences.findIndex((e) => e.id === newSequence.id);
|
|
218
|
+
if (sequenceIndex === -1) {
|
|
219
|
+
throw new Error('Sequence not found');
|
|
220
|
+
}
|
|
221
|
+
sequences.splice(sequenceIndex, 1, newSequence);
|
|
222
|
+
state.teselaJsonCache[newSequence.id] = convertToTeselaJson(newSequence);
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
updateSource(state, action) {
|
|
226
|
+
const newSource = action.payload;
|
|
227
|
+
const { sources } = state;
|
|
228
|
+
const source = sources.find((s) => s.id === newSource.id);
|
|
229
|
+
Object.assign(source, newSource);
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
updateSequence(state, action) {
|
|
233
|
+
const newSequence = action.payload;
|
|
234
|
+
const { sequences } = state;
|
|
235
|
+
const sequence = sequences.find((s) => s.id === newSequence.id);
|
|
236
|
+
Object.assign(sequence, newSequence);
|
|
237
|
+
state.teselaJsonCache[newSequence.id] = convertToTeselaJson(newSequence);
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
replaceSource(state, action) {
|
|
241
|
+
const newSource = action.payload;
|
|
242
|
+
const { sources } = state;
|
|
243
|
+
const sourceIndex = sources.findIndex((s) => s.id === newSource.id);
|
|
244
|
+
if (sourceIndex === -1) {
|
|
245
|
+
throw new Error('Source not found');
|
|
246
|
+
}
|
|
247
|
+
sources.splice(sourceIndex, 1, newSource);
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
deleteSourceAndItsChildren(state, action) {
|
|
251
|
+
const sourceId = action.payload;
|
|
252
|
+
const { sources, sequences } = state;
|
|
253
|
+
const sources2delete = [];
|
|
254
|
+
const sequences2delete = [];
|
|
255
|
+
const currentSources = [sources.find((s) => s.id === sourceId)];
|
|
256
|
+
while (currentSources.length > 0) {
|
|
257
|
+
const currentSource = currentSources.pop();
|
|
258
|
+
sources2delete.push(currentSource.id);
|
|
259
|
+
if (doesSourceHaveOutput(state, currentSource.id)) {
|
|
260
|
+
sequences2delete.push(currentSource.id);
|
|
261
|
+
currentSources.push(...sources.filter((ss) => ss.input.some(({sequence}) => sequence === currentSource.id)));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
state.sources = sources.filter((s) => !sources2delete.includes(s.id));
|
|
265
|
+
state.sequences = sequences.filter((e) => !sequences2delete.includes(e.id));
|
|
266
|
+
state.files = state.files.filter((f) => !sequences2delete.includes(f.sequence_id));
|
|
267
|
+
sequences2delete.forEach((e) => {
|
|
268
|
+
delete state.teselaJsonCache[e];
|
|
269
|
+
deleteFilesFromSessionStorage(e);
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
setState(state, action) {
|
|
274
|
+
const { sources, sequences, primers, description, files, sourcesWithHiddenAncestors } = action.payload;
|
|
275
|
+
if (!Array.isArray(sources) || !Array.isArray(sequences)) {
|
|
276
|
+
throw new Error('Cloning strategy not valid: fields `sources` and `sequences` should exist and be arrays');
|
|
277
|
+
}
|
|
278
|
+
const ids = [...sources.map((s) => s.id), ...sequences.map((e) => e.id)];
|
|
279
|
+
// They should all be positive integers
|
|
280
|
+
if (ids.some((id) => id < 1 || !Number.isInteger(id))) {
|
|
281
|
+
throw new Error('Some ids are not positive integers');
|
|
282
|
+
}
|
|
283
|
+
// None should be repeated
|
|
284
|
+
const sourceIds = sources.map((s) => s.id);
|
|
285
|
+
const sequenceIds = sequences.map((e) => e.id);
|
|
286
|
+
if (new Set(sourceIds).size !== sourceIds.length || new Set(sequenceIds).size !== sequenceIds.length) {
|
|
287
|
+
throw new Error('Repeated ids in sources or sequences');
|
|
288
|
+
}
|
|
289
|
+
state.sources = sources;
|
|
290
|
+
state.sequences = sequences;
|
|
291
|
+
state.teselaJsonCache = {};
|
|
292
|
+
state.primers = primers || [];
|
|
293
|
+
state.description = description || '';
|
|
294
|
+
state.files = files || [];
|
|
295
|
+
state.sourcesWithHiddenAncestors = sourcesWithHiddenAncestors || [];
|
|
296
|
+
sequences.forEach((e) => {
|
|
297
|
+
if (e.type === 'TextFileSequence') {
|
|
298
|
+
state.teselaJsonCache[e.id] = convertToTeselaJson(e);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
setDescription(state, action) {
|
|
304
|
+
state.description = action.payload;
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
setSelectedRegions(state, action) {
|
|
308
|
+
state.selectedRegions = [...action.payload];
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
setKnownErrors(state, action) {
|
|
312
|
+
state.knownErrors = action.payload;
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
setConfig(state, action) {
|
|
316
|
+
Object.assign(state.config, action.payload);
|
|
317
|
+
state.config.loaded = true;
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
addPrimer(state, action) {
|
|
321
|
+
const primer = action.payload;
|
|
322
|
+
const { primers } = state;
|
|
323
|
+
const nextPrimerId = getNextUniqueId(state);
|
|
324
|
+
primers.push({ ...primer, id: nextPrimerId });
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
setPrimers(state, action) {
|
|
328
|
+
const primers = action.payload;
|
|
329
|
+
// Ids are unique and all are positive integers
|
|
330
|
+
const ids = primers.map((p) => p.id);
|
|
331
|
+
if (ids.some((id) => id < 1 || !Number.isInteger(id))) {
|
|
332
|
+
throw new Error('Some ids are not positive integers');
|
|
333
|
+
}
|
|
334
|
+
// None should be repeated
|
|
335
|
+
if (new Set(ids).size !== ids.length) {
|
|
336
|
+
throw new Error('Repeated ids in the primers');
|
|
337
|
+
}
|
|
338
|
+
state.primers = primers;
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
deletePrimer(state, action) {
|
|
342
|
+
const primerId = action.payload;
|
|
343
|
+
state.primers = state.primers.filter((p) => p.id !== primerId);
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
editPrimer(state, action) {
|
|
347
|
+
const editedPrimer = action.payload;
|
|
348
|
+
const targetPrimer = state.primers.find((p) => p.id === editedPrimer.id);
|
|
349
|
+
if (!targetPrimer) {
|
|
350
|
+
throw new Error('Primer not found');
|
|
351
|
+
}
|
|
352
|
+
Object.assign(targetPrimer, editedPrimer);
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
addPrimersToPCRSource(state, action) {
|
|
356
|
+
const { sourceId, fwdPrimer, revPrimer } = action.payload;
|
|
357
|
+
const { sources, primers } = state;
|
|
358
|
+
const nextId = getNextUniqueId(state);
|
|
359
|
+
// For now, primers were coming with id=0 from the backend
|
|
360
|
+
const copyFwdPrimer = { ...fwdPrimer };
|
|
361
|
+
const copyRevPrimer = { ...revPrimer };
|
|
362
|
+
copyFwdPrimer.id = nextId;
|
|
363
|
+
copyRevPrimer.id = nextId + 1;
|
|
364
|
+
primers.push(copyFwdPrimer);
|
|
365
|
+
primers.push(copyRevPrimer);
|
|
366
|
+
|
|
367
|
+
const source = sources.find((s) => s.id === sourceId);
|
|
368
|
+
if (!source) {
|
|
369
|
+
throw new Error('Source not found');
|
|
370
|
+
}
|
|
371
|
+
source.input = [ {sequence: nextId}, ...source.input, {sequence: nextId + 1} ];
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
addToSourcesWithHiddenAncestors(state, action) {
|
|
375
|
+
const sourceId = action.payload;
|
|
376
|
+
state.sourcesWithHiddenAncestors.push(sourceId);
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
removeFromSourcesWithHiddenAncestors(state, action) {
|
|
380
|
+
const sourceId = action.payload;
|
|
381
|
+
state.sourcesWithHiddenAncestors = state.sourcesWithHiddenAncestors.filter((id) => id !== sourceId);
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
addAlert(state, action) {
|
|
385
|
+
state.alerts.push(action.payload);
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
removeAlert(state, action) {
|
|
389
|
+
const message = action.payload;
|
|
390
|
+
state.alerts = state.alerts.filter((a) => a.message !== message);
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
addFile(state, action) {
|
|
394
|
+
state.files.push(action.payload);
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
setFiles(state, action) {
|
|
398
|
+
state.files = action.payload;
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
removeFile(state, action) {
|
|
402
|
+
const { fileName, sequenceId } = action.payload;
|
|
403
|
+
state.files = state.files.filter((f) => f.file_name !== fileName || f.sequence_id !== sequenceId);
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
removeFilesAssociatedToSequence(state, action) {
|
|
407
|
+
const sequenceId = action.payload;
|
|
408
|
+
state.files = state.files.filter((f) => f.sequence_id !== sequenceId);
|
|
409
|
+
deleteFilesFromSessionStorage(sequenceId);
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
addDatabaseIdToSequence(state, action) {
|
|
413
|
+
const { databaseId, id } = action.payload;
|
|
414
|
+
const sequence = state.sequences.find((e) => e.id === id);
|
|
415
|
+
if (!sequence) {
|
|
416
|
+
throw new Error('Sequence not found');
|
|
417
|
+
}
|
|
418
|
+
const source = state.sources.find((s) => s.id === id);
|
|
419
|
+
if (!source) {
|
|
420
|
+
throw new Error('Source not found');
|
|
421
|
+
}
|
|
422
|
+
source.database_id = databaseId;
|
|
423
|
+
},
|
|
424
|
+
addDatabaseIdToPrimer(state, action) {
|
|
425
|
+
const { databaseId, localId } = action.payload;
|
|
426
|
+
const primer = state.primers.find((p) => p.id === localId);
|
|
427
|
+
if (!primer) {
|
|
428
|
+
throw new Error('Primer not found');
|
|
429
|
+
}
|
|
430
|
+
primer.database_id = databaseId;
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
updateAppInfo(state, action) {
|
|
434
|
+
state.appInfo = { ...state.appInfo, ...action.payload };
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
// Update global primer settings
|
|
438
|
+
setGlobalPrimerSettings(state, action) {
|
|
439
|
+
const updates = action.payload;
|
|
440
|
+
state.globalPrimerSettings = {
|
|
441
|
+
...state.globalPrimerSettings,
|
|
442
|
+
...updates,
|
|
443
|
+
};
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
const cloningSlice = createSlice({
|
|
450
|
+
name: 'cloning',
|
|
451
|
+
initialState: { ...initialState },
|
|
452
|
+
reducers: reducer,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
export const cloningActions = cloningSlice.actions;
|
|
456
|
+
export default cloningSlice.reducer;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { parseFeatureLocation } from '@teselagen/bio-parsers';
|
|
2
|
+
import { flipContainedRange, getRangeLength } from '@teselagen/range-utils';
|
|
3
|
+
|
|
4
|
+
export const isSequenceInputOfAnySource = (id, sources) => (sources.find((source) => source.input.some(({sequence}) => sequence === id))) !== undefined;
|
|
5
|
+
|
|
6
|
+
export function getSourcesTakingSequenceAsInput(sources, sequenceId) {
|
|
7
|
+
return sources.filter((s) => s.input.some(({sequence}) => sequence === sequenceId));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isCompletePCRSource(source) {
|
|
11
|
+
return source.type === 'PCRSource' && source.input.length === 3 && source.input[0].type === 'AssemblyFragment';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isCompleteOligoHybridizationSource(source) {
|
|
15
|
+
return source.type === 'OligoHybridizationSource' && source.input.length === 2 && source.overhang_crick_3prime !== undefined && source.overhang_crick_3prime !== null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getPcrTemplateSequenceId(source) {
|
|
19
|
+
return source.input.length === 1 ? source.input[0].sequence : source.input[1].sequence;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getIdsOfSequencesWithoutChildSource(sources, sequences) {
|
|
23
|
+
let idsSequencesWithChildSource = [];
|
|
24
|
+
sources.forEach((source) => {
|
|
25
|
+
idsSequencesWithChildSource = idsSequencesWithChildSource.concat(source.input.map(({sequence}) => sequence));
|
|
26
|
+
});
|
|
27
|
+
const sequencesNotChildSource = [];
|
|
28
|
+
|
|
29
|
+
sequences.forEach((sequence) => {
|
|
30
|
+
if (!idsSequencesWithChildSource.includes(sequence.id)) {
|
|
31
|
+
sequencesNotChildSource.push(sequence);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
return sequencesNotChildSource.map((sequence) => sequence.id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getInputSequencesFromSourceId(state, sourceId) {
|
|
38
|
+
const thisSource = state.cloning.sources.find((s) => s.id === sourceId);
|
|
39
|
+
// Sequences must be returned in the same order as in the source input, primers (undefined) are filtered out
|
|
40
|
+
return thisSource.input.map(({sequence}) => state.cloning.sequences.find((e) => e.id === sequence)).filter((e) => e !== undefined);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isSourceATemplate({ sources, sequences }, sourceId) {
|
|
44
|
+
// Get the output sequence
|
|
45
|
+
const source = sources.find((s) => s.id === sourceId);
|
|
46
|
+
const sequences2 = [...sequences.filter((e) => e.id === source.id), ...sequences.filter((e) => source.input.some((i) => i.sequence === e.id))];
|
|
47
|
+
return sequences2.some((s) => s.type === 'TemplateSequence');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getPrimerDesignObject({ sources, sequences }) {
|
|
51
|
+
// Find sequences that are templates and have primer_design set
|
|
52
|
+
const outputSequences = sequences.filter((e) => e.type === 'TemplateSequence' && Boolean(e.primer_design));
|
|
53
|
+
if (outputSequences.length === 0) {
|
|
54
|
+
// return 'No primer design sequence templates found';
|
|
55
|
+
return { finalSource: null, otherInputIds: [], pcrSources: [], outputSequences: [] };
|
|
56
|
+
}
|
|
57
|
+
const mockSequenceIds = outputSequences.map((s) => s.id);
|
|
58
|
+
|
|
59
|
+
// Find the PCRs from which the mock sequences are outputs
|
|
60
|
+
const pcrSources = sources.filter((s) => s.type === 'PCRSource' && mockSequenceIds.includes(s.id));
|
|
61
|
+
if (pcrSources.length === 0) {
|
|
62
|
+
return { finalSource: null, otherInputIds: [], pcrSources: [], outputSequences: [] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Find the template sequences for those PCRs
|
|
66
|
+
const templateSequences = sequences.filter((e) => pcrSources.some((ps) => ps.input.some((i) => i.sequence === e.id)));
|
|
67
|
+
|
|
68
|
+
// They should not be mock sequences
|
|
69
|
+
if (templateSequences.some((ts) => ts.type === 'TemplateSequence')) {
|
|
70
|
+
// return 'TemplateSequence input to final source is a TemplateSequence';
|
|
71
|
+
return { finalSource: null, otherInputIds: [], pcrSources: [], outputSequences: [] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Find the source they are input to (there should be zero or one)
|
|
75
|
+
const finalSources = sources.filter((s) => s.input.some((i) => mockSequenceIds.includes(i.sequence)));
|
|
76
|
+
|
|
77
|
+
if (finalSources.length === 0) {
|
|
78
|
+
// return as is
|
|
79
|
+
return { finalSource: null, otherInputIds: [], pcrSources, outputSequences };
|
|
80
|
+
}
|
|
81
|
+
if (finalSources.length > 1) {
|
|
82
|
+
// error
|
|
83
|
+
return { finalSource: null, otherInputIds: [], pcrSources: [], outputSequences: [] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const finalSource = finalSources[0];
|
|
87
|
+
|
|
88
|
+
// Inputs to the finalSource that are not mock sequences with primer_design
|
|
89
|
+
const otherInputIds = finalSource.input.map((i) => i.sequence).filter((i) => !mockSequenceIds.includes(i));
|
|
90
|
+
const otherInputs = sequences.filter((e) => otherInputIds.includes(e.id));
|
|
91
|
+
// There should be no TemplateSequence as an input that does not have primer_design set
|
|
92
|
+
if (otherInputs.some((i) => i.type === 'TemplateSequence' && !Boolean(i.primer_design))) {
|
|
93
|
+
// return 'TemplateSequence input to final source does not have primer_design set';
|
|
94
|
+
return { finalSource: null, otherInputIds: [], pcrSources: [], outputSequences: [] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { finalSource, otherInputIds, pcrSources, outputSequences };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const formatPrimer = (primer, position) => {
|
|
101
|
+
const { name, sequence, id } = primer;
|
|
102
|
+
return {
|
|
103
|
+
id: `${id}`,
|
|
104
|
+
name,
|
|
105
|
+
...position,
|
|
106
|
+
type: 'primer_bind',
|
|
107
|
+
primerBindsOn: '3prime',
|
|
108
|
+
forward: position.strand === 1,
|
|
109
|
+
bases: sequence,
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function pcrPrimerPositionsInInput(source, sequenceData) {
|
|
114
|
+
if (source.type !== 'PCRSource') {
|
|
115
|
+
throw new Error('Source is not a PCRSource');
|
|
116
|
+
}
|
|
117
|
+
const { size } = sequenceData;
|
|
118
|
+
|
|
119
|
+
const fwd = parseFeatureLocation(source.input[1].left_location, 0, 0, 0, 1, size)[0];
|
|
120
|
+
const rvs = parseFeatureLocation(source.input[1].right_location, 0, 0, 0, 1, size)[0];
|
|
121
|
+
|
|
122
|
+
if (!source.input[1].reverse_complemented) {
|
|
123
|
+
fwd.strand = 1;
|
|
124
|
+
rvs.strand = -1;
|
|
125
|
+
return [fwd, rvs];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const fwd2 = flipContainedRange(fwd, { start: 0, end: size - 1 }, size);
|
|
129
|
+
const rvs2 = flipContainedRange(rvs, { start: 0, end: size - 1 }, size);
|
|
130
|
+
|
|
131
|
+
fwd2.strand = -1;
|
|
132
|
+
rvs2.strand = 1;
|
|
133
|
+
return [fwd2, rvs2];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function pcrPrimerPositionsInOutput(primers, sequenceData) {
|
|
137
|
+
const [fwdPrimer, rvsPrimer] = primers;
|
|
138
|
+
return [
|
|
139
|
+
{ start: 0, end: fwdPrimer.sequence.length - 1, strand: 1 },
|
|
140
|
+
{ start: sequenceData.size - rvsPrimer.sequence.length, end: sequenceData.size - 1, strand: -1 },
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function getPCRPrimers({ primers, sources, teselaJsonCache }, sequenceId) {
|
|
145
|
+
let out = [];
|
|
146
|
+
|
|
147
|
+
// Get PCRs that have this sequence as input
|
|
148
|
+
const sourcesInput = getSourcesTakingSequenceAsInput(sources, sequenceId);
|
|
149
|
+
const sequenceData = teselaJsonCache[sequenceId];
|
|
150
|
+
|
|
151
|
+
sourcesInput.forEach((sourceInput) => {
|
|
152
|
+
if (isCompletePCRSource(sourceInput)) {
|
|
153
|
+
const pcrPrimers = [sourceInput.input[0].sequence, sourceInput.input[2].sequence].map((id) => primers.find((p) => p.id === id));
|
|
154
|
+
const primerPositions = pcrPrimerPositionsInInput(sourceInput, sequenceData);
|
|
155
|
+
out = out.concat(pcrPrimers.map((primer, i) => formatPrimer(primer, primerPositions[i])));
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Get the PCR that have this sequence as output (if any)
|
|
160
|
+
const sourceOutput = sources.find((s) => s.id === sequenceId);
|
|
161
|
+
if (sourceOutput?.type === 'PCRSource') {
|
|
162
|
+
const pcrPrimers = [sourceOutput.input[0].sequence, sourceOutput.input[2].sequence].map((id) => primers.find((p) => p.id === id));
|
|
163
|
+
const primerPositions = pcrPrimerPositionsInOutput(pcrPrimers, sequenceData);
|
|
164
|
+
out = out.concat(pcrPrimers.map((primer, i) => formatPrimer(primer, primerPositions[i])));
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function getNextUniqueId({ sources, sequences, primers }) {
|
|
170
|
+
const allIds = [...sources.map((s) => s.id), ...sequences.map((e) => e.id), ...primers.map((p) => p.id)];
|
|
171
|
+
if (allIds.length === 0) {
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
return Math.max(...allIds) + 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function shiftSource(source, idShift) {
|
|
178
|
+
const newSource = { ...source };
|
|
179
|
+
// Common part
|
|
180
|
+
newSource.id += idShift;
|
|
181
|
+
newSource.input = newSource.input.map((sourceInput) => ({ ...sourceInput, sequence: sourceInput.sequence + idShift }));
|
|
182
|
+
return newSource;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function mergePrimersInSource(source, keepId, removeId) {
|
|
186
|
+
const newSource = { ...source };
|
|
187
|
+
if (isCompletePCRSource(newSource)) {
|
|
188
|
+
if (newSource.input[0].sequence === removeId) {
|
|
189
|
+
newSource.input[0].sequence = keepId;
|
|
190
|
+
}
|
|
191
|
+
if (newSource.input[2].sequence === removeId) {
|
|
192
|
+
newSource.input[2].sequence = keepId;
|
|
193
|
+
}
|
|
194
|
+
} else if (newSource.type === 'OligoHybridizationSource' || newSource.type === 'CRISPRSource') {
|
|
195
|
+
newSource.input = newSource.input.map((sourceInput) => {
|
|
196
|
+
if (sourceInput.type === 'SourceInput' && sourceInput.sequence === removeId) {
|
|
197
|
+
return { ...sourceInput, sequence: keepId };
|
|
198
|
+
}
|
|
199
|
+
return { ...sourceInput };
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return newSource;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function shiftStateIds(newState, oldState, skipPrimers = false) {
|
|
207
|
+
const { sources: newSources, sequences: newSequences, primers: newPrimers, files: newFiles } = newState;
|
|
208
|
+
const { sources: oldSources, sequences: oldSequences, primers: oldPrimers } = oldState;
|
|
209
|
+
let idShift = getNextUniqueId({ sources: oldSources, sequences: oldSequences, primers: oldPrimers });
|
|
210
|
+
// Substract the smallest id to minimize the starting id
|
|
211
|
+
idShift -= Math.min(...[...newSources.map((s) => s.id), ...newSequences.map((e) => e.id), ...newPrimers.map((p) => p.id)]);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
shiftedState: {
|
|
215
|
+
sequences: newSequences.map((e) => ({ ...e, id: e.id + idShift })),
|
|
216
|
+
primers: newPrimers.map((p) => ({ ...p, id: p.id + idShift })),
|
|
217
|
+
sources: newSources.map((s) => shiftSource(s, idShift)),
|
|
218
|
+
files: newFiles ? newFiles.map((f) => ({ ...f, sequence_id: f.sequence_id + idShift })) : [],
|
|
219
|
+
},
|
|
220
|
+
idShift, };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function stringIsNotDNA(str) {
|
|
224
|
+
return str.match(/[^agct]/i) !== null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function formatGatewaySites(sites) {
|
|
228
|
+
const foundSites = [];
|
|
229
|
+
Object.keys(sites).forEach((siteName) => {
|
|
230
|
+
sites[siteName].forEach((location) => {
|
|
231
|
+
foundSites.push({ siteName, location });
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
return foundSites;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function getSourceDatabaseId(sources, sequenceId) {
|
|
238
|
+
const source = sources.find((s) => s.id === sequenceId);
|
|
239
|
+
return source?.database_id;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function primersInSource(source) {
|
|
243
|
+
if (isCompletePCRSource(source)) {
|
|
244
|
+
return [source.input[0].sequence, source.input[2].sequence];
|
|
245
|
+
}
|
|
246
|
+
if (source.type === 'OligoHybridizationSource' && source.input.length === 2) {
|
|
247
|
+
return source.input.map(({sequence}) => sequence);
|
|
248
|
+
}
|
|
249
|
+
if (source.type === 'CRISPRSource') {
|
|
250
|
+
return source.input.filter(({type}) => type === 'SourceInput').map(({sequence}) => sequence);
|
|
251
|
+
}
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function isAssemblyComplete(source) {
|
|
256
|
+
return source.input?.some(({type}) => type === 'AssemblyFragment');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function getUsedPrimerIds(sources) {
|
|
260
|
+
return sources.flatMap((s) => primersInSource(s));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function getSourcesWherePrimerIsUsed(sources, primerId) {
|
|
264
|
+
return sources.filter((s) => primersInSource(s).includes(primerId));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function getPrimerBindingInfoFromSource(primers, source, sequenceLength) {
|
|
268
|
+
let fwdPrimer = null;
|
|
269
|
+
let rvsPrimer = null;
|
|
270
|
+
let fwdLength = 0;
|
|
271
|
+
let rvsLength = 0;
|
|
272
|
+
|
|
273
|
+
if (isCompletePCRSource(source)) {
|
|
274
|
+
fwdPrimer = primers.find((p) => p.id === source.input[0].sequence);
|
|
275
|
+
rvsPrimer = primers.find((p) => p.id === source.input[2].sequence);
|
|
276
|
+
const fwdLocation = parseFeatureLocation(source.input[0].right_location, 0, 0, 0, 1, sequenceLength)[0];
|
|
277
|
+
const rvsLocation = parseFeatureLocation(source.input[2].left_location, 0, 0, 0, 1, sequenceLength)[0];
|
|
278
|
+
fwdLength = getRangeLength(fwdLocation, sequenceLength);
|
|
279
|
+
rvsLength = getRangeLength(rvsLocation, sequenceLength);
|
|
280
|
+
if (fwdLength < 0) {
|
|
281
|
+
fwdLength += sequenceLength;
|
|
282
|
+
}
|
|
283
|
+
if (rvsLength < 0) {
|
|
284
|
+
rvsLength += sequenceLength;
|
|
285
|
+
}
|
|
286
|
+
} else if (isCompleteOligoHybridizationSource(source)) {
|
|
287
|
+
fwdPrimer = primers.find((p) => p.id === source.input[0].sequence);
|
|
288
|
+
rvsPrimer = primers.find((p) => p.id === source.input[1].sequence);
|
|
289
|
+
fwdLength = fwdPrimer.sequence.length + source.overhang_crick_3prime;
|
|
290
|
+
rvsLength = fwdLength;
|
|
291
|
+
}
|
|
292
|
+
return { sourceId: source.id, sourceType: source.type, fwdPrimer, rvsPrimer, fwdLength, rvsLength };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function doesSourceHaveOutput(cloningState, sourceId) {
|
|
296
|
+
return Boolean(cloningState.sequences.find((s) => s.id === sourceId));
|
|
297
|
+
}
|
|
298
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { it, describe } from 'vitest';
|
|
2
|
+
import { pcrPrimerPositionsInInput } from './cloning_utils';
|
|
3
|
+
|
|
4
|
+
// Template sequence fwd
|
|
5
|
+
const source1 = {
|
|
6
|
+
type: 'PCRSource',
|
|
7
|
+
input: [
|
|
8
|
+
{
|
|
9
|
+
sequence: 1,
|
|
10
|
+
left_location: null,
|
|
11
|
+
right_location: '1..6',
|
|
12
|
+
reverse_complemented: false,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
sequence: 2,
|
|
16
|
+
left_location: 'join(21..23,1..3)',
|
|
17
|
+
right_location: '10..15',
|
|
18
|
+
reverse_complemented: false,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
sequence: 1,
|
|
22
|
+
left_location: '1..6',
|
|
23
|
+
right_location: null,
|
|
24
|
+
reverse_complemented: true,
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Template sequence rvs
|
|
30
|
+
const source2 = {
|
|
31
|
+
type: 'PCRSource',
|
|
32
|
+
input: [
|
|
33
|
+
{
|
|
34
|
+
sequence: 1,
|
|
35
|
+
left_location: null,
|
|
36
|
+
right_location: '1..7',
|
|
37
|
+
reverse_complemented: false,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
sequence: 4,
|
|
41
|
+
left_location: 'join(21..23,1..4)',
|
|
42
|
+
right_location: '10..15',
|
|
43
|
+
reverse_complemented: true,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
sequence: 2,
|
|
47
|
+
left_location: '1..6',
|
|
48
|
+
right_location: null,
|
|
49
|
+
reverse_complemented: true,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
describe('test pcrPrimerPositionsInInput', () => {
|
|
55
|
+
it('test normal case', () => {
|
|
56
|
+
const primerPos1 = pcrPrimerPositionsInInput(source1, { size: 23 });
|
|
57
|
+
expect(primerPos1).toEqual([{ start: 20, end: 2, strand: 1 }, { start: 9, end: 14, strand: -1 }]);
|
|
58
|
+
|
|
59
|
+
const primerPos2 = pcrPrimerPositionsInInput(source2, { size: 23 });
|
|
60
|
+
expect(primerPos2).toEqual([{ end: 2, start: 19, strand: -1 }, { end: 13, start: 8, strand: 1 }]);
|
|
61
|
+
});
|
|
62
|
+
it('raises error if source is not a PCRSource', () => {
|
|
63
|
+
const source = { type: 'dummy' };
|
|
64
|
+
expect(() => pcrPrimerPositionsInInput(source, { size: 23 })).toThrow('Source is not a PCRSource');
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// optionally connect to the redux store
|
|
2
|
+
import { createStore, combineReducers, applyMiddleware, compose } from 'redux';
|
|
3
|
+
import { tg_modalState } from '@teselagen/ui';
|
|
4
|
+
import {
|
|
5
|
+
vectorEditorReducer as VectorEditor,
|
|
6
|
+
vectorEditorMiddleware,
|
|
7
|
+
} from '@teselagen/ove';
|
|
8
|
+
import thunk from 'redux-thunk';
|
|
9
|
+
import { reducer as form } from 'redux-form';
|
|
10
|
+
import cloningReducer from './cloning';
|
|
11
|
+
|
|
12
|
+
const composeEnhancer = (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
|
13
|
+
&& window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
|
|
14
|
+
actionsBlacklist: ['HOVEREDANNOTATIONUPDATE', 'HOVEREDANNOTATIONCLEAR'],
|
|
15
|
+
// actionSanitizer,
|
|
16
|
+
latency: 1000,
|
|
17
|
+
name: 'openVE',
|
|
18
|
+
}))
|
|
19
|
+
|| compose;
|
|
20
|
+
|
|
21
|
+
const store = createStore(
|
|
22
|
+
combineReducers({
|
|
23
|
+
form,
|
|
24
|
+
tg_modalState,
|
|
25
|
+
VectorEditor: VectorEditor(),
|
|
26
|
+
cloning: cloningReducer,
|
|
27
|
+
}),
|
|
28
|
+
undefined,
|
|
29
|
+
composeEnhancer(
|
|
30
|
+
applyMiddleware(thunk, vectorEditorMiddleware), // your store should be redux-thunk connected for the VectorEditor component to work
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
export default store;
|
package/vitest.config.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
test: {
|
|
6
|
+
globals: true,
|
|
7
|
+
environment: 'jsdom',
|
|
8
|
+
setupFiles: '../../tests/setup.js',
|
|
9
|
+
include: ['src/**/*.{test,spec}.{js,jsx}'],
|
|
10
|
+
},
|
|
11
|
+
resolve: {
|
|
12
|
+
alias: {
|
|
13
|
+
'@opencloning/store': resolve(__dirname, './src'),
|
|
14
|
+
'@opencloning/utils': resolve(__dirname, '../utils/src/utils'),
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|