@opencloning/ui 2.1.1 → 2.2.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/package.json +8 -8
- package/src/StandAloneOpenCloning.js +4 -4
- package/src/components/MainSequenceEditor.jsx +1 -1
- package/src/components/SequenceViewer.cy.jsx +0 -1
- package/src/components/SequenceViewer.jsx +13 -16
- package/src/components/assembler/Assembler.cy.jsx +3 -3
- package/src/components/assembler/ExistingSyntaxDialog.cy.jsx +1 -1
- package/src/components/assembler/ExistingSyntaxDialog.jsx +1 -1
- package/src/components/assembler/SyntaxOverviewTable.jsx +3 -3
- package/src/components/assembler/UploadPlasmidsButton.cy.jsx +35 -5
- package/src/components/assembler/UploadPlasmidsButton.jsx +1 -1
- package/src/components/assembler/assembler_utils.js +3 -1
- package/src/components/assembler/assembler_utils.test.js +25 -6
- package/src/components/assembler/graph_utils.js +17 -17
- package/src/components/assembler/graph_utils.test.js +7 -7
- package/src/components/assembler/sbol_visual_glyphs.js +0 -1
- package/src/components/assembler/useAssembler.js +0 -1
- package/src/components/domesticate/DomesticateDialog.jsx +1 -1
- package/src/components/dummy/DummyInterface.js +2 -1
- package/src/components/form/ChipMultiSelect.cy.jsx +43 -0
- package/src/components/form/ChipMultiSelect.jsx +60 -0
- package/src/components/form/GetRequestMultiSelect.jsx +1 -1
- package/src/components/index.js +1 -0
- package/src/components/navigation/MainAppBar.css +0 -2
- package/src/components/navigation/MainAppBar.jsx +1 -1
- package/src/components/primers/PrimerList.jsx +1 -1
- package/src/components/primers/import_primers/styles.css +0 -1
- package/src/components/settings/SettingsTab.jsx +0 -2
- package/src/components/sources/SourceGenomeRegion.cy.jsx +2 -4
- package/src/components/sources/repositoryMetadata.js +0 -1
- package/src/components/verification/VerificationFileDialog.jsx +3 -3
- package/src/hooks/useInitializeApp.js +0 -1
- package/src/hooks/useSequencingAlignment.js +2 -9
- package/src/index.js +0 -1
- package/src/providers/ConfigProvider.jsx +4 -4
- package/src/version.js +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"license": "MIT",
|
|
3
3
|
"name": "@opencloning/ui",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.2.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"scripts": {
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
},
|
|
32
32
|
"repository": {
|
|
33
33
|
"type": "git",
|
|
34
|
-
"url": "https://github.com/OpenCloning/
|
|
35
|
-
"directory": "packages/ui"
|
|
34
|
+
"url": "https://github.com/OpenCloning/OpenCloning.git",
|
|
35
|
+
"directory": "js/packages/ui"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@emotion/react": "^11.14.0",
|
|
@@ -40,16 +40,16 @@
|
|
|
40
40
|
"@mui/icons-material": "^5.18.0",
|
|
41
41
|
"@mui/material": "^5.18.0",
|
|
42
42
|
"@mui/x-data-grid": "^8.28.5",
|
|
43
|
-
"@opencloning/store": "2.
|
|
44
|
-
"@opencloning/utils": "2.
|
|
43
|
+
"@opencloning/store": "2.2.1",
|
|
44
|
+
"@opencloning/utils": "2.2.1",
|
|
45
45
|
"@teselagen/bio-parsers": "^0.4.38",
|
|
46
46
|
"@teselagen/ove": "^0.8.42",
|
|
47
47
|
"@teselagen/range-utils": "^0.3.20",
|
|
48
48
|
"@teselagen/sequence-utils": "^0.3.42",
|
|
49
|
-
"@zip.js/zip.js": "^2.
|
|
50
|
-
"axios": "^1.
|
|
49
|
+
"@zip.js/zip.js": "^2.24.0",
|
|
50
|
+
"axios": "^1.21.0",
|
|
51
51
|
"lodash-es": "^4.18.1",
|
|
52
|
-
"react-draggable": "^4.
|
|
52
|
+
"react-draggable": "^4.7.2"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"react": "^18.3.1",
|
|
@@ -9,24 +9,24 @@ import './index.css';
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* StandAloneOpenCloning - Mounts the OpenCloning component into a DOM element
|
|
12
|
-
*
|
|
12
|
+
*
|
|
13
13
|
* This function handles all the setup required for OpenCloning:
|
|
14
14
|
* - Redux Provider with store
|
|
15
15
|
* - ConfigProvider with config
|
|
16
16
|
* - React rendering
|
|
17
|
-
*
|
|
17
|
+
*
|
|
18
18
|
* @param {Object} options - Configuration options
|
|
19
19
|
* @param {Object} options.config - Configuration object for OpenCloning
|
|
20
20
|
* @param {HTMLElement} options.element - DOM element where to mount the component
|
|
21
21
|
* @returns {Function} Cleanup function that unmounts the component
|
|
22
|
-
*
|
|
22
|
+
*
|
|
23
23
|
* @example
|
|
24
24
|
* const container = document.getElementById('opencloning-container');
|
|
25
25
|
* const cleanup = StandAloneOpenCloning({
|
|
26
26
|
* config: { /* config object *\/ },
|
|
27
27
|
* element: container
|
|
28
28
|
* });
|
|
29
|
-
*
|
|
29
|
+
*
|
|
30
30
|
* // Later, to unmount:
|
|
31
31
|
* cleanup();
|
|
32
32
|
*/
|
|
@@ -169,7 +169,7 @@ function MainSequenceEditor() {
|
|
|
169
169
|
<AnnotationChangedAlert onSave={updateAnnotationInMainSequence} onCancel={onAnnotationCancel} />
|
|
170
170
|
}
|
|
171
171
|
<Editor {...{ editorName, ...defaultMainEditorProps, ...extraProp, height: '800' }} />
|
|
172
|
-
{domesticateDialogOpen &&
|
|
172
|
+
{domesticateDialogOpen &&
|
|
173
173
|
<DomesticateDialog
|
|
174
174
|
open={domesticateDialogOpen}
|
|
175
175
|
onClose={() => {
|
|
@@ -88,7 +88,7 @@ const baseViewerProps = {
|
|
|
88
88
|
rightClickOverrides,
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
-
function SequenceViewer({ sequenceData, alignmentData, onUpdateAnnotation }) {
|
|
91
|
+
function SequenceViewer({ sequenceData, alignmentData, onUpdateAnnotation, readOnly = false }) {
|
|
92
92
|
const store = useStore();
|
|
93
93
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
94
94
|
|
|
@@ -141,32 +141,29 @@ function SequenceViewer({ sequenceData, alignmentData, onUpdateAnnotation }) {
|
|
|
141
141
|
|
|
142
142
|
const viewerProps = {
|
|
143
143
|
...baseViewerProps,
|
|
144
|
+
readOnly,
|
|
144
145
|
isFullscreen,
|
|
145
146
|
};
|
|
146
147
|
|
|
147
148
|
React.useEffect(() => {
|
|
148
|
-
updateEditor(store, EDITOR_NAME, { ...baseViewerProps, sequenceData, sequenceDataHistory: {} });
|
|
149
|
-
}, [store, sequenceData]);
|
|
149
|
+
updateEditor(store, EDITOR_NAME, { ...baseViewerProps, readOnly, sequenceData, sequenceDataHistory: {} });
|
|
150
|
+
}, [store, sequenceData, readOnly]);
|
|
150
151
|
|
|
151
152
|
React.useEffect(() => {
|
|
152
|
-
const
|
|
153
|
+
const editorState = store.getState().VectorEditor?.[EDITOR_NAME];
|
|
154
|
+
let panelsShown = editorState?.panelsShown ?? baseViewerProps.panelsShown;
|
|
153
155
|
|
|
154
156
|
if (alignmentData) {
|
|
155
157
|
addAlignment(store, alignmentData);
|
|
156
|
-
|
|
157
|
-
const currentPanels = editorState?.panelsShown || [[]];
|
|
158
|
-
editorUpdate.panelsShown = updatePanelsShownWithAlignment(currentPanels);
|
|
158
|
+
panelsShown = updatePanelsShownWithAlignment(panelsShown);
|
|
159
159
|
} else {
|
|
160
|
-
|
|
161
|
-
if (editorState?.panelsShown) {
|
|
162
|
-
editorUpdate.panelsShown = removePanelFromShown(editorState.panelsShown, 'simpleAlignment');
|
|
163
|
-
}
|
|
160
|
+
panelsShown = removePanelFromShown(panelsShown, 'simpleAlignment');
|
|
164
161
|
}
|
|
165
162
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
updateEditor(store, EDITOR_NAME,
|
|
163
|
+
// A single active tab: the alignment if there is one, otherwise the map that matches the topology
|
|
164
|
+
const activePanelId = alignmentData ? 'simpleAlignment' : (sequenceData.circular ? 'circular' : 'rail');
|
|
165
|
+
const panelsWithActive = panelsShown[0].map((panel) => ({ ...panel, active: panel.id === activePanelId }));
|
|
166
|
+
updateEditor(store, EDITOR_NAME, { panelsShown: [panelsWithActive] });
|
|
170
167
|
|
|
171
168
|
}, [sequenceData.circular, store, alignmentData]);
|
|
172
169
|
|
|
@@ -210,7 +207,7 @@ function SequenceViewer({ sequenceData, alignmentData, onUpdateAnnotation }) {
|
|
|
210
207
|
</IconButton>
|
|
211
208
|
)}
|
|
212
209
|
{fullscreenExitButton}
|
|
213
|
-
{annotationChanged && <AnnotationChangedAlert onSave={handleUpdateAnnotation} onCancel={clearHistory} />}
|
|
210
|
+
{annotationChanged && !readOnly && <AnnotationChangedAlert onSave={handleUpdateAnnotation} onCancel={clearHistory} />}
|
|
214
211
|
<Editor
|
|
215
212
|
editorName={EDITOR_NAME}
|
|
216
213
|
{...viewerProps}
|
|
@@ -144,7 +144,7 @@ describe('<AssemblerComponent />', () => {
|
|
|
144
144
|
cy.get('input').click();
|
|
145
145
|
});
|
|
146
146
|
cy.get('li').contains('Test Plasmid 1').click();
|
|
147
|
-
|
|
147
|
+
|
|
148
148
|
// Select second plasmid
|
|
149
149
|
cy.get('[data-testid="plasmid-select"]').eq(1).within(() => {
|
|
150
150
|
cy.get('input').click();
|
|
@@ -284,13 +284,13 @@ describe('<AssemblerComponent />', () => {
|
|
|
284
284
|
cy.get('[data-testid="assembler-download-assemblies-button"]').click();
|
|
285
285
|
|
|
286
286
|
// Check that the download was triggered (createObjectURL receives the zip Blob)
|
|
287
|
-
|
|
287
|
+
|
|
288
288
|
cy.get('@createObjectURL').should((stub) => {
|
|
289
289
|
expect(stub).to.have.been.calledOnce;
|
|
290
290
|
expect(stub.firstCall.args[0]).to.be.instanceOf(Blob);
|
|
291
291
|
expect(stub.firstCall.args[0].type).to.equal('application/zip');
|
|
292
292
|
});
|
|
293
|
-
|
|
293
|
+
|
|
294
294
|
cy.get('@addAlert').should('have.not.been.called');
|
|
295
295
|
|
|
296
296
|
});
|
|
@@ -9,7 +9,7 @@ function OverhangRow({ row, parts, mode = 'detailed' }) {
|
|
|
9
9
|
|
|
10
10
|
const rows2iterate = [...row];
|
|
11
11
|
const actualRows =[];
|
|
12
|
-
|
|
12
|
+
|
|
13
13
|
let currentCell = [rows2iterate.shift(), 1];
|
|
14
14
|
while (currentCell[0] !== undefined) {
|
|
15
15
|
if (rows2iterate[0] === GRAPH_SPACER) {
|
|
@@ -40,7 +40,7 @@ function OverhangRow({ row, parts, mode = 'detailed' }) {
|
|
|
40
40
|
{actualRows.flatMap(
|
|
41
41
|
(cell, index) => {
|
|
42
42
|
const showRight = index === actualRows.length - 1;
|
|
43
|
-
|
|
43
|
+
|
|
44
44
|
const colSpan = (cell[1]-1)*2 + 1;
|
|
45
45
|
const { left_overhang, right_overhang, left_inside, color, glyph } = cell[2];
|
|
46
46
|
const { leftTranslationOverhang, leftTranslationInside, leftOverhangRc, rightOverhangRc, leftInsideRc } = partDataToDisplayData(cell[2]);
|
|
@@ -58,7 +58,7 @@ function OverhangRow({ row, parts, mode = 'detailed' }) {
|
|
|
58
58
|
</AssemblerPartContainer>
|
|
59
59
|
</TableCell>
|
|
60
60
|
{showRight && (
|
|
61
|
-
<TableCell
|
|
61
|
+
<TableCell
|
|
62
62
|
key={index}
|
|
63
63
|
sx={{ padding: 0, borderRight: 'solid 1px gray' }}
|
|
64
64
|
>
|
|
@@ -172,14 +172,14 @@ describe('<UploadPlasmidsButton />', () => {
|
|
|
172
172
|
<UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
|
|
173
173
|
</ConfigProvider>,
|
|
174
174
|
);
|
|
175
|
-
|
|
175
|
+
|
|
176
176
|
cy.get('[data-testid="load-plasmids-file-input"]').selectFile([
|
|
177
177
|
'cypress/test_files/syntax/pYTK002.gb',
|
|
178
178
|
'cypress/test_files/syntax/moclo_ytk_multi_part.gb',
|
|
179
179
|
'cypress/test_files/syntax/pYTK095.gb',
|
|
180
180
|
'cypress/test_files/sequencing/locus.gb'],
|
|
181
181
|
{ force: true });
|
|
182
|
-
|
|
182
|
+
|
|
183
183
|
cy.get('.MuiDialog-root', { timeout: 10000 }).should('be.visible');
|
|
184
184
|
|
|
185
185
|
cy.get('button').contains('Cancel').click();
|
|
@@ -240,7 +240,7 @@ describe('<UploadPlasmidsButton />', () => {
|
|
|
240
240
|
return Promise.resolve({
|
|
241
241
|
data: dummyIndex,
|
|
242
242
|
});
|
|
243
|
-
}
|
|
243
|
+
}
|
|
244
244
|
if (url.endsWith('/example2.gb')) {
|
|
245
245
|
return Promise.resolve({ data: 'wrong_format' });
|
|
246
246
|
}
|
|
@@ -294,7 +294,10 @@ describe('<UploadPlasmidsButton />', () => {
|
|
|
294
294
|
];
|
|
295
295
|
const mockDatabase = {
|
|
296
296
|
name: 'MockDB',
|
|
297
|
-
|
|
297
|
+
// Shows the default sequence types it receives, to check that plasmids are selected by default
|
|
298
|
+
GetSequencesInBulkComponent: ({ onSequencesLoaded, defaultSequenceTypes }) => (
|
|
299
|
+
<button type="button" data-default-types={JSON.stringify(defaultSequenceTypes)} onClick={() => onSequencesLoaded(entries)}>Mock load</button>
|
|
300
|
+
),
|
|
298
301
|
};
|
|
299
302
|
const addPlasmidsSpy = cy.spy().as('addPlasmidsSpy');
|
|
300
303
|
cy.mount(
|
|
@@ -306,7 +309,7 @@ describe('<UploadPlasmidsButton />', () => {
|
|
|
306
309
|
);
|
|
307
310
|
cy.get('[data-testid="load-plasmids-button"]').click();
|
|
308
311
|
cy.get('[data-testid="load-plasmids-from-database"]').contains('From MockDB').click();
|
|
309
|
-
cy.contains('button', 'Mock load').click();
|
|
312
|
+
cy.contains('button', 'Mock load').should('have.attr', 'data-default-types', '["plasmid"]').click();
|
|
310
313
|
// The bulk dialog closes and the valid / invalid dialog is shown
|
|
311
314
|
cy.contains('button', 'Mock load').should('not.exist');
|
|
312
315
|
cy.get('[data-testid="invalid-plasmids-box"]').contains('multi_part_db');
|
|
@@ -330,6 +333,33 @@ describe('<UploadPlasmidsButton />', () => {
|
|
|
330
333
|
});
|
|
331
334
|
});
|
|
332
335
|
|
|
336
|
+
it('shows linear sequences with a single cut (e.g. PCR products) as invalid, without errors', () => {
|
|
337
|
+
// Linear, with a single BsaI site, so all its fragments end at the sequence ends
|
|
338
|
+
const pcrProduct = [
|
|
339
|
+
'LOCUS pcr_product 28 bp DNA linear UNK 01-JAN-1980',
|
|
340
|
+
'ORIGIN',
|
|
341
|
+
' 1 tgggtctcac cctagagtca cacaggac',
|
|
342
|
+
'//',
|
|
343
|
+
].join('\n');
|
|
344
|
+
const mockDatabase = {
|
|
345
|
+
name: 'MockDB',
|
|
346
|
+
GetSequencesInBulkComponent: ({ onSequencesLoaded }) => <button type="button" onClick={() => onSequencesLoaded([{ databaseId: 1, name: 'pcr_product_db', textFileSequence: { file_content: pcrProduct } }])}>Mock load</button>,
|
|
347
|
+
};
|
|
348
|
+
cy.mount(
|
|
349
|
+
<ConfigProvider config={testConfig}>
|
|
350
|
+
<DatabaseProvider value={mockDatabase}>
|
|
351
|
+
<UploadPlasmidsButton addPlasmids={cy.spy()} syntax={mocloSyntax} />
|
|
352
|
+
</DatabaseProvider>
|
|
353
|
+
</ConfigProvider>,
|
|
354
|
+
);
|
|
355
|
+
cy.get('[data-testid="load-plasmids-button"]').click();
|
|
356
|
+
cy.get('[data-testid="load-plasmids-from-database"]').contains('From MockDB').click();
|
|
357
|
+
cy.contains('button', 'Mock load').click();
|
|
358
|
+
cy.get('[data-testid="invalid-plasmids-box"]').contains('pcr_product_db');
|
|
359
|
+
cy.get('[data-testid="valid-plasmids-box"]').should('not.exist');
|
|
360
|
+
cy.contains('button', 'Import valid plasmids').should('be.disabled');
|
|
361
|
+
});
|
|
362
|
+
|
|
333
363
|
it('shows an error if a database sequence cannot be parsed', () => {
|
|
334
364
|
const mockDatabase = {
|
|
335
365
|
name: 'MockDB',
|
|
@@ -172,7 +172,7 @@ function UploadPlasmidsButton({ addPlasmids, syntax }) {
|
|
|
172
172
|
)}
|
|
173
173
|
{openDialog === 'database' && (
|
|
174
174
|
<LoadPlasmidsDialog title={`Load Plasmids from ${database.name}`} onLoad={loadPlasmidsFromDatabase} onClose={closeDialog}>
|
|
175
|
-
{(onLoad) => <database.GetSequencesInBulkComponent onSequencesLoaded={onLoad} />}
|
|
175
|
+
{(onLoad) => <database.GetSequencesInBulkComponent onSequencesLoaded={onLoad} defaultSequenceTypes={['plasmid']} />}
|
|
176
176
|
</LoadPlasmidsDialog>
|
|
177
177
|
)}
|
|
178
178
|
<ImportPlasmidsDialog
|
|
@@ -92,11 +92,13 @@ export function longestFeatureInDigestFragment(digestFragment, sequenceData) {
|
|
|
92
92
|
export function getSimplifiedDigestFragments(sequenceData, enzymes) {
|
|
93
93
|
const { sequence, circular } = sequenceData;
|
|
94
94
|
|
|
95
|
+
// In linear sequences, the fragments at the ends have a "cut" at the start / end of the sequence,
|
|
96
|
+
// which has no overhang, so they cannot be parts.
|
|
95
97
|
const digestFragments = getDigestFragmentsForRestrictionEnzymes(
|
|
96
98
|
sequence,
|
|
97
99
|
circular,
|
|
98
100
|
enzymes,
|
|
99
|
-
);
|
|
101
|
+
).filter(({ cut1, cut2 }) => cut1.type !== 'START_OR_END_OF_SEQ' && cut2.type !== 'START_OR_END_OF_SEQ');
|
|
100
102
|
|
|
101
103
|
const longestFeatures = digestFragments.map(fragment => longestFeatureInDigestFragment(fragment, sequenceData));
|
|
102
104
|
const simplifiedDigestFragments = digestFragments.map(simplifyDigestFragment);
|
|
@@ -179,6 +179,25 @@ describe('assignSequenceToSyntaxPart', () => {
|
|
|
179
179
|
const result = assignSequenceToSyntaxPart(sequenceData, enzymes, graph);
|
|
180
180
|
expect(result).toEqual([]);
|
|
181
181
|
})
|
|
182
|
+
|
|
183
|
+
it('ignores the fragments at the ends of linear sequences (e.g. PCR products)', () => {
|
|
184
|
+
const parts = [{left_overhang: 'TACT', right_overhang: 'AATG'}, {left_overhang: 'AATG', right_overhang: 'AGGT'}];
|
|
185
|
+
const graph = partsToEdgesGraph(parts);
|
|
186
|
+
const enzymes = [aliasedEnzymesByName["bsai"]];
|
|
187
|
+
// The part between the two cuts is found, the fragments up to the sequence ends are ignored
|
|
188
|
+
expect(assignSequenceToSyntaxPart({ sequence: sequenceBsaI, circular: false }, enzymes, graph))
|
|
189
|
+
.toEqual([{left_overhang: 'TACT', right_overhang: 'AATG', longestFeature: null}]);
|
|
190
|
+
// A single cut only gives fragments with a sequence end
|
|
191
|
+
expect(assignSequenceToSyntaxPart({ sequence: 'tgggtctcaTACTagagtc', circular: false }, enzymes, graph)).toEqual([]);
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('does not assign a sequence that the enzyme does not cut', () => {
|
|
195
|
+
const parts = [{left_overhang: 'TACT', right_overhang: 'AATG'}, {left_overhang: 'AATG', right_overhang: 'AGGT'}];
|
|
196
|
+
const graph = partsToEdgesGraph(parts);
|
|
197
|
+
const enzymes = [aliasedEnzymesByName["bsai"]];
|
|
198
|
+
expect(assignSequenceToSyntaxPart({ sequence: 'AAAAAAA', circular: true }, enzymes, graph)).toEqual([]);
|
|
199
|
+
expect(assignSequenceToSyntaxPart({ sequence: 'AAAAAAA', circular: false }, enzymes, graph)).toEqual([]);
|
|
200
|
+
})
|
|
182
201
|
});
|
|
183
202
|
|
|
184
203
|
describe('tripletsToTranslation', () => {
|
|
@@ -195,10 +214,10 @@ describe('tripletsToTranslation', () => {
|
|
|
195
214
|
it('translates valid triplets to amino acid codes', () => {
|
|
196
215
|
// ATG = Methionine (Met)
|
|
197
216
|
expect(tripletsToTranslation(['ATG'])).toBe('Met');
|
|
198
|
-
|
|
217
|
+
|
|
199
218
|
// TTT = Phenylalanine (Phe)
|
|
200
219
|
expect(tripletsToTranslation(['TTT'])).toBe('Phe');
|
|
201
|
-
|
|
220
|
+
|
|
202
221
|
// Multiple triplets
|
|
203
222
|
expect(tripletsToTranslation(['ATG', 'TTT', 'GCA'])).toBe('MetPheAla');
|
|
204
223
|
});
|
|
@@ -230,9 +249,9 @@ describe('partDataToDisplayData', () => {
|
|
|
230
249
|
left_inside: 'TTAA',
|
|
231
250
|
right_inside: 'AATT'
|
|
232
251
|
};
|
|
233
|
-
|
|
252
|
+
|
|
234
253
|
const result = partDataToDisplayData(data);
|
|
235
|
-
|
|
254
|
+
|
|
236
255
|
expect(result.leftOverhangRc).toBe(getComplementSequenceString('ATGC'));
|
|
237
256
|
expect(result.rightOverhangRc).toBe(getComplementSequenceString('CGTA'));
|
|
238
257
|
expect(result.leftInsideRc).toBe(getComplementSequenceString('TTAA'));
|
|
@@ -252,9 +271,9 @@ describe('partDataToDisplayData', () => {
|
|
|
252
271
|
right_overhang: '',
|
|
253
272
|
right_inside: ''
|
|
254
273
|
};
|
|
255
|
-
|
|
274
|
+
|
|
256
275
|
const result = partDataToDisplayData(data);
|
|
257
|
-
|
|
276
|
+
|
|
258
277
|
// ATG CGC A... should translate to Met Arg...
|
|
259
278
|
expect(result.leftTranslationOverhang).toBe('Me');
|
|
260
279
|
expect(result.leftTranslationInside).toBe('tArg');
|
|
@@ -46,41 +46,41 @@ export function openCycleAtNode(graph, cutNode) {
|
|
|
46
46
|
}
|
|
47
47
|
return newGraph;
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
|
|
50
50
|
// Convert DAG to MSA-like matrix (rows = paths, columns = topological generations)
|
|
51
51
|
function dagToMSA(graph) {
|
|
52
52
|
const generations = topologicalGenerations(graph);
|
|
53
53
|
const nodeToCol = new Map();
|
|
54
54
|
generations.forEach((gen, col) => gen.forEach(node => nodeToCol.set(node, col)));
|
|
55
|
-
|
|
55
|
+
|
|
56
56
|
const sources = graph.nodes().filter(n => graph.inDegree(n) === 0);
|
|
57
57
|
const sinks = graph.nodes().filter(n => graph.outDegree(n) === 0);
|
|
58
|
-
|
|
58
|
+
|
|
59
59
|
const allPaths = sources.flatMap(src => sinks.flatMap(sink => allSimplePaths(graph, src, sink)));
|
|
60
|
-
|
|
60
|
+
|
|
61
61
|
return allPaths.map(path => {
|
|
62
62
|
const row = new Array(generations.length).fill(GRAPH_SPACER);
|
|
63
63
|
path.forEach(node => { row[nodeToCol.get(node)] = node; });
|
|
64
64
|
return row;
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
|
-
|
|
67
|
+
|
|
68
68
|
// Find independent segments in the MSA
|
|
69
69
|
// Two adjacent columns are in the same segment if they're correlated (vary together)
|
|
70
70
|
// They're independent if all combinations of values exist
|
|
71
71
|
function getIndependentSegments(msa) {
|
|
72
72
|
if (msa.length === 0) return [];
|
|
73
|
-
|
|
73
|
+
|
|
74
74
|
const numCols = msa[0].length;
|
|
75
75
|
const uniqueValues = col => new Set(msa.map(row => row[col]));
|
|
76
76
|
const isStable = col => uniqueValues(col).size === 1;
|
|
77
|
-
|
|
77
|
+
|
|
78
78
|
// Check if two columns vary independently
|
|
79
79
|
const areIndependent = (col1, col2) => {
|
|
80
80
|
const pairs = new Set(msa.map(row => `${row[col1]}|${row[col2]}`));
|
|
81
81
|
return pairs.size > Math.max(uniqueValues(col1).size, uniqueValues(col2).size);
|
|
82
82
|
};
|
|
83
|
-
|
|
83
|
+
|
|
84
84
|
// Find segment boundaries
|
|
85
85
|
const boundaries = [0];
|
|
86
86
|
for (let col = 0; col < numCols - 1; col++) {
|
|
@@ -90,7 +90,7 @@ function getIndependentSegments(msa) {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
boundaries.push(numCols);
|
|
93
|
-
|
|
93
|
+
|
|
94
94
|
// Build segments
|
|
95
95
|
return boundaries.slice(0, -1).map((start, i) => {
|
|
96
96
|
const end = boundaries[i + 1] - 1;
|
|
@@ -98,17 +98,17 @@ function getIndependentSegments(msa) {
|
|
|
98
98
|
return { start, end, stable: isStable(start), alternatives: alts };
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
|
-
|
|
101
|
+
|
|
102
102
|
// Find minimum set of rows that covers all unique alternatives (greedy set cover)
|
|
103
103
|
function minimumCoveringRows(msa) {
|
|
104
104
|
if (msa.length === 0) return [];
|
|
105
|
-
|
|
105
|
+
|
|
106
106
|
const segments = getIndependentSegments(msa).filter(s => !s.stable);
|
|
107
107
|
if (segments.length === 0) return [msa[0]];
|
|
108
|
-
|
|
108
|
+
|
|
109
109
|
// All alternatives that need to be covered
|
|
110
110
|
const allAlts = new Set(segments.flatMap((seg, i) => seg.alternatives.map(alt => `${i}:${alt}`)));
|
|
111
|
-
|
|
111
|
+
|
|
112
112
|
// Count non-gap elements in a row (gap is '---------')
|
|
113
113
|
const countElements = row => row.filter(cell => cell !== GRAPH_SPACER).length;
|
|
114
114
|
|
|
@@ -118,12 +118,12 @@ function minimumCoveringRows(msa) {
|
|
|
118
118
|
elements: countElements(row),
|
|
119
119
|
covered: new Set(segments.map((seg, i) => `${i}:${row.slice(seg.start, seg.end + 1).join(' | ')}`))
|
|
120
120
|
}));
|
|
121
|
-
|
|
121
|
+
|
|
122
122
|
// Greedy: pick rows that cover the most uncovered alternatives
|
|
123
123
|
// When tied, prefer rows with more elements (fewer gaps)
|
|
124
124
|
const selected = [];
|
|
125
125
|
const covered = new Set();
|
|
126
|
-
|
|
126
|
+
|
|
127
127
|
while (covered.size < allAlts.size) {
|
|
128
128
|
const best = rowCoverage.reduce((best, rc) => {
|
|
129
129
|
const newCount = [...rc.covered].filter(a => !covered.has(a)).length;
|
|
@@ -132,12 +132,12 @@ function minimumCoveringRows(msa) {
|
|
|
132
132
|
if (newCount === best.count && rc.elements > best.elements) return { rc, count: newCount, elements: rc.elements };
|
|
133
133
|
return best;
|
|
134
134
|
}, { rc: null, count: 0, elements: -1 });
|
|
135
|
-
|
|
135
|
+
|
|
136
136
|
if (!best.rc) break;
|
|
137
137
|
selected.push(best.rc.row);
|
|
138
138
|
best.rc.covered.forEach(a => covered.add(a));
|
|
139
139
|
}
|
|
140
|
-
|
|
140
|
+
|
|
141
141
|
// Sort by number of elements (most elements first)
|
|
142
142
|
return selected.sort((a, b) => countElements(b) - countElements(a));
|
|
143
143
|
}
|
|
@@ -112,7 +112,7 @@ describe('openCycleAtNode', () => {
|
|
|
112
112
|
graph.addEdge('A', 'B');
|
|
113
113
|
graph.addEdge('B', 'C');
|
|
114
114
|
graph.addEdge('C', 'B'); // Creates cycle
|
|
115
|
-
|
|
115
|
+
|
|
116
116
|
expect(graph.inDegree('B')).toBe(2);
|
|
117
117
|
const newGraph = openCycleAtNode(graph, 'B');
|
|
118
118
|
expect(newGraph.inDegree('B')).toBe(0);
|
|
@@ -126,7 +126,7 @@ describe('openCycleAtNode', () => {
|
|
|
126
126
|
graph.addNode('B');
|
|
127
127
|
graph.addEdge('A', 'B');
|
|
128
128
|
graph.addEdge('B', 'A'); // Cycle
|
|
129
|
-
|
|
129
|
+
|
|
130
130
|
const newGraph = openCycleAtNode(graph, 'A');
|
|
131
131
|
expect(newGraph.outDegree('A')).toBe(1);
|
|
132
132
|
expect(newGraph.hasEdge('A', 'B')).toBe(true);
|
|
@@ -137,7 +137,7 @@ describe('openCycleAtNode', () => {
|
|
|
137
137
|
graph.addNode('A');
|
|
138
138
|
graph.addNode('B');
|
|
139
139
|
graph.addEdge('A', 'B');
|
|
140
|
-
|
|
140
|
+
|
|
141
141
|
const newGraph = openCycleAtNode(graph, 'A');
|
|
142
142
|
expect(newGraph.inDegree('A')).toBe(0);
|
|
143
143
|
expect(newGraph.hasEdge('A', 'B')).toBe(true);
|
|
@@ -203,7 +203,7 @@ describe('graphHasCycle', () => {
|
|
|
203
203
|
graph.addNode('C');
|
|
204
204
|
graph.addEdge('A', 'B');
|
|
205
205
|
graph.addEdge('B', 'C');
|
|
206
|
-
|
|
206
|
+
|
|
207
207
|
expect(graphHasCycle(graph)).toBe(false);
|
|
208
208
|
});
|
|
209
209
|
|
|
@@ -213,7 +213,7 @@ describe('graphHasCycle', () => {
|
|
|
213
213
|
graph.addNode('B');
|
|
214
214
|
graph.addEdge('A', 'B');
|
|
215
215
|
graph.addEdge('B', 'A');
|
|
216
|
-
|
|
216
|
+
|
|
217
217
|
expect(graphHasCycle(graph)).toBe(true);
|
|
218
218
|
});
|
|
219
219
|
|
|
@@ -221,7 +221,7 @@ describe('graphHasCycle', () => {
|
|
|
221
221
|
const graph = new DirectedGraph();
|
|
222
222
|
graph.addNode('A');
|
|
223
223
|
graph.addEdge('A', 'A');
|
|
224
|
-
|
|
224
|
+
|
|
225
225
|
expect(graphHasCycle(graph)).toBe(true);
|
|
226
226
|
});
|
|
227
227
|
|
|
@@ -233,7 +233,7 @@ describe('graphHasCycle', () => {
|
|
|
233
233
|
it('handles single node with no edges', () => {
|
|
234
234
|
const graph = new DirectedGraph();
|
|
235
235
|
graph.addNode('A');
|
|
236
|
-
|
|
236
|
+
|
|
237
237
|
expect(graphHasCycle(graph)).toBe(false);
|
|
238
238
|
});
|
|
239
239
|
});
|
|
@@ -197,7 +197,7 @@ export default function DomesticateDialog({ open, onClose, initialContext, mainS
|
|
|
197
197
|
))}
|
|
198
198
|
</Select>
|
|
199
199
|
</FormControl>
|
|
200
|
-
{location.includes('join') && cloningType === 'synthesis' &&
|
|
200
|
+
{location.includes('join') && cloningType === 'synthesis' &&
|
|
201
201
|
<Alert severity="info" sx={{ mb: 2 }}>Synthesis will remove introns in this feature</Alert>
|
|
202
202
|
}
|
|
203
203
|
<FormControl fullWidth margin="dense">
|
|
@@ -38,7 +38,8 @@ export default {
|
|
|
38
38
|
// Function to get the sequencing files from the database, see docs for what the return value should be
|
|
39
39
|
getSequencingFiles: null,
|
|
40
40
|
// Component for loading several sequences at once (e.g. in the assembler), calls
|
|
41
|
-
// onSequencesLoaded with an array of { databaseId, name, textFileSequence }
|
|
41
|
+
// onSequencesLoaded with an array of { databaseId, name, textFileSequence }. Optional defaultSequenceTypes
|
|
42
|
+
// (e.g. ['plasmid']) sets which sequence types are selected by default when loading by tag
|
|
42
43
|
GetSequencesInBulkComponent: null,
|
|
43
44
|
// Dialog for submitting several cloning strategies at once (e.g. the assembler products), receives
|
|
44
45
|
// { files, onClose, onSuccess }, where files is an array of File (cloning strategy JSON)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ChipMultiSelect from './ChipMultiSelect';
|
|
3
|
+
import { clickMultiSelectOption } from '../../../../../cypress/e2e/common_functions';
|
|
4
|
+
|
|
5
|
+
const OPTIONS = [{ id: 1, label: 'One' }, { id: 2, label: 'Two' }, { id: 3, label: 'Three' }];
|
|
6
|
+
|
|
7
|
+
function Wrapper({ initialValue = [], onChange, ...rest }) {
|
|
8
|
+
const [value, setValue] = React.useState(initialValue);
|
|
9
|
+
return (
|
|
10
|
+
<ChipMultiSelect
|
|
11
|
+
label="Numbers"
|
|
12
|
+
options={OPTIONS}
|
|
13
|
+
value={value}
|
|
14
|
+
onChange={(ids) => { setValue(ids); onChange(ids); }}
|
|
15
|
+
{...rest}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('<ChipMultiSelect />', () => {
|
|
21
|
+
it('shows the selected options as chips and calls onChange with ids', () => {
|
|
22
|
+
const onChangeSpy = cy.spy().as('onChangeSpy');
|
|
23
|
+
cy.mount(<Wrapper initialValue={[2]} onChange={onChangeSpy} />);
|
|
24
|
+
|
|
25
|
+
cy.contains('.MuiChip-root', 'Two').should('exist');
|
|
26
|
+
clickMultiSelectOption('Numbers', 'Three', 'div');
|
|
27
|
+
cy.get('@onChangeSpy').should('have.been.calledWith', [2, 3]);
|
|
28
|
+
cy.contains('.MuiChip-root', 'Two').find('.MuiChip-deleteIcon').click();
|
|
29
|
+
cy.get('@onChangeSpy').should('have.been.calledWith', [3]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('ignores values that are not in the options', () => {
|
|
33
|
+
cy.mount(<Wrapper initialValue={[1, 99]} onChange={cy.spy()} />);
|
|
34
|
+
cy.get('.MuiChip-root').should('have.length', 1).and('contain', 'One');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('shows the placeholder only when nothing is selected', () => {
|
|
38
|
+
cy.mount(<Wrapper initialValue={[1]} onChange={cy.spy()} placeholder="Any number" />);
|
|
39
|
+
cy.get('input[placeholder="Any number"]').should('not.exist');
|
|
40
|
+
cy.contains('.MuiChip-root', 'One').find('.MuiChip-deleteIcon').click();
|
|
41
|
+
cy.get('input[placeholder="Any number"]').should('exist');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Autocomplete, Chip, TextField } from '@mui/material';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Multi-select that shows the selected options as chips.
|
|
6
|
+
* options is an array of { id, label }, value and onChange use arrays of ids.
|
|
7
|
+
* placeholder is shown when nothing is selected, id is set on the input.
|
|
8
|
+
*/
|
|
9
|
+
function ChipMultiSelect({ label, options, value, onChange, loading, disabled, sx, size = 'small', placeholder, id }) {
|
|
10
|
+
const optionById = React.useMemo(
|
|
11
|
+
() => new Map(options.map((opt) => [opt.id, opt])),
|
|
12
|
+
[options],
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const selectedOptions = React.useMemo(
|
|
16
|
+
() => value.map((optionId) => optionById.get(optionId)).filter(Boolean),
|
|
17
|
+
[value, optionById],
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Autocomplete
|
|
22
|
+
multiple
|
|
23
|
+
size={size}
|
|
24
|
+
options={options}
|
|
25
|
+
getOptionLabel={(option) => option.label}
|
|
26
|
+
isOptionEqualToValue={(option, selected) => option.id === selected.id}
|
|
27
|
+
value={selectedOptions}
|
|
28
|
+
onChange={(_, newValue) => {
|
|
29
|
+
const ids = newValue.map((opt) => opt.id);
|
|
30
|
+
onChange(ids);
|
|
31
|
+
}}
|
|
32
|
+
renderTags={(tagValue, getTagProps) =>
|
|
33
|
+
tagValue.map((option, index) => (
|
|
34
|
+
<Chip
|
|
35
|
+
{...getTagProps({ index })}
|
|
36
|
+
key={option.id}
|
|
37
|
+
label={option.label}
|
|
38
|
+
size="small"
|
|
39
|
+
/>
|
|
40
|
+
))
|
|
41
|
+
}
|
|
42
|
+
renderInput={(params) => (
|
|
43
|
+
<TextField
|
|
44
|
+
{...params}
|
|
45
|
+
id={id ?? params.id}
|
|
46
|
+
label={label}
|
|
47
|
+
variant="outlined"
|
|
48
|
+
placeholder={selectedOptions.length === 0 ? placeholder : undefined}
|
|
49
|
+
// Keep the label above the input, so that it does not cover the placeholder
|
|
50
|
+
InputLabelProps={placeholder ? { ...params.InputLabelProps, shrink: true } : params.InputLabelProps}
|
|
51
|
+
/>
|
|
52
|
+
)}
|
|
53
|
+
loading={loading}
|
|
54
|
+
disabled={disabled}
|
|
55
|
+
sx={sx}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default ChipMultiSelect;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { Autocomplete, TextField, Alert, Button, CircularProgress, FormControl, FormHelperText, InputLabel, MenuItem, Select } from '@mui/material';
|
|
3
3
|
|
|
4
|
-
export default function GetRequestMultiSelect({
|
|
4
|
+
export default function GetRequestMultiSelect({
|
|
5
5
|
getOptionsFromResponse,
|
|
6
6
|
url,
|
|
7
7
|
label,
|
package/src/components/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export { default as EditTextDialog } from './form/EditTextDialog';
|
|
|
4
4
|
export { default as useUrlParamsLoader } from '../hooks/useUrlParamsLoader';
|
|
5
5
|
export { default as useInitializeApp } from '../hooks/useInitializeApp';
|
|
6
6
|
export { default as QuerySelect } from './form/QuerySelect';
|
|
7
|
+
export { default as ChipMultiSelect } from './form/ChipMultiSelect';
|
|
7
8
|
export { default as QueryStatusWrapper } from './form/QueryStatusWrapper';
|
|
8
9
|
export { default as TextFieldQueryValidated } from './form/TextFieldQueryValidated';
|
|
9
10
|
export { default as useDebouncedSearchQuery } from '../hooks/useDebouncedSearchQuery';
|
|
@@ -85,7 +85,7 @@ function MainAppBar() {
|
|
|
85
85
|
const helpMenu = [
|
|
86
86
|
{ display: 'Newsletter', onClick: () => window.open('https://eepurl.com/h9-n71') },
|
|
87
87
|
{ display: 'About the project', onClick: () => window.open('https://github.com/OpenCloning/OpenCloning') },
|
|
88
|
-
{ display: 'GitHub repository', onClick: () => window.open('https://github.com/OpenCloning/
|
|
88
|
+
{ display: 'GitHub repository', onClick: () => window.open('https://github.com/OpenCloning/OpenCloning') },
|
|
89
89
|
{ display: 'Demo videos', onClick: () => window.open('https://www.youtube.com/watch?v=n0hedzvpW88&list=PLpv3x-ensLZkJToD2E6ejefADmHcUPYSJ&index=1') },
|
|
90
90
|
{ display: 'App version', onClick: () => setOpenVersionDialog(true) },
|
|
91
91
|
];
|
|
@@ -31,7 +31,7 @@ describe('<AssemblyIdSelector />', () => {
|
|
|
31
31
|
}).as('getAssemblyInfo');
|
|
32
32
|
|
|
33
33
|
cy.intercept('GET', 'https://api.ncbi.nlm.nih.gov/datasets/v2alpha/genome/accession/GCF_000002945.3/dataset_report*', {
|
|
34
|
-
statusCode: 200,
|
|
34
|
+
statusCode: 200,
|
|
35
35
|
body: {
|
|
36
36
|
reports: [{
|
|
37
37
|
accession: 'GCF_000002945.3',
|
|
@@ -49,7 +49,7 @@ describe('<AssemblyIdSelector />', () => {
|
|
|
49
49
|
|
|
50
50
|
cy.mount(
|
|
51
51
|
<ConfigProvider config={config}>
|
|
52
|
-
<AssemblyIdSelector
|
|
52
|
+
<AssemblyIdSelector
|
|
53
53
|
setAssemblyId={setAssemblyId}
|
|
54
54
|
setHasAnnotation={setHasAnnotation}
|
|
55
55
|
onAssemblyIdChange={onAssemblyIdChange}
|
|
@@ -188,5 +188,3 @@ describe('<SequenceAccessionPicker />', () => {
|
|
|
188
188
|
|
|
189
189
|
});
|
|
190
190
|
});
|
|
191
|
-
|
|
192
|
-
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from '@mui/material';
|
|
19
19
|
import { batch, shallowEqual, useDispatch, useSelector, useStore } from 'react-redux';
|
|
20
20
|
import { isEqual } from 'lodash-es';
|
|
21
|
-
import { getTeselaJsonFromBase64,
|
|
21
|
+
import { getTeselaJsonFromBase64, readSequencingFile } from '@opencloning/utils/readNwrite';
|
|
22
22
|
import SequencingFileRow from './SequencingFileRow';
|
|
23
23
|
import { cloningActions } from '@opencloning/store/cloning';
|
|
24
24
|
import useStoreEditor from '../../hooks/useStoreEditor';
|
|
@@ -66,10 +66,10 @@ export default function VerificationFileDialog({ id, dialogOpen, setDialogOpen }
|
|
|
66
66
|
|
|
67
67
|
// Read the new sequencing files
|
|
68
68
|
const parsedSequencingFiles = await Promise.all(newFiles.map(async (newFile) => {
|
|
69
|
-
const base64str = await
|
|
69
|
+
const { base64str, teselaJson } = await readSequencingFile(newFile);
|
|
70
70
|
return {
|
|
71
71
|
sequence_id: id,
|
|
72
|
-
trace:
|
|
72
|
+
trace: teselaJson.sequence,
|
|
73
73
|
base64str,
|
|
74
74
|
file_name: newFile.name,
|
|
75
75
|
file_type: 'Sequencing file',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback } from 'react';
|
|
2
2
|
import { useMutation } from '@tanstack/react-query';
|
|
3
|
-
import {
|
|
3
|
+
import { buildSequencingAlignmentConfig } from '@opencloning/utils/alignmentUtils';
|
|
4
4
|
import useBackendHttpClient from './useBackendHttpClient';
|
|
5
5
|
|
|
6
6
|
export default function useSequencingAlignment({ onError } = {}) {
|
|
@@ -10,14 +10,7 @@ export default function useSequencingAlignment({ onError } = {}) {
|
|
|
10
10
|
// parsedFiles: [{ fileName, fileContent }] where fileContent is getTeselaJsonFromBase64 output
|
|
11
11
|
const traces = parsedFiles.map((f) => f.fileContent.sequence);
|
|
12
12
|
const resp = await httpClient.post('align_sanger', { sequence: sequenceModel, traces });
|
|
13
|
-
|
|
14
|
-
const referenceTrack = buildReferenceTrack(sequenceData, resp.data[0]);
|
|
15
|
-
const otherTracks = parsedFiles.map((f, i) => {
|
|
16
|
-
const alignmentFile = { file_name: f.fileName, alignment: [resp.data[0], resp.data[i + 1]] }; // eslint-disable-line camelcase
|
|
17
|
-
return buildAlignmentTrack(f.fileContent, alignmentFile);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
return buildAlignmentConfig(sequenceModel.id, sequenceData, [referenceTrack, ...otherTracks]);
|
|
13
|
+
return buildSequencingAlignmentConfig(sequenceModel, sequenceData, parsedFiles, resp.data);
|
|
21
14
|
}, [httpClient]);
|
|
22
15
|
|
|
23
16
|
return useMutation({ mutationFn: fetchAlignments, onError });
|
package/src/index.js
CHANGED
|
@@ -4,7 +4,7 @@ const ConfigContext = createContext(null);
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* ConfigProvider - Provides application configuration via React Context
|
|
7
|
-
*
|
|
7
|
+
*
|
|
8
8
|
* @param {Object} props
|
|
9
9
|
* @param {Object} props.config - Config object
|
|
10
10
|
* @param {React.ReactNode} props.children - Child components
|
|
@@ -27,7 +27,7 @@ export function ConfigProvider({ config, children }) {
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* useConfig - Hook to access configuration from ConfigProvider
|
|
30
|
-
*
|
|
30
|
+
*
|
|
31
31
|
* @returns {Object} Configuration object with properties:
|
|
32
32
|
* - backendUrl: string
|
|
33
33
|
* - showAppBar: boolean
|
|
@@ -36,12 +36,12 @@ export function ConfigProvider({ config, children }) {
|
|
|
36
36
|
* - requiresBackendAuth: boolean
|
|
37
37
|
* - noExternalRequests: boolean
|
|
38
38
|
* - database: string | null
|
|
39
|
-
*
|
|
39
|
+
*
|
|
40
40
|
* @throws {Error} If used outside of ConfigProvider
|
|
41
41
|
*/
|
|
42
42
|
export function useConfig() {
|
|
43
43
|
const context = useContext(ConfigContext);
|
|
44
|
-
|
|
44
|
+
|
|
45
45
|
if (context === null) {
|
|
46
46
|
throw new Error('useConfig must be used within a ConfigProvider');
|
|
47
47
|
}
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Version placeholder - replaced at publish time via prepack script
|
|
2
|
-
export const version = "2.
|
|
2
|
+
export const version = "2.2.1";
|