@opencloning/ui 2.0.0 → 2.1.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 +25 -0
- package/package.json +3 -3
- package/src/components/assembler/Assembler.cy.jsx +184 -0
- package/src/components/assembler/Assembler.jsx +54 -6
- package/src/components/assembler/UploadPlasmidsButton.cy.jsx +88 -6
- package/src/components/assembler/UploadPlasmidsButton.jsx +137 -73
- package/src/components/assembler/assembler_utils.js +25 -12
- package/src/components/assembler/assembler_utils.test.js +40 -1
- package/src/components/assembler/useAssembler.js +18 -0
- package/src/components/assembler/usePlasmidsLogic.js +20 -2
- package/src/components/domesticate/DomesticateDialog.jsx +1 -0
- package/src/components/dummy/DummyInterface.js +6 -0
- package/src/version.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @opencloning/ui
|
|
2
2
|
|
|
3
|
+
## 2.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#786](https://github.com/OpenCloning/OpenCloning_frontend/pull/786) [`c8c46d4`](https://github.com/OpenCloning/OpenCloning_frontend/commit/c8c46d4dc36cb8741d1bd89816c7ca82b89996b3) Thanks [@manulera](https://github.com/manulera)! - Assembler: load plasmids from the database and upload the assemblies to it.
|
|
8
|
+
|
|
9
|
+
- `@opencloning/ui`:
|
|
10
|
+
- The Assembler's plasmid buttons are grouped into a "Load Plasmids" menu (from files, from the local server, from the database).
|
|
11
|
+
- When the database interface provides `GetSequencesInBulkComponent`, plasmids can be loaded by tag or picked, and are used as `DatabaseSource`s. In that case the default plasmids of a syntax are not loaded.
|
|
12
|
+
- When it provides `SubmitCloningStrategiesInBulkComponent`, a new "Upload to DB" button uploads the assemblies with their history, as in the bulk upload of sequences with history.
|
|
13
|
+
- Both are new optional entries of the database interface (see `DummyInterface`).
|
|
14
|
+
- `@opencloning/opencloningdb`:
|
|
15
|
+
- Implements `GetSequencesInBulkComponent` and `SubmitCloningStrategiesInBulkComponent`.
|
|
16
|
+
- Exports `TagMultiSelect` (with `allowCreate` and `onCreateError`), `BulkUploadModal`, `BulkUploadPreview` and `CommonTableRow`, moved from the `opencloningdb` app.
|
|
17
|
+
- `opencloningdb`:
|
|
18
|
+
- The "Upload sequences with history" button uses `SubmitCloningStrategiesInBulkComponent`. Its validation, submission and conflict messages are now shown in the dialog instead of as alerts.
|
|
19
|
+
- The tag selects no longer drop excluded tags from the shared tags cache.
|
|
20
|
+
- `syntax-builder`: the dev server runs on port 3000 and listens on `127.0.0.1`, like the other apps.
|
|
21
|
+
|
|
22
|
+
### Patch Changes
|
|
23
|
+
|
|
24
|
+
- Updated dependencies []:
|
|
25
|
+
- @opencloning/store@2.1.0
|
|
26
|
+
- @opencloning/utils@2.1.0
|
|
27
|
+
|
|
3
28
|
## 2.0.0
|
|
4
29
|
|
|
5
30
|
### Major Changes
|
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.1.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"scripts": {
|
|
@@ -40,8 +40,8 @@
|
|
|
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.1.0",
|
|
44
|
+
"@opencloning/utils": "2.1.0",
|
|
45
45
|
"@teselagen/bio-parsers": "^0.4.38",
|
|
46
46
|
"@teselagen/ove": "^0.8.42",
|
|
47
47
|
"@teselagen/range-utils": "^0.3.20",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/* eslint-disable camelcase */
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { ConfigProvider } from '@opencloning/ui/providers/ConfigProvider';
|
|
4
|
+
import { DatabaseProvider } from '@opencloning/ui/providers/DatabaseContext';
|
|
5
|
+
import store from '@opencloning/store';
|
|
4
6
|
import { AssemblerComponent } from './Assembler';
|
|
5
7
|
|
|
6
8
|
|
|
@@ -362,3 +364,185 @@ describe('<AssemblerComponent />', () => {
|
|
|
362
364
|
});
|
|
363
365
|
});
|
|
364
366
|
|
|
367
|
+
describe('<AssemblerComponent /> with plasmids from a database', () => {
|
|
368
|
+
const databasePlasmids = [
|
|
369
|
+
{ id: 1, key: 'CCCT-AACG', database_id: 10 },
|
|
370
|
+
{ id: 2, key: 'AACG-CCCT', database_id: 20 },
|
|
371
|
+
].map(({ id, key, database_id }) => {
|
|
372
|
+
const [left_overhang, right_overhang] = key.split('-');
|
|
373
|
+
return {
|
|
374
|
+
id,
|
|
375
|
+
type: 'databaseSequence',
|
|
376
|
+
plasmid_name: `DB Plasmid ${database_id}`,
|
|
377
|
+
database_id,
|
|
378
|
+
left_overhang,
|
|
379
|
+
right_overhang,
|
|
380
|
+
key,
|
|
381
|
+
userUploaded: true,
|
|
382
|
+
textFileSequence: { ...dummyResponse.sequences[0], id: database_id, file_content: `${dummyResponse.sequences[0].file_content}\n${database_id}` },
|
|
383
|
+
};
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('uses the database sequences and creates DatabaseSources', () => {
|
|
387
|
+
cy.intercept('POST', 'http://localhost:8000/restriction_and_ligation*', (req) => {
|
|
388
|
+
// Sequence content is the one from the database, no request is made to fetch them
|
|
389
|
+
expect(req.body.sequences.map((s) => s.file_content)).to.deep.equal(databasePlasmids.map((p) => p.textFileSequence.file_content));
|
|
390
|
+
req.reply({ statusCode: 200, body: dummyResponse2 });
|
|
391
|
+
}).as('assemblySuccess');
|
|
392
|
+
|
|
393
|
+
cy.mount(
|
|
394
|
+
<ConfigProvider config={testConfig}>
|
|
395
|
+
<AssemblerComponent
|
|
396
|
+
plasmids={databasePlasmids}
|
|
397
|
+
categories={mockCategories}
|
|
398
|
+
assemblyEnzymes={['assembly_enzyme']}
|
|
399
|
+
addAlert={cy.stub().as('addAlert')}
|
|
400
|
+
appInfo={{}}
|
|
401
|
+
/>
|
|
402
|
+
</ConfigProvider>,
|
|
403
|
+
);
|
|
404
|
+
cy.get('[data-testid="plasmid-select"]').first().within(() => {
|
|
405
|
+
cy.get('input').click();
|
|
406
|
+
});
|
|
407
|
+
// Plasmids from the database are not highlighted (only those uploaded from files)
|
|
408
|
+
cy.get('li').contains('DB Plasmid 10').should('not.have.css', 'background-color', 'rgb(220, 237, 200)');
|
|
409
|
+
cy.get('li').contains('DB Plasmid 10').click();
|
|
410
|
+
cy.get('[data-testid="plasmid-select"]').eq(1).within(() => {
|
|
411
|
+
cy.get('input').click();
|
|
412
|
+
});
|
|
413
|
+
cy.get('li').contains('DB Plasmid 20').click();
|
|
414
|
+
|
|
415
|
+
cy.get('[data-testid="assembler-submit-button"]').click();
|
|
416
|
+
cy.wait('@assemblySuccess');
|
|
417
|
+
cy.get('[data-testid="assembler-product-table"]').contains('DB Plasmid 10').should('exist');
|
|
418
|
+
|
|
419
|
+
cy.get('[data-testid="assembler-product-table-view-button"]').first().click();
|
|
420
|
+
cy.then(() => {
|
|
421
|
+
const { sources, sequences } = store.getState().cloning;
|
|
422
|
+
const databaseSources = sources.filter((s) => s.type === 'DatabaseSource');
|
|
423
|
+
expect(databaseSources.map((s) => s.database_id)).to.deep.equal([10, 20]);
|
|
424
|
+
databaseSources.forEach((source) => {
|
|
425
|
+
expect(source.input).to.deep.equal([]);
|
|
426
|
+
const sequence = sequences.find((seq) => seq.id === source.id);
|
|
427
|
+
expect(sequence.file_content).to.equal(databasePlasmids.find((p) => p.database_id === source.database_id).textFileSequence.file_content);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
cy.get('@addAlert').should('have.not.been.called');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
describe('Upload to DB', () => {
|
|
434
|
+
function mountAndAssemble(database) {
|
|
435
|
+
cy.intercept('POST', 'http://localhost:8000/restriction_and_ligation*', { statusCode: 200, body: dummyResponse2 }).as('assemblySuccess');
|
|
436
|
+
const component = (
|
|
437
|
+
<ConfigProvider config={testConfig}>
|
|
438
|
+
<AssemblerComponent
|
|
439
|
+
plasmids={databasePlasmids}
|
|
440
|
+
categories={mockCategories}
|
|
441
|
+
assemblyEnzymes={['assembly_enzyme']}
|
|
442
|
+
addAlert={cy.stub().as('addAlert')}
|
|
443
|
+
appInfo={{ backendVersion: '1', schemaVersion: '2', frontendVersion: '3' }}
|
|
444
|
+
/>
|
|
445
|
+
</ConfigProvider>
|
|
446
|
+
);
|
|
447
|
+
cy.mount(database ? <DatabaseProvider value={database}>{component}</DatabaseProvider> : component);
|
|
448
|
+
cy.get('[data-testid="plasmid-select"]').first().within(() => {
|
|
449
|
+
cy.get('input').click();
|
|
450
|
+
});
|
|
451
|
+
cy.get('li').contains('DB Plasmid 10').click();
|
|
452
|
+
cy.get('[data-testid="plasmid-select"]').eq(1).within(() => {
|
|
453
|
+
cy.get('input').click();
|
|
454
|
+
});
|
|
455
|
+
cy.get('li').contains('DB Plasmid 20').click();
|
|
456
|
+
cy.get('[data-testid="assembler-submit-button"]').click();
|
|
457
|
+
cy.wait('@assemblySuccess');
|
|
458
|
+
cy.get('[data-testid="assembler-download-assemblies-button"]').should('exist');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Mock of the database dialog, stores the props it receives
|
|
462
|
+
// submittedCount: number of strategies the mock reports as submitted on success
|
|
463
|
+
function mockDatabase(submittedCount = 1) {
|
|
464
|
+
const receivedProps = [];
|
|
465
|
+
function SubmitCloningStrategiesInBulkComponent(props) {
|
|
466
|
+
receivedProps.push(props);
|
|
467
|
+
const syncResults = Array.from({ length: submittedCount }, (_, i) => ({ file_name: `${i}.json` }));
|
|
468
|
+
return (
|
|
469
|
+
<div data-testid="mock-submit-dialog">
|
|
470
|
+
<button type="button" onClick={() => { props.onSuccess([{ id: 100 }], syncResults); props.onClose(); }}>mock success</button>
|
|
471
|
+
<button type="button" onClick={props.onClose}>mock close</button>
|
|
472
|
+
</div>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
return { database: { name: 'MockDB', SubmitCloningStrategiesInBulkComponent }, receivedProps };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
it('is not shown without a database', () => {
|
|
479
|
+
mountAndAssemble(null);
|
|
480
|
+
cy.get('[data-testid="assembler-upload-to-db-button"]').should('not.exist');
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('is not shown if the database does not support bulk submission', () => {
|
|
484
|
+
mountAndAssemble({ name: 'MockDB' });
|
|
485
|
+
cy.get('[data-testid="assembler-upload-to-db-button"]').should('not.exist');
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('is disabled when an output name is empty', () => {
|
|
489
|
+
const { database } = mockDatabase();
|
|
490
|
+
mountAndAssemble(database);
|
|
491
|
+
cy.get('[data-testid="assembler-upload-to-db-button"]').should('not.be.disabled');
|
|
492
|
+
cy.get('[data-testid="assembler-product-table-edit-button"]').first().click();
|
|
493
|
+
cy.get('[role="dialog"]').find('input').clear();
|
|
494
|
+
cy.get('[role="dialog"]').contains('Save').click();
|
|
495
|
+
cy.get('[data-testid="assembler-upload-to-db-button"]').should('be.disabled');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('passes the cloning strategies of the assemblies to the database dialog', () => {
|
|
499
|
+
const { database, receivedProps } = mockDatabase();
|
|
500
|
+
mountAndAssemble(database);
|
|
501
|
+
cy.get('[data-testid="assembler-product-table-edit-button"]').first().click();
|
|
502
|
+
cy.get('[role="dialog"]').find('input').clear();
|
|
503
|
+
cy.get('[role="dialog"]').find('input').type('my product');
|
|
504
|
+
cy.get('[role="dialog"]').contains('Save').click();
|
|
505
|
+
|
|
506
|
+
cy.get('[data-testid="mock-submit-dialog"]').should('not.exist');
|
|
507
|
+
cy.get('[data-testid="assembler-upload-to-db-button"]').click();
|
|
508
|
+
cy.get('[data-testid="mock-submit-dialog"]').should('exist');
|
|
509
|
+
cy.then(() => {
|
|
510
|
+
const { files } = receivedProps[receivedProps.length - 1];
|
|
511
|
+
expect(files).to.have.length(1);
|
|
512
|
+
expect(files[0]).to.be.instanceOf(File);
|
|
513
|
+
expect(files[0].name).to.equal('my_product.json');
|
|
514
|
+
return files[0].text();
|
|
515
|
+
}).then((content) => {
|
|
516
|
+
const cloningStrategy = JSON.parse(content);
|
|
517
|
+
expect(cloningStrategy.backend_version).to.equal('1');
|
|
518
|
+
const databaseSources = cloningStrategy.sources.filter((s) => s.type === 'DatabaseSource');
|
|
519
|
+
expect(databaseSources.map((s) => s.database_id)).to.deep.equal([10, 20]);
|
|
520
|
+
const finalSource = cloningStrategy.sources.find((s) => s.type === 'RestrictionAndLigationSource');
|
|
521
|
+
expect(finalSource.output_name).to.equal('my_product');
|
|
522
|
+
const finalSequence = cloningStrategy.sequences.find((s) => s.id === finalSource.id);
|
|
523
|
+
expect(finalSequence.file_content.split('\n')[0]).to.contain('my_product');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
cy.contains('button', 'mock success').click();
|
|
527
|
+
cy.get('[data-testid="mock-submit-dialog"]').should('not.exist');
|
|
528
|
+
cy.get('@addAlert').should('have.been.calledOnceWith', { message: 'Uploaded 1 assembly to MockDB', severity: 'success' });
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('reports the number of submitted assemblies, not the number of files', () => {
|
|
532
|
+
const { database } = mockDatabase(2);
|
|
533
|
+
mountAndAssemble(database);
|
|
534
|
+
cy.get('[data-testid="assembler-upload-to-db-button"]').click();
|
|
535
|
+
cy.contains('button', 'mock success').click();
|
|
536
|
+
cy.get('@addAlert').should('have.been.calledOnceWith', { message: 'Uploaded 2 assemblies to MockDB', severity: 'success' });
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('closes the dialog without alerts', () => {
|
|
540
|
+
const { database } = mockDatabase();
|
|
541
|
+
mountAndAssemble(database);
|
|
542
|
+
cy.get('[data-testid="assembler-upload-to-db-button"]').click();
|
|
543
|
+
cy.contains('button', 'mock close').click();
|
|
544
|
+
cy.get('[data-testid="mock-submit-dialog"]').should('not.exist');
|
|
545
|
+
cy.get('@addAlert').should('not.have.been.called');
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
});
|
|
@@ -18,9 +18,10 @@ import EditTextDialog from '../form/EditTextDialog';
|
|
|
18
18
|
import useCombinatorialAssembly from './useCombinatorialAssembly';
|
|
19
19
|
import ExistingSyntaxDialog from './ExistingSyntaxDialog';
|
|
20
20
|
import error2String from '@opencloning/utils/error2String';
|
|
21
|
-
import { categoryFilter, downloadAssemblerFilesAsZip, getFilesToExportFromAssembler, getDefaultAssemblyOutputName, MAX_OUTPUT_NAME_LENGTH, sanitizeOutputName } from './assembler_utils';
|
|
21
|
+
import { categoryFilter, downloadAssemblerFilesAsZip, getFilesToExportFromAssembler, getCloningStrategyFilesFromAssembler, getDefaultAssemblyOutputName, MAX_OUTPUT_NAME_LENGTH, sanitizeOutputName } from './assembler_utils';
|
|
22
22
|
import useBackendHttpClient from '../../hooks/useBackendHttpClient';
|
|
23
23
|
import useCloningAlerts from '../../hooks/useCloningAlerts';
|
|
24
|
+
import useDatabase from '../../hooks/useDatabase';
|
|
24
25
|
import UploadPlasmidsButton from './UploadPlasmidsButton';
|
|
25
26
|
import { useConfig } from '../../providers';
|
|
26
27
|
import { isEqual } from 'lodash-es';
|
|
@@ -30,6 +31,9 @@ import { graphToMSA, partsToGraph } from './graph_utils';
|
|
|
30
31
|
|
|
31
32
|
const { setState: setCloningState, setCurrentTab: setCurrentTabAction } = cloningActions;
|
|
32
33
|
|
|
34
|
+
// Shown on the download and upload buttons when they are disabled because of the output names
|
|
35
|
+
const INVALID_OUTPUT_NAMES_MESSAGE = `Output names must be unique, non-empty, and at most ${MAX_OUTPUT_NAME_LENGTH} characters`;
|
|
36
|
+
|
|
33
37
|
function formatItemName(item) {
|
|
34
38
|
// Fallback in case the item is not found (while updating list)
|
|
35
39
|
return item ? `${item.plasmid_name}` : '-'
|
|
@@ -194,7 +198,7 @@ function AssemblerBox({ item, index, setCategory, setId, categories, plasmids, a
|
|
|
194
198
|
const { key, ...restProps } = props
|
|
195
199
|
const plasmid = plasmids.find((d) => d.id === option)
|
|
196
200
|
return (
|
|
197
|
-
<MenuItem key={key} {...restProps} sx={{ backgroundColor: plasmid.
|
|
201
|
+
<MenuItem key={key} {...restProps} sx={{ backgroundColor: plasmid.type === 'loadedFile' ? '#dcedc8' : undefined }}>
|
|
198
202
|
{formatItemName(plasmid)}
|
|
199
203
|
</MenuItem>
|
|
200
204
|
)}}
|
|
@@ -215,11 +219,16 @@ export function AssemblerComponent({ plasmids, categories, assemblyEnzymes, addA
|
|
|
215
219
|
const [assemblyOutputNames, setAssemblyOutputNames] = React.useState([])
|
|
216
220
|
const [errorMessage, setErrorMessage] = React.useState('')
|
|
217
221
|
const [loadingMessage, setLoadingMessage] = React.useState('')
|
|
222
|
+
// Cloning strategy files being uploaded to the database (null if the dialog is closed)
|
|
223
|
+
const [filesToUpload, setFilesToUpload] = React.useState(null)
|
|
224
|
+
const database = useDatabase()
|
|
225
|
+
const SubmitCloningStrategiesInBulkComponent = database?.SubmitCloningStrategiesInBulkComponent
|
|
218
226
|
|
|
219
227
|
const clearAssemblySelection = React.useCallback(() => {
|
|
220
228
|
setRequestedAssemblies([])
|
|
221
229
|
setAssemblyOutputNames([])
|
|
222
230
|
setErrorMessage('')
|
|
231
|
+
setFilesToUpload(null)
|
|
223
232
|
}, [])
|
|
224
233
|
|
|
225
234
|
const { assembly, setCategory, setId, expandedAssemblies, assemblyComplete, canBeSubmitted, currentCategories } = useCombinatorialAssembly({ onValueChange: clearAssemblySelection, categories, plasmids })
|
|
@@ -282,6 +291,21 @@ export function AssemblerComponent({ plasmids, categories, assemblyEnzymes, addA
|
|
|
282
291
|
}
|
|
283
292
|
}, [requestedAssemblies, expandedAssemblies, plasmids, currentCategories, categories, appInfo, addAlert, assemblyOutputNames])
|
|
284
293
|
|
|
294
|
+
const onUploadToDatabase = React.useCallback(() => {
|
|
295
|
+
try {
|
|
296
|
+
setFilesToUpload(getCloningStrategyFilesFromAssembler({ requestedAssemblies, appInfo, outputNames: assemblyOutputNames })
|
|
297
|
+
.map(({ name, content }) => new File([content], name, { type: 'application/json' })))
|
|
298
|
+
} catch (error) {
|
|
299
|
+
addAlert({ message: `Error preparing assemblies for upload: ${error.message}`, severity: 'error' })
|
|
300
|
+
}
|
|
301
|
+
}, [requestedAssemblies, appInfo, assemblyOutputNames, addAlert])
|
|
302
|
+
|
|
303
|
+
// syncResults are the submitted strategies (some may be skipped, e.g. already in the database)
|
|
304
|
+
const onUploadSuccess = React.useCallback((_sequenceRefs, syncResults) => {
|
|
305
|
+
const count = syncResults.length
|
|
306
|
+
addAlert({ message: `Uploaded ${count} assembl${count === 1 ? 'y' : 'ies'} to ${database.name}`, severity: 'success' })
|
|
307
|
+
}, [addAlert, database])
|
|
308
|
+
|
|
285
309
|
const options = React.useMemo(() => assemblyComplete ? assembly : [...assembly, { category: '', plasmidIds: [] }], [assembly, assemblyComplete])
|
|
286
310
|
|
|
287
311
|
return (
|
|
@@ -311,7 +335,16 @@ export function AssemblerComponent({ plasmids, categories, assemblyEnzymes, addA
|
|
|
311
335
|
sx={{ p: 2, fontSize: '1.2rem' }}
|
|
312
336
|
onClick={onDownloadAssemblies}
|
|
313
337
|
disabled={!canDownload}
|
|
314
|
-
title={!canDownload ?
|
|
338
|
+
title={!canDownload ? INVALID_OUTPUT_NAMES_MESSAGE : ''}>Download Assemblies
|
|
339
|
+
</Button>}
|
|
340
|
+
{requestedAssemblies.length > 0 && SubmitCloningStrategiesInBulkComponent && <Button
|
|
341
|
+
color="primary"
|
|
342
|
+
variant="contained"
|
|
343
|
+
data-testid="assembler-upload-to-db-button"
|
|
344
|
+
sx={{ p: 2, fontSize: '1.2rem' }}
|
|
345
|
+
onClick={onUploadToDatabase}
|
|
346
|
+
disabled={!canDownload}
|
|
347
|
+
title={!canDownload ? INVALID_OUTPUT_NAMES_MESSAGE : `Upload the assemblies and their history to ${database.name}`}>Upload to DB
|
|
315
348
|
</Button>}
|
|
316
349
|
</Box>
|
|
317
350
|
{errorMessage && <Alert severity="error" sx={{ my: 2, maxWidth: 300, margin: 'auto', fontSize: '1.2rem' }}>{errorMessage}</Alert>}
|
|
@@ -321,6 +354,13 @@ export function AssemblerComponent({ plasmids, categories, assemblyEnzymes, addA
|
|
|
321
354
|
assemblyOutputNames, onOutputNameChange,
|
|
322
355
|
}} />
|
|
323
356
|
}
|
|
357
|
+
{filesToUpload && SubmitCloningStrategiesInBulkComponent && (
|
|
358
|
+
<SubmitCloningStrategiesInBulkComponent
|
|
359
|
+
files={filesToUpload}
|
|
360
|
+
onClose={() => setFilesToUpload(null)}
|
|
361
|
+
onSuccess={onUploadSuccess}
|
|
362
|
+
/>
|
|
363
|
+
)}
|
|
324
364
|
|
|
325
365
|
</Box >
|
|
326
366
|
)
|
|
@@ -378,20 +418,25 @@ function LoadSyntaxButton({ setSyntax, addPlasmids, clearPlasmids }) {
|
|
|
378
418
|
const httpClient = useBackendHttpClient();
|
|
379
419
|
const { staticContentPath } = useConfig();
|
|
380
420
|
const { addAlert } = useCloningAlerts();
|
|
421
|
+
const database = useDatabase();
|
|
422
|
+
// If plasmids can be loaded from the database, the default plasmids of the syntax are not loaded
|
|
423
|
+
const loadDefaultPlasmids = !database?.GetSequencesInBulkComponent;
|
|
381
424
|
const onSyntaxSelect = React.useCallback(async (syntax, plasmids) => {
|
|
382
425
|
const url = 'validate_syntax';
|
|
383
426
|
try {
|
|
384
427
|
await httpClient.post(url, syntax);
|
|
385
428
|
setSyntax(syntax)
|
|
386
429
|
clearPlasmids()
|
|
387
|
-
|
|
430
|
+
if (loadDefaultPlasmids) {
|
|
431
|
+
addPlasmids(plasmids)
|
|
432
|
+
}
|
|
388
433
|
} catch (error) {
|
|
389
434
|
addAlert({
|
|
390
435
|
message: error2String(error),
|
|
391
436
|
severity: 'error',
|
|
392
437
|
});
|
|
393
438
|
}
|
|
394
|
-
}, [setSyntax, addPlasmids, clearPlasmids, httpClient, addAlert])
|
|
439
|
+
}, [setSyntax, addPlasmids, clearPlasmids, httpClient, addAlert, loadDefaultPlasmids])
|
|
395
440
|
return <>
|
|
396
441
|
<Button color="success" onClick={() => setExistingSyntaxDialogOpen(true)}>Load Syntax</Button>
|
|
397
442
|
{existingSyntaxDialogOpen && <ExistingSyntaxDialog
|
|
@@ -448,7 +493,10 @@ function Assembler() {
|
|
|
448
493
|
const addPlasmids = React.useCallback((newPlasmids) => {
|
|
449
494
|
setPlasmids((prevPlasmids) => {
|
|
450
495
|
const maxId = Math.max(...prevPlasmids.map((plasmid) => plasmid.id), 0)
|
|
451
|
-
|
|
496
|
+
// Plasmids from the database that are already loaded are skipped
|
|
497
|
+
const loadedDatabaseIds = new Set(prevPlasmids.map((plasmid) => plasmid.database_id).filter((id) => id !== undefined))
|
|
498
|
+
const plasmidsToAdd = newPlasmids.filter((plasmid) => plasmid.database_id === undefined || !loadedDatabaseIds.has(plasmid.database_id))
|
|
499
|
+
return [...prevPlasmids, ...plasmidsToAdd.map((plasmid, index) => ({ ...plasmid, id: maxId + index + 1 }))]
|
|
452
500
|
})
|
|
453
501
|
}, [])
|
|
454
502
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { ConfigProvider } from '@opencloning/ui/providers/ConfigProvider';
|
|
3
|
+
import { DatabaseProvider } from '@opencloning/ui/providers/DatabaseContext';
|
|
3
4
|
import { localFilesHttpClient } from '@opencloning/ui/hooks';
|
|
4
5
|
import UploadPlasmidsButton from './UploadPlasmidsButton';
|
|
5
6
|
import mocloSyntax from '../../../../../cypress/test_files/syntax/moclo_syntax.json';
|
|
@@ -67,8 +68,10 @@ describe('<UploadPlasmidsButton />', () => {
|
|
|
67
68
|
</ConfigProvider>,
|
|
68
69
|
);
|
|
69
70
|
|
|
70
|
-
cy.get('button').
|
|
71
|
-
cy.get('
|
|
71
|
+
cy.get('[data-testid="load-plasmids-button"]').click();
|
|
72
|
+
cy.get('[data-testid="load-plasmids-from-local-server"]').should('not.exist');
|
|
73
|
+
cy.get('body').type('{esc}');
|
|
74
|
+
cy.get('[data-testid="load-plasmids-file-input"]').selectFile([
|
|
72
75
|
'cypress/test_files/syntax/pYTK002.gb',
|
|
73
76
|
'cypress/test_files/syntax/moclo_ytk_multi_part.gb',
|
|
74
77
|
'cypress/test_files/syntax/pYTK095.gb',
|
|
@@ -148,7 +151,7 @@ describe('<UploadPlasmidsButton />', () => {
|
|
|
148
151
|
<UploadPlasmidsButton addPlasmids={() => {}} syntax={mocloSyntax} />
|
|
149
152
|
</ConfigProvider>,
|
|
150
153
|
);
|
|
151
|
-
cy.get('
|
|
154
|
+
cy.get('[data-testid="load-plasmids-file-input"]').selectFile([
|
|
152
155
|
'cypress/test_files/sequencing/locus.gb'],
|
|
153
156
|
{ force: true });
|
|
154
157
|
|
|
@@ -170,7 +173,7 @@ describe('<UploadPlasmidsButton />', () => {
|
|
|
170
173
|
</ConfigProvider>,
|
|
171
174
|
);
|
|
172
175
|
|
|
173
|
-
cy.get('
|
|
176
|
+
cy.get('[data-testid="load-plasmids-file-input"]').selectFile([
|
|
174
177
|
'cypress/test_files/syntax/pYTK002.gb',
|
|
175
178
|
'cypress/test_files/syntax/moclo_ytk_multi_part.gb',
|
|
176
179
|
'cypress/test_files/syntax/pYTK095.gb',
|
|
@@ -207,7 +210,8 @@ describe('<UploadPlasmidsButton />', () => {
|
|
|
207
210
|
<UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
|
|
208
211
|
</ConfigProvider>,
|
|
209
212
|
);
|
|
210
|
-
cy.get('button').
|
|
213
|
+
cy.get('[data-testid="load-plasmids-button"]').click();
|
|
214
|
+
cy.get('[data-testid="load-plasmids-from-local-server"]').click();
|
|
211
215
|
|
|
212
216
|
cy.get('button').contains('Submit').should('be.disabled');
|
|
213
217
|
cy.get('#option-select').click();
|
|
@@ -248,7 +252,8 @@ describe('<UploadPlasmidsButton />', () => {
|
|
|
248
252
|
<UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
|
|
249
253
|
</ConfigProvider>,
|
|
250
254
|
);
|
|
251
|
-
cy.get('button').
|
|
255
|
+
cy.get('[data-testid="load-plasmids-button"]').click();
|
|
256
|
+
cy.get('[data-testid="load-plasmids-from-local-server"]').click();
|
|
252
257
|
cy.get('button').contains('Submit').should('be.disabled');
|
|
253
258
|
cy.get('#option-select').click();
|
|
254
259
|
cy.contains('Example sequence 1').click();
|
|
@@ -267,4 +272,81 @@ describe('<UploadPlasmidsButton />', () => {
|
|
|
267
272
|
cy.get('@addPlasmidsSpy').should('not.have.been.called');
|
|
268
273
|
});
|
|
269
274
|
|
|
275
|
+
it('does not show the database button when there is no database or it does not support bulk loading', () => {
|
|
276
|
+
cy.mount(
|
|
277
|
+
<ConfigProvider config={testConfig}>
|
|
278
|
+
<DatabaseProvider value={{ name: 'MockDB' }}>
|
|
279
|
+
<UploadPlasmidsButton addPlasmids={() => {}} syntax={mocloSyntax} />
|
|
280
|
+
</DatabaseProvider>
|
|
281
|
+
</ConfigProvider>,
|
|
282
|
+
);
|
|
283
|
+
cy.get('[data-testid="load-plasmids-button"]').click();
|
|
284
|
+
cy.get('[data-testid="load-plasmids-from-files"]').should('exist');
|
|
285
|
+
cy.get('[data-testid="load-plasmids-from-database"]').should('not.exist');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('loads plasmids from the database as DatabaseSource plasmids', () => {
|
|
289
|
+
cy.readFile('cypress/test_files/syntax/pYTK002.gb').then((pYTK002) => {
|
|
290
|
+
cy.readFile('cypress/test_files/syntax/moclo_ytk_multi_part.gb').then((multiPart) => {
|
|
291
|
+
const entries = [
|
|
292
|
+
{ databaseId: 10, name: 'pYTK002_db', textFileSequence: { id: 10, type: 'TextFileSequence', sequence_file_format: 'genbank', overhang_crick_3prime: 0, overhang_watson_3prime: 0, file_content: pYTK002 } },
|
|
293
|
+
{ databaseId: 20, name: 'multi_part_db', textFileSequence: { id: 20, type: 'TextFileSequence', sequence_file_format: 'genbank', overhang_crick_3prime: 0, overhang_watson_3prime: 0, file_content: multiPart } },
|
|
294
|
+
];
|
|
295
|
+
const mockDatabase = {
|
|
296
|
+
name: 'MockDB',
|
|
297
|
+
GetSequencesInBulkComponent: ({ onSequencesLoaded }) => <button type="button" onClick={() => onSequencesLoaded(entries)}>Mock load</button>,
|
|
298
|
+
};
|
|
299
|
+
const addPlasmidsSpy = cy.spy().as('addPlasmidsSpy');
|
|
300
|
+
cy.mount(
|
|
301
|
+
<ConfigProvider config={testConfig}>
|
|
302
|
+
<DatabaseProvider value={mockDatabase}>
|
|
303
|
+
<UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
|
|
304
|
+
</DatabaseProvider>
|
|
305
|
+
</ConfigProvider>,
|
|
306
|
+
);
|
|
307
|
+
cy.get('[data-testid="load-plasmids-button"]').click();
|
|
308
|
+
cy.get('[data-testid="load-plasmids-from-database"]').contains('From MockDB').click();
|
|
309
|
+
cy.contains('button', 'Mock load').click();
|
|
310
|
+
// The bulk dialog closes and the valid / invalid dialog is shown
|
|
311
|
+
cy.contains('button', 'Mock load').should('not.exist');
|
|
312
|
+
cy.get('[data-testid="invalid-plasmids-box"]').contains('multi_part_db');
|
|
313
|
+
cy.get('[data-testid="valid-plasmids-box"]').contains('pYTK002_db');
|
|
314
|
+
|
|
315
|
+
cy.contains('button', 'Import valid plasmids').click();
|
|
316
|
+
cy.get('@addPlasmidsSpy').should('have.been.calledOnce');
|
|
317
|
+
cy.get('@addPlasmidsSpy').then((spy) => {
|
|
318
|
+
const plasmids = spy.getCall(0).args[0];
|
|
319
|
+
expect(plasmids).to.have.length(1);
|
|
320
|
+
const [plasmid] = plasmids;
|
|
321
|
+
expect(plasmid.type).to.equal('databaseSequence');
|
|
322
|
+
expect(plasmid.database_id).to.equal(10);
|
|
323
|
+
expect(plasmid.plasmid_name).to.equal('pYTK002_db (ConS)');
|
|
324
|
+
expect(plasmid.key).to.equal('CCCT-AACG');
|
|
325
|
+
expect(plasmid.userUploaded).to.equal(true);
|
|
326
|
+
// The sequence is kept as returned by the database
|
|
327
|
+
expect(plasmid.textFileSequence).to.deep.equal(entries[0].textFileSequence);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('shows an error if a database sequence cannot be parsed', () => {
|
|
334
|
+
const mockDatabase = {
|
|
335
|
+
name: 'MockDB',
|
|
336
|
+
GetSequencesInBulkComponent: ({ onSequencesLoaded }) => <button type="button" onClick={() => onSequencesLoaded([{ databaseId: 1, name: 'broken', textFileSequence: { file_content: 'wrong_format' } }])}>Mock load</button>,
|
|
337
|
+
};
|
|
338
|
+
const addPlasmidsSpy = cy.spy().as('addPlasmidsSpy');
|
|
339
|
+
cy.mount(
|
|
340
|
+
<ConfigProvider config={testConfig}>
|
|
341
|
+
<DatabaseProvider value={mockDatabase}>
|
|
342
|
+
<UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
|
|
343
|
+
</DatabaseProvider>
|
|
344
|
+
</ConfigProvider>,
|
|
345
|
+
);
|
|
346
|
+
cy.get('[data-testid="load-plasmids-button"]').click();
|
|
347
|
+
cy.get('[data-testid="load-plasmids-from-database"]').contains('From MockDB').click();
|
|
348
|
+
cy.contains('button', 'Mock load').click();
|
|
349
|
+
cy.get('.MuiAlert-colorError').contains('Error loading plasmid broken from database').should('exist');
|
|
350
|
+
cy.get('@addPlasmidsSpy').should('not.have.been.called');
|
|
351
|
+
});
|
|
270
352
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { Button, Dialog, DialogTitle, DialogContent, DialogActions, Alert, Box,
|
|
2
|
+
import { Button, Dialog, DialogTitle, DialogContent, DialogActions, Alert, Box, ListItemIcon, Menu, MenuItem } from '@mui/material';
|
|
3
|
+
import { ArrowDropDown as ArrowDropDownIcon, Dns as DnsIcon, Storage as StorageIcon, UploadFile as UploadFileIcon } from '@mui/icons-material';
|
|
3
4
|
import { useConfig } from '../../providers';
|
|
5
|
+
import useDatabase from '../../hooks/useDatabase';
|
|
4
6
|
import ServerStaticFileSelect from '../form/ServerStaticFileSelect';
|
|
5
7
|
import { usePlasmidsLogic } from './usePlasmidsLogic';
|
|
6
8
|
import PlasmidSyntaxTable from './PlasmidSyntaxTable';
|
|
@@ -9,14 +11,28 @@ import { jsonToGenbank } from '@teselagen/bio-parsers';
|
|
|
9
11
|
function formatPlasmid(sequenceData) {
|
|
10
12
|
|
|
11
13
|
const { appData } = sequenceData;
|
|
12
|
-
const { fileName, correspondingParts, longestFeature } = appData;
|
|
14
|
+
const { fileName, correspondingParts, longestFeature, databaseId, textFileSequence } = appData;
|
|
13
15
|
const [left_overhang, right_overhang] = correspondingParts[0].split('-');
|
|
14
|
-
|
|
16
|
+
|
|
15
17
|
let plasmidName = fileName;
|
|
16
18
|
if (longestFeature[0]?.name) {
|
|
17
19
|
plasmidName += ` (${longestFeature[0].name})`;
|
|
18
20
|
}
|
|
19
|
-
|
|
21
|
+
|
|
22
|
+
if (databaseId !== undefined) {
|
|
23
|
+
return {
|
|
24
|
+
type: 'databaseSequence',
|
|
25
|
+
plasmid_name: plasmidName,
|
|
26
|
+
database_id: databaseId,
|
|
27
|
+
left_overhang,
|
|
28
|
+
right_overhang,
|
|
29
|
+
key: `${left_overhang}-${right_overhang}`,
|
|
30
|
+
sequenceData,
|
|
31
|
+
textFileSequence,
|
|
32
|
+
userUploaded: true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
return {
|
|
21
37
|
type: 'loadedFile',
|
|
22
38
|
plasmid_name: plasmidName,
|
|
@@ -28,95 +44,143 @@ function formatPlasmid(sequenceData) {
|
|
|
28
44
|
genbankString: jsonToGenbank(sequenceData),
|
|
29
45
|
userUploaded: true,
|
|
30
46
|
};
|
|
31
|
-
|
|
47
|
+
|
|
32
48
|
}
|
|
33
49
|
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
/** Dialog wrapping a component that loads plasmids. children is a function that receives the onLoad callback. */
|
|
51
|
+
function LoadPlasmidsDialog({ title, onLoad, onClose, children }) {
|
|
36
52
|
const [error, setError] = React.useState(null);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const onFileSelected = React.useCallback(async (files) => {
|
|
53
|
+
|
|
54
|
+
const handleLoad = React.useCallback(async (items) => {
|
|
40
55
|
try {
|
|
41
|
-
await
|
|
42
|
-
|
|
56
|
+
await onLoad(items)
|
|
57
|
+
onClose()
|
|
43
58
|
} catch (e) {
|
|
44
59
|
setError(e.message)
|
|
45
60
|
}
|
|
46
|
-
}, [
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
61
|
+
}, [onLoad, onClose])
|
|
62
|
+
|
|
63
|
+
return <Dialog open onClose={onClose}>
|
|
64
|
+
<DialogTitle>{title}</DialogTitle>
|
|
65
|
+
<DialogContent sx={{ width: '500px' }}>
|
|
66
|
+
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
67
|
+
{children(handleLoad)}
|
|
68
|
+
</DialogContent>
|
|
69
|
+
</Dialog>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* "Load Plasmids" button with a dropdown. The "From files" option is always present (opens a file picker
|
|
74
|
+
* and calls onFilesSelected), extraOptions are appended: [{ id, label, Icon, onClick }]
|
|
75
|
+
*/
|
|
76
|
+
function LoadPlasmidsMenuButton({ onFilesSelected, extraOptions }) {
|
|
77
|
+
const [anchorEl, setAnchorEl] = React.useState(null)
|
|
78
|
+
const open = Boolean(anchorEl)
|
|
79
|
+
const fileInputRef = React.useRef(null)
|
|
80
|
+
|
|
81
|
+
const onFileInputChange = (event) => {
|
|
82
|
+
const files = Array.from(event.target.files)
|
|
83
|
+
event.target.value = ''
|
|
84
|
+
onFilesSelected(files)
|
|
50
85
|
}
|
|
51
|
-
|
|
86
|
+
|
|
52
87
|
return <>
|
|
53
|
-
<
|
|
54
|
-
|
|
88
|
+
<input multiple type="file" ref={fileInputRef} style={{ display: 'none' }} onChange={onFileInputChange} accept=".gbk,.gb,.fasta,.fa,.dna" data-testid="load-plasmids-file-input" />
|
|
89
|
+
<Button
|
|
90
|
+
color="primary"
|
|
91
|
+
onClick={(event) => setAnchorEl(event.currentTarget)}
|
|
92
|
+
endIcon={<ArrowDropDownIcon />}
|
|
93
|
+
aria-haspopup="true"
|
|
94
|
+
aria-expanded={open ? 'true' : undefined}
|
|
95
|
+
aria-controls={open ? 'load-plasmids-menu' : undefined}
|
|
96
|
+
data-testid="load-plasmids-button"
|
|
97
|
+
>
|
|
98
|
+
Load Plasmids
|
|
55
99
|
</Button>
|
|
56
|
-
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
100
|
+
<Menu id="load-plasmids-menu" anchorEl={anchorEl} open={open} onClose={() => setAnchorEl(null)} onClick={() => setAnchorEl(null)}>
|
|
101
|
+
<MenuItem onClick={() => fileInputRef.current.click()} data-testid="load-plasmids-from-files">
|
|
102
|
+
<ListItemIcon><UploadFileIcon fontSize="small" /></ListItemIcon>
|
|
103
|
+
From files
|
|
104
|
+
</MenuItem>
|
|
105
|
+
{extraOptions.map(({ id, label, Icon, onClick }) => (
|
|
106
|
+
<MenuItem key={id} onClick={onClick} data-testid={`load-plasmids-from-${id}`}>
|
|
107
|
+
<ListItemIcon><Icon fontSize="small" /></ListItemIcon>
|
|
108
|
+
{label}
|
|
109
|
+
</MenuItem>
|
|
110
|
+
))}
|
|
111
|
+
</Menu>
|
|
63
112
|
</>
|
|
64
113
|
}
|
|
65
|
-
|
|
114
|
+
|
|
115
|
+
function ImportPlasmidsDialog({ validPlasmids, invalidPlasmids, onImport, onCancel }) {
|
|
116
|
+
return <Dialog
|
|
117
|
+
maxWidth="lg"
|
|
118
|
+
fullWidth
|
|
119
|
+
open={invalidPlasmids.length > 0 || validPlasmids.length > 0}
|
|
120
|
+
onClose={onCancel}
|
|
121
|
+
PaperProps={{ style: { maxHeight: '80vh' } }}
|
|
122
|
+
>
|
|
123
|
+
<DialogActions sx={{ justifyContent: 'center', position: 'sticky', top: 0, zIndex: 99, background: '#fff', py: 2 }}>
|
|
124
|
+
<Button sx={{ p: 1.5, fontSize: '1rem' }} disabled={validPlasmids.length === 0} variant="contained" color="success" onClick={onImport}>Import valid plasmids</Button>
|
|
125
|
+
<Button sx={{ p: 1.5, fontSize: '1rem' }} variant="contained" color="error" onClick={onCancel}>Cancel</Button>
|
|
126
|
+
</DialogActions>
|
|
127
|
+
{invalidPlasmids.length > 0 && (
|
|
128
|
+
<Box data-testid="invalid-plasmids-box">
|
|
129
|
+
<DialogTitle>Invalid Plasmids</DialogTitle>
|
|
130
|
+
<DialogContent>
|
|
131
|
+
<PlasmidSyntaxTable plasmids={invalidPlasmids} />
|
|
132
|
+
</DialogContent>
|
|
133
|
+
</Box>
|
|
134
|
+
)}
|
|
135
|
+
{validPlasmids.length > 0 && (
|
|
136
|
+
<Box data-testid="valid-plasmids-box">
|
|
137
|
+
<DialogTitle>Valid Plasmids</DialogTitle>
|
|
138
|
+
<DialogContent>
|
|
139
|
+
<PlasmidSyntaxTable plasmids={validPlasmids} />
|
|
140
|
+
</DialogContent>
|
|
141
|
+
</Box>
|
|
142
|
+
)}
|
|
143
|
+
</Dialog>
|
|
144
|
+
}
|
|
145
|
+
|
|
66
146
|
function UploadPlasmidsButton({ addPlasmids, syntax }) {
|
|
67
|
-
const { uploadPlasmids, linkedPlasmids, setLinkedPlasmids } = usePlasmidsLogic(syntax)
|
|
147
|
+
const { uploadPlasmids, loadPlasmidsFromDatabase, linkedPlasmids, setLinkedPlasmids } = usePlasmidsLogic(syntax)
|
|
68
148
|
const validPlasmids = React.useMemo(() => linkedPlasmids.filter((plasmid) => plasmid.appData.correspondingParts.length === 1), [linkedPlasmids])
|
|
69
149
|
const invalidPlasmids = React.useMemo(() => linkedPlasmids.filter((plasmid) => plasmid.appData.correspondingParts.length !== 1), [linkedPlasmids])
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
150
|
+
// null | 'local-server' | 'database'
|
|
151
|
+
const [openDialog, setOpenDialog] = React.useState(null)
|
|
152
|
+
const closeDialog = React.useCallback(() => setOpenDialog(null), [])
|
|
153
|
+
const { staticContentPath } = useConfig();
|
|
154
|
+
const database = useDatabase();
|
|
155
|
+
|
|
77
156
|
const handleImportValidPlasmids = React.useCallback(() => {
|
|
78
157
|
addPlasmids(validPlasmids.map(formatPlasmid))
|
|
79
158
|
setLinkedPlasmids([])
|
|
80
159
|
}, [addPlasmids, validPlasmids, setLinkedPlasmids])
|
|
81
|
-
|
|
160
|
+
|
|
161
|
+
const extraMenuOptions = [
|
|
162
|
+
staticContentPath && { id: 'local-server', label: 'From local server', Icon: DnsIcon, onClick: () => setOpenDialog('local-server') },
|
|
163
|
+
database?.GetSequencesInBulkComponent && { id: 'database', label: `From ${database.name}`, Icon: StorageIcon, onClick: () => setOpenDialog('database') },
|
|
164
|
+
].filter(Boolean)
|
|
165
|
+
|
|
82
166
|
return (<>
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
<Button sx={{ p: 1.5, fontSize: '1rem' }} disabled={validPlasmids.length === 0} variant="contained" color="success" onClick={handleImportValidPlasmids}>Import valid plasmids</Button>
|
|
101
|
-
<Button sx={{ p: 1.5, fontSize: '1rem' }} variant="contained" color="error" onClick={() => setLinkedPlasmids([])}>Cancel</Button>
|
|
102
|
-
</DialogActions>
|
|
103
|
-
{invalidPlasmids.length > 0 && (
|
|
104
|
-
<Box data-testid="invalid-plasmids-box">
|
|
105
|
-
<DialogTitle>Invalid Plasmids</DialogTitle>
|
|
106
|
-
<DialogContent>
|
|
107
|
-
<PlasmidSyntaxTable plasmids={invalidPlasmids} />
|
|
108
|
-
</DialogContent>
|
|
109
|
-
</Box>
|
|
110
|
-
)}
|
|
111
|
-
{validPlasmids.length > 0 && (
|
|
112
|
-
<Box data-testid="valid-plasmids-box">
|
|
113
|
-
<DialogTitle>Valid Plasmids</DialogTitle>
|
|
114
|
-
<DialogContent>
|
|
115
|
-
<PlasmidSyntaxTable plasmids={validPlasmids} />
|
|
116
|
-
</DialogContent>
|
|
117
|
-
</Box>
|
|
118
|
-
)}
|
|
119
|
-
</Dialog>
|
|
167
|
+
<LoadPlasmidsMenuButton onFilesSelected={uploadPlasmids} extraOptions={extraMenuOptions} />
|
|
168
|
+
{openDialog === 'local-server' && (
|
|
169
|
+
<LoadPlasmidsDialog title="Load Plasmids from Local Server" onLoad={uploadPlasmids} onClose={closeDialog}>
|
|
170
|
+
{(onLoad) => <ServerStaticFileSelect onFileSelected={onLoad} type="sequence" multiple />}
|
|
171
|
+
</LoadPlasmidsDialog>
|
|
172
|
+
)}
|
|
173
|
+
{openDialog === 'database' && (
|
|
174
|
+
<LoadPlasmidsDialog title={`Load Plasmids from ${database.name}`} onLoad={loadPlasmidsFromDatabase} onClose={closeDialog}>
|
|
175
|
+
{(onLoad) => <database.GetSequencesInBulkComponent onSequencesLoaded={onLoad} />}
|
|
176
|
+
</LoadPlasmidsDialog>
|
|
177
|
+
)}
|
|
178
|
+
<ImportPlasmidsDialog
|
|
179
|
+
validPlasmids={validPlasmids}
|
|
180
|
+
invalidPlasmids={invalidPlasmids}
|
|
181
|
+
onImport={handleImportValidPlasmids}
|
|
182
|
+
onCancel={() => setLinkedPlasmids([])}
|
|
183
|
+
/>
|
|
120
184
|
</>)
|
|
121
185
|
}
|
|
122
186
|
|
|
@@ -203,26 +203,39 @@ export function getFilesToExportFromAssembler({requestedAssemblies, expandedAsse
|
|
|
203
203
|
});
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
getCloningStrategyFilesFromAssembler({ requestedAssemblies, appInfo, outputNames }).forEach(({ name, content, finalSequence }) => {
|
|
207
|
+
files2Export.push({ name, content });
|
|
208
|
+
files2Export.push({
|
|
209
|
+
name: name.replace(/\.json$/, '.gbk'),
|
|
210
|
+
content: finalSequence.file_content,
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
return files2Export;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* One cloning strategy JSON file per assembly, with the output name set on the final sequence.
|
|
218
|
+
* Returns [{ name: '<name>.json', content: '<json string>', finalSequence }]. The input is not modified.
|
|
219
|
+
*/
|
|
220
|
+
export function getCloningStrategyFilesFromAssembler({ requestedAssemblies, appInfo, outputNames }) {
|
|
221
|
+
if (outputNames.length !== requestedAssemblies.length) {
|
|
222
|
+
throw new Error('outputNames must be the same length as requestedAssemblies');
|
|
223
|
+
}
|
|
224
|
+
return requestedAssemblies.map((assembly, i) => {
|
|
207
225
|
const name = sanitizeOutputName(outputNames[i]);
|
|
208
|
-
const requestedAssembly = JSON.parse(JSON.stringify(
|
|
209
|
-
const jsonContent = formatStateForJsonExport({...requestedAssembly, appInfo});
|
|
226
|
+
const requestedAssembly = JSON.parse(JSON.stringify(assembly));
|
|
210
227
|
const finalSequenceId = getGraftSequenceId(requestedAssembly)
|
|
211
228
|
const finalSequence = requestedAssembly.sequences.find(s => s.id === finalSequenceId)
|
|
212
229
|
finalSequence.file_content = editGenbankSequenceNameFromTextContent(finalSequence.file_content, name);
|
|
213
230
|
const finalSource = requestedAssembly.sources.find(s => s.id === finalSequenceId)
|
|
214
231
|
finalSource.output_name = name;
|
|
215
|
-
|
|
232
|
+
const jsonContent = formatStateForJsonExport({...requestedAssembly, appInfo});
|
|
233
|
+
return {
|
|
216
234
|
name: `${name}.json`,
|
|
217
235
|
content: JSON.stringify(jsonContent, null, 2),
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
name: `${name}.gbk`,
|
|
222
|
-
content: finalSequence.file_content,
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
return files2Export;
|
|
236
|
+
finalSequence,
|
|
237
|
+
};
|
|
238
|
+
});
|
|
226
239
|
}
|
|
227
240
|
|
|
228
241
|
export async function downloadAssemblerFilesAsZip(files) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { aliasedEnzymesByName, getDigestFragmentsForRestrictionEnzymes, getReverseComplementSequenceString, getComplementSequenceString, getReverseComplementSequenceAndAnnotations } from "@teselagen/sequence-utils";
|
|
2
2
|
import fs from 'fs';
|
|
3
|
-
import { assignSequenceToSyntaxPart, simplifyDigestFragment, reverseComplementSimplifiedDigestFragment, tripletsToTranslation, partDataToDisplayData, arrayCombinations, getFilesToExportFromAssembler, getDefaultAssemblyOutputName } from "./assembler_utils";
|
|
3
|
+
import { assignSequenceToSyntaxPart, simplifyDigestFragment, reverseComplementSimplifiedDigestFragment, tripletsToTranslation, partDataToDisplayData, arrayCombinations, getFilesToExportFromAssembler, getCloningStrategyFilesFromAssembler, getDefaultAssemblyOutputName, sanitizeOutputName } from "./assembler_utils";
|
|
4
4
|
import { partsToEdgesGraph } from "./graph_utils";
|
|
5
5
|
|
|
6
6
|
|
|
@@ -379,3 +379,42 @@ describe('getFilesToExportFromAssembler', () => {
|
|
|
379
379
|
expect(files[5].name).toBe('my_assembly_2.gbk');
|
|
380
380
|
});
|
|
381
381
|
});
|
|
382
|
+
|
|
383
|
+
describe('getCloningStrategyFilesFromAssembler', () => {
|
|
384
|
+
const appInfo = dummyData.appInfo;
|
|
385
|
+
it('returns one cloning strategy per assembly, with the output name set on the final sequence', () => {
|
|
386
|
+
const outputNames = ['my assembly/1', 'my_assembly_2'];
|
|
387
|
+
const files = getCloningStrategyFilesFromAssembler({ requestedAssemblies: dummyData.requestedAssemblies, appInfo, outputNames });
|
|
388
|
+
expect(files).toHaveLength(2);
|
|
389
|
+
const sanitizedNames = outputNames.map(sanitizeOutputName);
|
|
390
|
+
files.forEach((file, i) => {
|
|
391
|
+
expect(file.name).toBe(`${sanitizedNames[i]}.json`);
|
|
392
|
+
const cloningStrategy = JSON.parse(file.content);
|
|
393
|
+
const originalSequences = dummyData.requestedAssemblies[i].sequences;
|
|
394
|
+
expect(cloningStrategy.sequences).toHaveLength(originalSequences.length);
|
|
395
|
+
expect(cloningStrategy.primers).toEqual(dummyData.requestedAssemblies[i].primers);
|
|
396
|
+
const finalSource = cloningStrategy.sources[cloningStrategy.sources.length - 1];
|
|
397
|
+
expect(finalSource.output_name).toBe(sanitizedNames[i]);
|
|
398
|
+
const finalSequence = cloningStrategy.sequences.find((s) => s.id === finalSource.id);
|
|
399
|
+
expect(finalSequence.file_content.split('\n')[0]).toContain(sanitizedNames[i]);
|
|
400
|
+
expect(file.finalSequence).toEqual(finalSequence);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('keeps the database ids of the sources and does not modify the input', () => {
|
|
405
|
+
const withDatabaseSource = JSON.parse(JSON.stringify(dummyData.requestedAssemblies[0]));
|
|
406
|
+
const firstSource = withDatabaseSource.sources[0];
|
|
407
|
+
withDatabaseSource.sources[0] = { id: firstSource.id, type: 'DatabaseSource', input: [], database_id: 10 };
|
|
408
|
+
const requestedAssemblies = [withDatabaseSource];
|
|
409
|
+
const inputCopy = JSON.parse(JSON.stringify(requestedAssemblies));
|
|
410
|
+
|
|
411
|
+
const [file] = getCloningStrategyFilesFromAssembler({ requestedAssemblies, appInfo, outputNames: ['product'] });
|
|
412
|
+
const cloningStrategy = JSON.parse(file.content);
|
|
413
|
+
expect(cloningStrategy.sources[0]).toEqual({ id: firstSource.id, type: 'DatabaseSource', input: [], database_id: 10 });
|
|
414
|
+
expect(requestedAssemblies).toEqual(inputCopy);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('throws if the number of names does not match the number of assemblies', () => {
|
|
418
|
+
expect(() => getCloningStrategyFilesFromAssembler({ requestedAssemblies: dummyData.requestedAssemblies, appInfo, outputNames: ['a'] })).toThrow();
|
|
419
|
+
});
|
|
420
|
+
});
|
|
@@ -26,6 +26,22 @@ function formatLoadedFile(plasmid, id) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
function formatDatabaseSequence(plasmid, id) {
|
|
30
|
+
return {
|
|
31
|
+
source: {
|
|
32
|
+
id,
|
|
33
|
+
type: 'DatabaseSource',
|
|
34
|
+
input: [],
|
|
35
|
+
database_id: plasmid.database_id,
|
|
36
|
+
},
|
|
37
|
+
sequence: {
|
|
38
|
+
...plasmid.textFileSequence,
|
|
39
|
+
id,
|
|
40
|
+
},
|
|
41
|
+
plasmid: plasmid,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
29
45
|
export const useAssembler = () => {
|
|
30
46
|
const httpClient = useBackendHttpClient();
|
|
31
47
|
|
|
@@ -37,6 +53,8 @@ export const useAssembler = () => {
|
|
|
37
53
|
try {
|
|
38
54
|
if (plasmid.type === 'loadedFile') {
|
|
39
55
|
processedOutput[pos].push(formatLoadedFile(plasmid, pos + 1))
|
|
56
|
+
} else if (plasmid.type === 'databaseSequence') {
|
|
57
|
+
processedOutput[pos].push(formatDatabaseSequence(plasmid, pos + 1))
|
|
40
58
|
} else {
|
|
41
59
|
const { source } = plasmid;
|
|
42
60
|
const url = classNameToEndPointMap[source.type]
|
|
@@ -10,7 +10,7 @@ import { aliasedEnzymesByName } from '@teselagen/sequence-utils';
|
|
|
10
10
|
* @param {Array} params.parts - Array of parts
|
|
11
11
|
* @param {string[]} params.assemblyEnzymes - Assembly enzyme names
|
|
12
12
|
* @param {Object} params.overhangNames - Mapping of overhangs to names
|
|
13
|
-
* @returns {Object} - { linkedPlasmids, setLinkedPlasmids, uploadPlasmids }
|
|
13
|
+
* @returns {Object} - { linkedPlasmids, setLinkedPlasmids, uploadPlasmids, loadPlasmidsFromDatabase }
|
|
14
14
|
*/
|
|
15
15
|
export function usePlasmidsLogic({ parts, assemblyEnzymes, overhangNames }) {
|
|
16
16
|
const [linkedPlasmids, setLinkedPlasmidsState] = React.useState([]);
|
|
@@ -82,5 +82,23 @@ export function usePlasmidsLogic({ parts, assemblyEnzymes, overhangNames }) {
|
|
|
82
82
|
setLinkedPlasmids(plasmids);
|
|
83
83
|
}, [setLinkedPlasmids]);
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
// entries: [{ databaseId, name, textFileSequence }], as returned by database.GetSequencesInBulkComponent
|
|
86
|
+
// Topology is not forced to circular, since the sequence is used as stored in the database.
|
|
87
|
+
const loadPlasmidsFromDatabase = React.useCallback(async (entries) => {
|
|
88
|
+
const plasmids = await Promise.all(entries.map(async ({ databaseId, name, textFileSequence }) => {
|
|
89
|
+
try {
|
|
90
|
+
const data = await anyToJson(textFileSequence.file_content, { fileName: 'sequence.gb' });
|
|
91
|
+
if (!data[0]?.success || !data[0].parsedSequence) {
|
|
92
|
+
throw new Error(data[0]?.messages?.join(', ') || 'could not parse sequence');
|
|
93
|
+
}
|
|
94
|
+
const sequenceData = data[0].parsedSequence;
|
|
95
|
+
return { ...sequenceData, appData: { fileName: name, databaseId, textFileSequence, correspondingParts: [], partInfo: [], correspondingPartsNames: [] } };
|
|
96
|
+
} catch (e) {
|
|
97
|
+
throw new Error(`Error loading plasmid ${name} from database: ${e.message}`);
|
|
98
|
+
}
|
|
99
|
+
}));
|
|
100
|
+
setLinkedPlasmids(plasmids);
|
|
101
|
+
}, [setLinkedPlasmids]);
|
|
102
|
+
|
|
103
|
+
return { linkedPlasmids, setLinkedPlasmids, uploadPlasmids, loadPlasmidsFromDatabase };
|
|
86
104
|
}
|
|
@@ -37,4 +37,10 @@ export default {
|
|
|
37
37
|
getSequenceName: null,
|
|
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
|
+
// Component for loading several sequences at once (e.g. in the assembler), calls
|
|
41
|
+
// onSequencesLoaded with an array of { databaseId, name, textFileSequence }
|
|
42
|
+
GetSequencesInBulkComponent: null,
|
|
43
|
+
// Dialog for submitting several cloning strategies at once (e.g. the assembler products), receives
|
|
44
|
+
// { files, onClose, onSuccess }, where files is an array of File (cloning strategy JSON)
|
|
45
|
+
SubmitCloningStrategiesInBulkComponent: null,
|
|
40
46
|
};
|
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.1.0";
|