@opencloning/opencloningdb 1.8.8 → 1.9.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 CHANGED
@@ -1,5 +1,30 @@
1
1
  # @opencloning/opencloningdb
2
2
 
3
+ ## 1.9.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 [[`c8c46d4`](https://github.com/OpenCloning/OpenCloning_frontend/commit/c8c46d4dc36cb8741d1bd89816c7ca82b89996b3)]:
25
+ - @opencloning/ui@2.1.0
26
+ - @opencloning/utils@2.1.0
27
+
3
28
  ## 1.8.8
4
29
 
5
30
  ### Patch Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "license": "MIT",
3
3
  "name": "@opencloning/opencloningdb",
4
- "version": "1.8.8",
4
+ "version": "1.9.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
@@ -15,8 +15,8 @@
15
15
  "dependencies": {
16
16
  "@mui/icons-material": "^5.18.0",
17
17
  "@mui/material": "^5.18.0",
18
- "@opencloning/ui": "2.0.0",
19
- "@opencloning/utils": "2.0.0",
18
+ "@opencloning/ui": "2.1.0",
19
+ "@opencloning/utils": "2.1.0",
20
20
  "axios": "^1.20.0"
21
21
  },
22
22
  "peerDependencies": {
@@ -0,0 +1,116 @@
1
+ import React from 'react';
2
+ import GetSequencesInBulkComponent from './GetSequencesInBulkComponent';
3
+ import { clickMultiSelectOption } from '../../../cypress/e2e/common_functions';
4
+ import endpoints from './endpoints';
5
+ import { formatSequenceName } from './common';
6
+
7
+ const SEQUENCE_NAME = 'ase1_CDS_PCR';
8
+ const TAG_NAME = 'gibson_assembly';
9
+
10
+ function expectLoadedSequences(spy, stubBody) {
11
+ expect(spy).to.have.been.calledOnce;
12
+ const [sequences] = spy.lastCall.args;
13
+ expect(sequences).to.deep.equal(stubBody.map(({ sequence_ref, sequence }) => ({
14
+ databaseId: sequence_ref.id,
15
+ name: formatSequenceName(sequence_ref),
16
+ textFileSequence: sequence,
17
+ })));
18
+ }
19
+
20
+ describe('<GetSequencesInBulkComponent />', () => {
21
+ beforeEach(() => {
22
+ cy.setupOpenCloningDBTestAuth();
23
+ });
24
+
25
+ it('loads all sequences with a tag', () => {
26
+ const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
27
+ cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
28
+ cy.interceptOpenCloningDBStub('get_text_file_sequences_by_tag', { alias: 'getSequences' });
29
+
30
+ cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
31
+
32
+ cy.contains('button', 'Load sequences').should('be.disabled');
33
+ cy.wait('@getTags');
34
+ clickMultiSelectOption('Tags', TAG_NAME, 'div');
35
+ cy.contains('button', 'Load sequences').click();
36
+ cy.wait('@getSequences');
37
+
38
+ cy.getStub('get_text_file_sequences_by_tag').then((stub) => {
39
+ expect(stub.response.body).to.have.length.greaterThan(1);
40
+ cy.get('@onSequencesLoadedSpy').should((spy) => expectLoadedSequences(spy, stub.response.body));
41
+ });
42
+ });
43
+
44
+ it('loads the picked sequences', () => {
45
+ const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
46
+ cy.interceptOpenCloningDBStub('get_sequences_search_by_name', { alias: 'searchByName' });
47
+ cy.interceptOpenCloningDBStub('get_sequences_search_by_name_uid_query', { alias: 'searchByUid' });
48
+ cy.interceptOpenCloningDBStub('get_text_file_sequences_by_ids', { alias: 'getSequences' });
49
+
50
+ cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
51
+
52
+ cy.contains('button', 'Pick sequences').click();
53
+ cy.contains('button', 'Load sequences').should('be.disabled');
54
+ cy.get('input').type(SEQUENCE_NAME);
55
+ cy.wait('@searchByName');
56
+ clickMultiSelectOption('Sequences', RegExp(`^${SEQUENCE_NAME}$`), 'div');
57
+ cy.contains('button', 'Load sequences').click();
58
+ cy.wait('@getSequences');
59
+
60
+ cy.getStub('get_text_file_sequences_by_ids').then((stub) => {
61
+ cy.get('@onSequencesLoadedSpy').should((spy) => expectLoadedSequences(spy, stub.response.body));
62
+ });
63
+ });
64
+
65
+ it('shows the error returned by the server', () => {
66
+ const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
67
+ cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
68
+ cy.intercept('GET', `**${endpoints.sequenceTextFiles}*`, {
69
+ statusCode: 400,
70
+ body: { detail: 'Too many sequences (300), the maximum is 200.' },
71
+ }).as('getSequences');
72
+
73
+ cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
74
+
75
+ cy.wait('@getTags');
76
+ clickMultiSelectOption('Tags', TAG_NAME, 'div');
77
+ cy.contains('button', 'Load sequences').click();
78
+ cy.wait('@getSequences');
79
+ cy.get('.MuiAlert-colorError').contains('Too many sequences (300), the maximum is 200.').should('exist');
80
+ cy.get('@onSequencesLoadedSpy').should('not.have.been.called');
81
+ });
82
+
83
+ it('shows a message when no sequences have the tag', () => {
84
+ const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
85
+ cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
86
+ cy.intercept('GET', `**${endpoints.sequenceTextFiles}*`, { statusCode: 200, body: [] }).as('getSequences');
87
+
88
+ cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
89
+
90
+ cy.wait('@getTags');
91
+ clickMultiSelectOption('Tags', TAG_NAME, 'div');
92
+ cy.contains('button', 'Load sequences').click();
93
+ cy.wait('@getSequences');
94
+ cy.contains('No sequences found with the selected tags.').should('exist');
95
+ cy.get('@onSequencesLoadedSpy').should('not.have.been.called');
96
+ });
97
+
98
+ it('shows a message when the picked sequences have no content', () => {
99
+ const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
100
+ cy.interceptOpenCloningDBStub('get_sequences_search_by_name', { alias: 'searchByName' });
101
+ cy.interceptOpenCloningDBStub('get_sequences_search_by_name_uid_query', { alias: 'searchByUid' });
102
+ cy.intercept('GET', `**${endpoints.sequenceTextFiles}*`, { statusCode: 200, body: [] }).as('getSequences');
103
+
104
+ cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
105
+
106
+ cy.contains('button', 'Pick sequences').click();
107
+ cy.get('input').type(SEQUENCE_NAME);
108
+ cy.wait('@searchByName');
109
+ clickMultiSelectOption('Sequences', RegExp(`^${SEQUENCE_NAME}$`), 'div');
110
+ cy.contains('button', 'Load sequences').click();
111
+ cy.wait('@getSequences');
112
+ cy.contains('The selected sequences have no content (template sequences cannot be loaded).').should('exist');
113
+ cy.contains('No sequences found with the selected tags.').should('not.exist');
114
+ cy.get('@onSequencesLoadedSpy').should('not.have.been.called');
115
+ });
116
+ });
@@ -0,0 +1,74 @@
1
+ import React from 'react';
2
+ import { useMutation } from '@tanstack/react-query';
3
+ import { Alert, Button, CircularProgress, FormControl, Stack, ToggleButton, ToggleButtonGroup } from '@mui/material';
4
+ import SequenceSelect from './SequenceSelect';
5
+ import TagMultiSelect from './TagMultiSelect';
6
+ import { formatSequenceName, openCloningDBHttpClient } from './common';
7
+ import endpoints from './endpoints';
8
+
9
+ /**
10
+ * Load several sequences from OpenCloningDB, either all the sequences with any of the selected tags,
11
+ * or the sequences picked by the user. Template sequences are not included (they have no content).
12
+ *
13
+ * onSequencesLoaded receives an array of { databaseId, name, textFileSequence }.
14
+ */
15
+ function GetSequencesInBulkComponent({ onSequencesLoaded }) {
16
+ const [mode, setMode] = React.useState('tags');
17
+ const [selectedTags, setSelectedTags] = React.useState([]);
18
+ const [selectedSequences, setSelectedSequences] = React.useState([]);
19
+ const [emptyResult, setEmptyResult] = React.useState(false);
20
+
21
+ const { mutate, isPending, error, reset } = useMutation({
22
+ mutationFn: async (params) => {
23
+ const { data } = await openCloningDBHttpClient.get(endpoints.sequenceTextFiles, { params });
24
+ return data.map(({ sequence_ref, sequence }) => ({
25
+ databaseId: sequence_ref.id,
26
+ name: formatSequenceName(sequence_ref),
27
+ textFileSequence: sequence,
28
+ }));
29
+ },
30
+ onSuccess: (sequences) => {
31
+ setEmptyResult(sequences.length === 0);
32
+ if (sequences.length > 0) {
33
+ onSequencesLoaded(sequences);
34
+ }
35
+ },
36
+ retry: false,
37
+ });
38
+
39
+ const onModeChange = (event, newMode) => {
40
+ if (newMode !== null) {
41
+ setMode(newMode);
42
+ setEmptyResult(false);
43
+ reset();
44
+ }
45
+ };
46
+
47
+ const selection = mode === 'tags' ? selectedTags : selectedSequences;
48
+ const onLoad = () => {
49
+ setEmptyResult(false);
50
+ // selectedTags are ids, selectedSequences are sequence objects
51
+ mutate(mode === 'tags' ? { tags: selectedTags } : { ids: selectedSequences.map((item) => item.id) });
52
+ };
53
+
54
+ return (
55
+ <Stack spacing={2} sx={{ pt: 1 }}>
56
+ <ToggleButtonGroup value={mode} exclusive onChange={onModeChange} size="small" color="primary">
57
+ <ToggleButton value="tags">By tag</ToggleButton>
58
+ <ToggleButton value="sequences">Pick sequences</ToggleButton>
59
+ </ToggleButtonGroup>
60
+ <FormControl fullWidth>
61
+ {mode === 'tags'
62
+ ? <TagMultiSelect label="Tags" value={selectedTags} onChange={setSelectedTags} allowCreate={false} />
63
+ : <SequenceSelect label="Sequences" value={selectedSequences} onChange={setSelectedSequences} />}
64
+ </FormControl>
65
+ {error && <Alert severity="error">{error.response?.data?.detail || error.message || 'Could not load sequences from OpenCloningDB'}</Alert>}
66
+ {emptyResult && <Alert severity="info">{mode === 'tags' ? 'No sequences found with the selected tags.' : 'The selected sequences have no content (template sequences cannot be loaded).'}</Alert>}
67
+ {isPending
68
+ ? <CircularProgress sx={{ alignSelf: 'center' }} />
69
+ : <Button variant="contained" disabled={selection.length === 0} onClick={onLoad}>Load sequences</Button>}
70
+ </Stack>
71
+ );
72
+ }
73
+
74
+ export default GetSequencesInBulkComponent;
@@ -1,10 +1,12 @@
1
1
  import { Save as SaveIcon, Link as LinkIcon } from '@mui/icons-material';
2
2
  import GetPrimerComponent from './GetPrimerComponent';
3
3
  import GetSequenceFileAndDatabaseIdComponent from './GetSequenceFileAndDatabaseIdComponent';
4
+ import GetSequencesInBulkComponent from './GetSequencesInBulkComponent';
4
5
  import { openCloningDBHttpClient } from './common';
5
6
  import endpoints from './endpoints';
6
7
  import LoadHistoryComponent from './LoadHistoryComponent';
7
8
  import SubmitToDatabaseComponent from './SubmitToDatabaseComponent';
9
+ import SubmitCloningStrategiesInBulkComponent from './SubmitCloningStrategiesInBulkComponent';
8
10
  import PrimersNotInDatabaseComponent from './PrimersNotInDatabaseComponent';
9
11
 
10
12
  function isSubmissionDataValid(submissionData) {
@@ -138,4 +140,10 @@ export default {
138
140
  locateSequenceInDatabase,
139
141
  // Function to submit sequencing files to the database
140
142
  submitSequencingFileToDatabase,
143
+ // Component for loading several sequences at once (e.g. in the assembler), calls
144
+ // onSequencesLoaded with an array of { databaseId, name, textFileSequence }
145
+ GetSequencesInBulkComponent,
146
+ // Dialog for submitting several cloning strategies at once (e.g. the assembler products), receives
147
+ // { files, onClose, onSuccess }, where files is an array of File (cloning strategy JSON)
148
+ SubmitCloningStrategiesInBulkComponent,
141
149
  };
@@ -25,6 +25,8 @@ vi.mock('./GetSequenceFileAndDatabaseIdComponent', () => ({ default: () => null
25
25
  vi.mock('./LoadHistoryComponent', () => ({ default: () => null }));
26
26
  vi.mock('./SubmitToDatabaseComponent', () => ({ default: () => null }));
27
27
  vi.mock('./PrimersNotInDatabaseComponent', () => ({ default: () => null }));
28
+ vi.mock('./GetSequencesInBulkComponent', () => ({ default: () => null }));
29
+ vi.mock('./SubmitCloningStrategiesInBulkComponent', () => ({ default: () => null }));
28
30
 
29
31
  import OpenCloningDBInterface from './OpenCloningDBInterface';
30
32
 
@@ -10,6 +10,8 @@ vi.mock('./GetSequenceFileAndDatabaseIdComponent', () => ({ default: () => null
10
10
  vi.mock('./LoadHistoryComponent', () => ({ default: () => null }));
11
11
  vi.mock('./SubmitToDatabaseComponent', () => ({ default: () => null }));
12
12
  vi.mock('./PrimersNotInDatabaseComponent', () => ({ default: () => null }));
13
+ vi.mock('./GetSequencesInBulkComponent', () => ({ default: () => null }));
14
+ vi.mock('./SubmitCloningStrategiesInBulkComponent', () => ({ default: () => null }));
13
15
 
14
16
  import OpenCloningDBInterface from './OpenCloningDBInterface';
15
17
 
@@ -0,0 +1,179 @@
1
+ import React from 'react';
2
+ import SubmitCloningStrategiesInBulkComponent from './SubmitCloningStrategiesInBulkComponent';
3
+ import endpoints from './endpoints';
4
+
5
+ const TAG_NAME = 'cre_lox_recombination';
6
+ const validateUrl = { method: 'POST', pathname: `/db${endpoints.sequencesCloningStrategyBulkValidate}` };
7
+ const submitUrl = { method: 'POST', pathname: `/db${endpoints.sequencesCloningStrategyBulk}` };
8
+
9
+ const fileContents = ['clear', 'synced', 'warning', 'error'].map((name) => ({
10
+ name: `${name}.json`,
11
+ content: JSON.stringify({ sequences: [], sources: [], primers: [], description: name }),
12
+ }));
13
+ const files = fileContents.map(({ name, content }) => new File([content], name, { type: 'application/json' }));
14
+
15
+ const strategy = (name) => ({ sequences: [], sources: [], primers: [], description: name });
16
+ const clearRow = { file_name: 'clear.json', cloning_strategy: strategy('clear') };
17
+ const syncedRow = { file_name: 'synced.json', cloning_strategy: strategy('synced'), already_synced: true };
18
+ const warningRow = { file_name: 'warning.json', cloning_strategy: strategy('warning'), parsing_warnings: ['Some warning'] };
19
+ const errorRow = { file_name: 'error.json', parsing_errors: ['Cloning strategy is not correct: bad'] };
20
+ const validationRows = [clearRow, syncedRow, warningRow, errorRow];
21
+
22
+ const createdSequences = [{ id: 100, type: 'Sequence', name: 'product' }];
23
+
24
+ function bodyToString(body) {
25
+ return typeof body === 'string' ? body : new TextDecoder().decode(body);
26
+ }
27
+
28
+ function mountComponent() {
29
+ const onCloseSpy = cy.spy().as('onCloseSpy');
30
+ const onSuccessSpy = cy.spy().as('onSuccessSpy');
31
+ cy.mount(<SubmitCloningStrategiesInBulkComponent files={files} onClose={onCloseSpy} onSuccess={onSuccessSpy} />);
32
+ }
33
+
34
+ describe('<SubmitCloningStrategiesInBulkComponent />', () => {
35
+ beforeEach(() => {
36
+ cy.setupOpenCloningDBTestAuth();
37
+ });
38
+
39
+ it('validates the files as multipart and shows the status of each one', () => {
40
+ cy.intercept(validateUrl, (req) => {
41
+ expect(req.headers['content-type']).to.contain('multipart/form-data');
42
+ const body = bodyToString(req.body);
43
+ fileContents.forEach(({ name, content }) => {
44
+ expect(body).to.contain(`filename="${name}"`);
45
+ expect(body).to.contain(content);
46
+ });
47
+ req.reply({ statusCode: 200, body: validationRows });
48
+ }).as('validate');
49
+
50
+ mountComponent();
51
+ cy.wait('@validate');
52
+ cy.get('[data-testid="submit-cloning-strategies-in-bulk-dialog"]').within(() => {
53
+ cy.get('tbody tr').should('have.length', 4);
54
+ // Errors first, then warnings, informational and clear rows
55
+ cy.get('tbody tr').eq(0).should('contain', 'error.json').and('contain', 'Invalid').and('contain', 'Cloning strategy is not correct: bad');
56
+ cy.get('tbody tr').eq(1).should('contain', 'warning.json').and('contain', 'Review warnings').and('contain', 'Some warning');
57
+ cy.get('tbody tr').eq(2).should('contain', 'synced.json').and('contain', 'Already synced');
58
+ cy.get('tbody tr').eq(3).should('contain', 'clear.json').and('contain', 'Ready to import');
59
+ cy.contains('1 clear, 1 warning, 1 error, 1 informational out of 4 uploaded items.').should('exist');
60
+ });
61
+ });
62
+
63
+ it('submits the importable rows with the selected tags', () => {
64
+ cy.intercept(validateUrl, { statusCode: 200, body: validationRows }).as('validate');
65
+ cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
66
+ cy.intercept(submitUrl, (req) => {
67
+ req.reply({ statusCode: 200, body: createdSequences });
68
+ }).as('submit');
69
+
70
+ mountComponent();
71
+ cy.wait(['@validate', '@getTags']);
72
+ // Not using clickMultiSelectOption, the click outside fails inside a dialog
73
+ cy.get('div[role="dialog"]').contains('label', 'Tags to apply (optional)').siblings('div').first().click();
74
+ cy.get('li[role="option"]').contains(TAG_NAME).click();
75
+ cy.get('div[role="dialog"]').contains('Upload to OpenCloningDB').click();
76
+ cy.contains('button', 'Import entries with warnings too').click();
77
+
78
+ cy.wait('@submit').then(({ request }) => {
79
+ expect(request.body).to.deep.equal([
80
+ { file_name: 'warning.json', cloning_strategy: warningRow.cloning_strategy },
81
+ { file_name: 'clear.json', cloning_strategy: clearRow.cloning_strategy },
82
+ ]);
83
+ cy.getStub('get_tags').then((stub) => {
84
+ const tag = stub.response.body.find((t) => t.name === TAG_NAME);
85
+ expect(request.query).to.deep.equal({ tags: String(tag.id) });
86
+ });
87
+ });
88
+ cy.get('@onSuccessSpy').should('have.been.calledOnceWith', createdSequences, [
89
+ { file_name: 'warning.json', cloning_strategy: warningRow.cloning_strategy },
90
+ { file_name: 'clear.json', cloning_strategy: clearRow.cloning_strategy },
91
+ ]);
92
+ cy.get('@onCloseSpy').should('have.been.calledOnce');
93
+ });
94
+
95
+ it('submits only the clear rows without tags', () => {
96
+ cy.intercept(validateUrl, { statusCode: 200, body: validationRows }).as('validate');
97
+ cy.intercept(submitUrl, (req) => {
98
+ expect(req.query).to.deep.equal({});
99
+ expect(req.body).to.deep.equal([{ file_name: 'clear.json', cloning_strategy: clearRow.cloning_strategy }]);
100
+ req.reply({ statusCode: 200, body: createdSequences });
101
+ }).as('submit');
102
+
103
+ mountComponent();
104
+ cy.wait('@validate');
105
+ cy.contains('button', 'Import entries without warnings').click();
106
+ cy.wait('@submit');
107
+ cy.get('@onSuccessSpy').should('have.been.calledOnceWith', createdSequences, [
108
+ { file_name: 'clear.json', cloning_strategy: clearRow.cloning_strategy },
109
+ ]);
110
+ });
111
+
112
+ it('does not allow importing when no row is importable', () => {
113
+ cy.intercept(validateUrl, { statusCode: 200, body: [syncedRow, errorRow] }).as('validate');
114
+ cy.intercept(submitUrl, cy.spy().as('submitSpy'));
115
+
116
+ mountComponent();
117
+ cy.wait('@validate');
118
+ // Only already synced rows, which are not submitted
119
+ cy.contains('button', 'Import').should('be.disabled');
120
+ cy.get('@submitSpy').should('not.have.been.called');
121
+ });
122
+
123
+ it('updates the rows when the submission conflicts with the database', () => {
124
+ cy.intercept(validateUrl, { statusCode: 200, body: [clearRow] }).as('validate');
125
+ cy.intercept(submitUrl, { statusCode: 409, body: [{ ...clearRow, already_synced: true }] }).as('submit');
126
+
127
+ mountComponent();
128
+ cy.wait('@validate');
129
+ cy.contains('button', 'Import').click();
130
+ cy.wait('@submit');
131
+ cy.contains('Conflicts detected while importing. Review the updated validation results.').should('exist');
132
+ cy.get('tbody tr').eq(0).should('contain', 'clear.json').and('contain', 'Already synced');
133
+ cy.get('@onSuccessSpy').should('not.have.been.called');
134
+ cy.get('@onCloseSpy').should('not.have.been.called');
135
+ });
136
+
137
+ it('shows the error when the submission fails', () => {
138
+ cy.intercept(validateUrl, { statusCode: 200, body: [clearRow] }).as('validate');
139
+ cy.intercept(submitUrl, { statusCode: 403, body: { detail: 'Insufficient permissions' } }).as('submit');
140
+
141
+ mountComponent();
142
+ cy.wait('@validate');
143
+ cy.contains('button', 'Import').click();
144
+ cy.wait('@submit');
145
+ cy.get('.MuiAlert-colorError').contains('Insufficient permissions').should('exist');
146
+ cy.get('@onSuccessSpy').should('not.have.been.called');
147
+ });
148
+
149
+ it('shows the error when the validation fails', () => {
150
+ cy.intercept(validateUrl, { statusCode: 400, body: { detail: 'File too large' } }).as('validate');
151
+
152
+ mountComponent();
153
+ cy.wait('@validate');
154
+ cy.get('.MuiAlert-colorError').contains('File too large').should('exist');
155
+ cy.contains('button', 'Close').click();
156
+ cy.get('@onCloseSpy').should('have.been.calledOnce');
157
+ });
158
+
159
+ it('uses the given title and test id', () => {
160
+ cy.intercept(validateUrl, { statusCode: 200, body: validationRows }).as('validate');
161
+ cy.mount(<SubmitCloningStrategiesInBulkComponent files={files} onClose={cy.spy()} title="Custom title" dataTestId="custom-test-id" />);
162
+ cy.wait('@validate');
163
+ cy.get('[data-testid="custom-test-id"]').should('have.attr', 'role', 'dialog').within(() => {
164
+ cy.contains('Custom title').should('exist');
165
+ cy.get('tbody tr').should('have.length', 4);
166
+ });
167
+ });
168
+
169
+ it('closes without submitting when cancelling', () => {
170
+ cy.intercept(validateUrl, { statusCode: 200, body: validationRows }).as('validate');
171
+ cy.intercept(submitUrl, cy.spy().as('submitSpy'));
172
+
173
+ mountComponent();
174
+ cy.wait('@validate');
175
+ cy.contains('button', 'Cancel').click();
176
+ cy.get('@onCloseSpy').should('have.been.calledOnce');
177
+ cy.get('@submitSpy').should('not.have.been.called');
178
+ });
179
+ });
@@ -0,0 +1,105 @@
1
+ import React from 'react';
2
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { Alert, Box, Button } from '@mui/material';
4
+ import error2String from '@opencloning/utils/error2String';
5
+ import BulkUploadModal from './bulk_upload/BulkUploadModal';
6
+ import CloningStrategyBulkUploadPreviewTable from './bulk_upload/CloningStrategyBulkUploadPreviewTable';
7
+ import { validateCloningStrategyFiles, submitCloningStrategies } from './bulk_upload/cloning_strategy_bulk_api';
8
+ import { rowsToCloningStrategySyncResults } from './bulk_upload/bulk_upload_utils';
9
+
10
+ // Each dialog validates its own files, this gives it a unique key in the query cache
11
+ let validationCounter = 0;
12
+
13
+ /**
14
+ * Dialog to submit several cloning strategies at once (the bulk upload of sequences with history):
15
+ * the files are validated (and synced with the database), the user reviews the result, and the
16
+ * importable ones are submitted.
17
+ *
18
+ * files: array of File (OpenCloning JSON or SnapGene .dna).
19
+ * onSuccess(sequenceRefs, syncResults): sequenceRefs are returned by the server, all the sequences of the
20
+ * submitted strategies, both the created ones and those that were already in the database. syncResults
21
+ * are the submitted strategies ({ file_name, cloning_strategy }).
22
+ */
23
+ function SubmitCloningStrategiesInBulkComponent({
24
+ files,
25
+ onClose,
26
+ onSuccess,
27
+ title = 'Upload to OpenCloningDB',
28
+ dataTestId = 'submit-cloning-strategies-in-bulk-dialog',
29
+ }) {
30
+ // Files cannot be part of the query key (they are not serializable), and React.useId would be
31
+ // reused if the dialog is closed and opened again in the same place, so a counter is used.
32
+ const [validationId] = React.useState(() => ++validationCounter);
33
+ const queryClient = useQueryClient();
34
+ const [bulkTags, setBulkTags] = React.useState([]);
35
+ // Validation rows returned by the server when a submission conflicts with the database
36
+ const [conflictRows, setConflictRows] = React.useState(null);
37
+ const [message, setMessage] = React.useState(null);
38
+
39
+ // A query (not a mutation) so that it is not sent twice in strict mode, gcTime 0 so that it is never reused
40
+ const validation = useQuery({
41
+ queryKey: ['cloningStrategyBulkValidate', validationId],
42
+ queryFn: () => validateCloningStrategyFiles(files),
43
+ retry: false,
44
+ gcTime: 0,
45
+ staleTime: Infinity,
46
+ refetchOnWindowFocus: false,
47
+ });
48
+ const rows = conflictRows ?? validation.data ?? [];
49
+
50
+ const submission = useMutation({
51
+ mutationFn: submitCloningStrategies,
52
+ onSuccess: (sequenceRefs, { syncResults }) => {
53
+ queryClient.invalidateQueries({ queryKey: ['sequences'] });
54
+ queryClient.invalidateQueries({ queryKey: ['primers'] });
55
+ onSuccess?.(sequenceRefs, syncResults);
56
+ onClose();
57
+ },
58
+ onError: (error) => {
59
+ const updatedRows = error?.response?.status === 409 ? error.response.data : null;
60
+ if (Array.isArray(updatedRows)) {
61
+ setConflictRows(updatedRows);
62
+ setMessage({ severity: 'warning', text: 'Conflicts detected while importing. Review the updated validation results.' });
63
+ return;
64
+ }
65
+ setMessage({ severity: 'error', text: error2String(error) });
66
+ },
67
+ });
68
+
69
+ const handleSubmit = (submittedRows) => {
70
+ const syncResults = rowsToCloningStrategySyncResults(submittedRows);
71
+ if (syncResults.length < 1) {
72
+ setMessage({ severity: 'warning', text: 'No importable cloning strategies selected' });
73
+ return;
74
+ }
75
+ setMessage(null);
76
+ submission.mutate({ syncResults, tags: bulkTags });
77
+ };
78
+
79
+ return (
80
+ <BulkUploadModal open onClose={submission.isPending ? undefined : onClose} title={title} dataTestId={dataTestId}>
81
+ {message && <Alert severity={message.severity} sx={{ mb: 2 }}>{message.text}</Alert>}
82
+ {validation.error ? (
83
+ <>
84
+ <Alert severity="error" sx={{ mb: 2 }}>{error2String(validation.error)}</Alert>
85
+ <Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
86
+ <Button variant="outlined" onClick={onClose}>Close</Button>
87
+ </Box>
88
+ </>
89
+ ) : (
90
+ <CloningStrategyBulkUploadPreviewTable
91
+ rows={rows}
92
+ handleSubmit={handleSubmit}
93
+ handleCancel={onClose}
94
+ isSubmitting={submission.isPending}
95
+ isValidating={validation.isPending}
96
+ bulkTags={bulkTags}
97
+ onBulkTagsChange={setBulkTags}
98
+ onTagCreateError={(error) => setMessage({ severity: 'error', text: `Could not create tag: ${error2String(error)}` })}
99
+ />
100
+ )}
101
+ </BulkUploadModal>
102
+ );
103
+ }
104
+
105
+ export default SubmitCloningStrategiesInBulkComponent;
@@ -0,0 +1,88 @@
1
+ import React from 'react';
2
+ import TagMultiSelect from './TagMultiSelect';
3
+ import endpoints from './endpoints';
4
+
5
+ const tagsUrl = { method: 'GET', pathname: `/db${endpoints.tags}` };
6
+ const postTagUrl = { method: 'POST', pathname: `/db${endpoints.tagPost}` };
7
+
8
+ const tags = [{ id: 1, name: 'tag_one' }, { id: 2, name: 'tag_two' }];
9
+
10
+ // TagMultiSelect is controlled, this keeps the selected ids in state
11
+ function Harness({ onChangeSpy, ...props }) {
12
+ const [value, setValue] = React.useState([]);
13
+ return (
14
+ <TagMultiSelect
15
+ label="Tags"
16
+ value={value}
17
+ onChange={(ids) => { onChangeSpy(ids); setValue(ids); }}
18
+ {...props}
19
+ />
20
+ );
21
+ }
22
+
23
+ function openOptions() {
24
+ cy.get('label').contains('Tags').siblings('div').first().click();
25
+ }
26
+
27
+ describe('<TagMultiSelect />', () => {
28
+ beforeEach(() => {
29
+ cy.setupOpenCloningDBTestAuth();
30
+ cy.intercept(tagsUrl, { statusCode: 200, body: tags }).as('getTags');
31
+ });
32
+
33
+ it('selects tags by id', () => {
34
+ const onChangeSpy = cy.spy().as('onChangeSpy');
35
+ cy.mount(<Harness onChangeSpy={onChangeSpy} />);
36
+ cy.wait('@getTags');
37
+ openOptions();
38
+ cy.get('li[role="option"]').contains('tag_two').click();
39
+ cy.get('@onChangeSpy').should('have.been.calledWith', [2]);
40
+ });
41
+
42
+ it('does not show excluded tags', () => {
43
+ cy.mount(<Harness onChangeSpy={cy.spy()} excludeTagIds={[1]} />);
44
+ cy.wait('@getTags');
45
+ openOptions();
46
+ cy.get('li[role="option"]').should('have.length', 1);
47
+ cy.get('li[role="option"]').contains('tag_two').should('exist');
48
+ });
49
+
50
+ it('creates a tag and selects it', () => {
51
+ const onChangeSpy = cy.spy().as('onChangeSpy');
52
+ cy.intercept(postTagUrl, (req) => {
53
+ expect(req.body).to.deep.equal({ name: 'new_tag' });
54
+ req.reply({ statusCode: 201, body: { id: 3, name: 'new_tag' } });
55
+ }).as('postTag');
56
+ cy.mount(<Harness onChangeSpy={onChangeSpy} />);
57
+ cy.wait('@getTags');
58
+ cy.intercept(tagsUrl, { statusCode: 200, body: [...tags, { id: 3, name: 'new_tag' }] }).as('getTagsAfterCreate');
59
+ cy.get('input').type('new_tag');
60
+ cy.contains('Create tag').click();
61
+ cy.wait('@postTag');
62
+ cy.wait('@getTagsAfterCreate');
63
+ cy.get('@onChangeSpy').should('have.been.calledWith', [3]);
64
+ cy.get('.MuiChip-root').contains('new_tag').should('exist');
65
+ });
66
+
67
+ it('calls onCreateError when creating a tag fails', () => {
68
+ const onChangeSpy = cy.spy().as('onChangeSpy');
69
+ const onCreateErrorSpy = cy.spy().as('onCreateErrorSpy');
70
+ cy.intercept(postTagUrl, { statusCode: 409, body: { detail: 'Tag already exists' } }).as('postTag');
71
+ cy.mount(<Harness onChangeSpy={onChangeSpy} onCreateError={onCreateErrorSpy} />);
72
+ cy.wait('@getTags');
73
+ cy.get('input').type('tag_three');
74
+ cy.contains('Create tag').click();
75
+ cy.wait('@postTag');
76
+ cy.get('@onCreateErrorSpy').should('have.been.calledOnce');
77
+ cy.get('@onCreateErrorSpy').its('lastCall.args.0.response.data.detail').should('equal', 'Tag already exists');
78
+ cy.get('@onChangeSpy').should('not.have.been.called');
79
+ });
80
+
81
+ it('does not offer to create tags if allowCreate is false', () => {
82
+ cy.mount(<Harness onChangeSpy={cy.spy()} allowCreate={false} />);
83
+ cy.wait('@getTags');
84
+ cy.get('input').type('tag_three');
85
+ cy.contains('No options').should('exist');
86
+ cy.contains('Create tag').should('not.exist');
87
+ });
88
+ });
@@ -0,0 +1,61 @@
1
+ import React from 'react';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import { QuerySelect } from '@opencloning/ui';
4
+ import { openCloningDBHttpClient } from './common';
5
+ import endpoints from './endpoints';
6
+ import useCreateTagMutation from './useCreateTagMutation';
7
+
8
+ /**
9
+ * Multi-select of the workspace tags. value and onChange use arrays of tag ids.
10
+ * If allowCreate, a tag that does not exist can be created from the options popup,
11
+ * and it is added to the selection. onCreateError receives the error if the creation fails.
12
+ */
13
+ function TagMultiSelect({ onChange, label = 'Tags', value, excludeTagIds = [], allowCreate = true, onCreateError, ...rest }) {
14
+ const query = React.useMemo(() => ({
15
+ queryKey: ['tags'],
16
+ queryFn: async () => {
17
+ const { data } = await openCloningDBHttpClient.get(endpoints.tags);
18
+ return data;
19
+ },
20
+ select: (tags) => tags.filter((tag) => !excludeTagIds.includes(tag.id)),
21
+ }), [excludeTagIds]);
22
+
23
+ const { data: options = [] } = useQuery(query);
24
+ const valueAsObjects = React.useMemo(
25
+ () => (value || []).map((id) => options.find((o) => o.id === id)).filter(Boolean),
26
+ [value, options],
27
+ );
28
+
29
+ const createTagMutation = useCreateTagMutation({ onError: onCreateError });
30
+
31
+ const handleCreateTag = React.useCallback(async (inputValue) => {
32
+ let createdTag;
33
+ try {
34
+ createdTag = await createTagMutation.mutateAsync(inputValue);
35
+ } catch {
36
+ // Reported by onError
37
+ return;
38
+ }
39
+ const selectedTagIds = value || [];
40
+ onChange(selectedTagIds.includes(createdTag.id) ? selectedTagIds : [...selectedTagIds, createdTag.id]);
41
+ }, [createTagMutation, onChange, value]);
42
+
43
+ return (
44
+ <QuerySelect
45
+ query={query}
46
+ label={label}
47
+ loadingMessage="retrieving tags"
48
+ errorMessage="Could not retrieve tags from OpenCloningDB"
49
+ getOptionLabel={(tag) => tag.name}
50
+ getOptionKey={(tag) => tag.id}
51
+ value={valueAsObjects}
52
+ onChange={(selectedTags) => onChange(selectedTags?.map((t) => t.id) ?? [])}
53
+ inputProps={{ size: 'small' }}
54
+ autoComplete={true}
55
+ noOptionsAction={allowCreate ? { label: 'Create tag', onClick: handleCreateTag } : null}
56
+ {...rest}
57
+ />
58
+ );
59
+ }
60
+
61
+ export default TagMultiSelect;
@@ -0,0 +1,40 @@
1
+ import React from 'react';
2
+ import { Box, Modal, Typography } from '@mui/material';
3
+
4
+ /** Modal used by the bulk upload previews. Pass onClose undefined to prevent closing it (e.g. while submitting). */
5
+ export default function BulkUploadModal({
6
+ open,
7
+ onClose,
8
+ title,
9
+ dataTestId,
10
+ children,
11
+ }) {
12
+ const titleId = React.useId();
13
+ return (
14
+ <Modal open={open} onClose={onClose} aria-labelledby={titleId}>
15
+ <Box
16
+ role="dialog"
17
+ aria-modal="true"
18
+ aria-labelledby={titleId}
19
+ data-testid={dataTestId}
20
+ sx={{
21
+ bgcolor: 'background.paper',
22
+ p: 3,
23
+ width: 'min(1280px, 98vw)',
24
+ maxHeight: '90vh',
25
+ overflow: 'hidden',
26
+ mx: 'auto',
27
+ my: '5vh',
28
+ borderRadius: 1,
29
+ display: 'flex',
30
+ flexDirection: 'column',
31
+ }}
32
+ >
33
+ <Typography id={titleId} variant="h6" sx={{ mb: 1, textAlign: 'center' }}>
34
+ {title}
35
+ </Typography>
36
+ {children}
37
+ </Box>
38
+ </Modal>
39
+ );
40
+ }
@@ -0,0 +1,41 @@
1
+ import React from 'react';
2
+ import { Box, CircularProgress } from '@mui/material';
3
+ import { BulkUploadPreviewTableWrapper } from './common';
4
+
5
+ export default function BulkUploadPreview({
6
+ rows,
7
+ getRowsInfo,
8
+ renderTable,
9
+ handleSubmit,
10
+ handleCancel,
11
+ isSubmitting,
12
+ isValidating,
13
+ bulkTags,
14
+ onBulkTagsChange,
15
+ onTagCreateError,
16
+ importMode,
17
+ }) {
18
+ if (isValidating) {
19
+ return (
20
+ <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
21
+ <CircularProgress size={24} />
22
+ </Box>
23
+ );
24
+ }
25
+
26
+ const rowsInfo = getRowsInfo(rows);
27
+ return (
28
+ <BulkUploadPreviewTableWrapper
29
+ rowsInfo={rowsInfo}
30
+ handleSubmit={handleSubmit}
31
+ handleCancel={handleCancel}
32
+ isSubmitting={isSubmitting}
33
+ bulkTags={bulkTags}
34
+ onBulkTagsChange={onBulkTagsChange}
35
+ onTagCreateError={onTagCreateError}
36
+ importMode={importMode}
37
+ >
38
+ {renderTable(rowsInfo.orderedRows)}
39
+ </BulkUploadPreviewTableWrapper>
40
+ );
41
+ }
@@ -0,0 +1,103 @@
1
+ import React from 'react';
2
+ import { Table, TableBody, TableCell, TableHead, TableRow } from '@mui/material';
3
+ import {
4
+ cloningStrategyRowIssues,
5
+ cloningStrategyRowStatus,
6
+ getCloningStrategyRowsInfo,
7
+ } from './bulk_upload_utils';
8
+ import { CommonTableRow } from './common';
9
+ import BulkUploadPreview from './BulkUploadPreview';
10
+
11
+ function statusLabel(status) {
12
+ if (status === 'error') {
13
+ return 'Invalid';
14
+ }
15
+ if (status === 'warning') {
16
+ return 'Review warnings';
17
+ }
18
+ if (status === 'info') {
19
+ return 'Already synced';
20
+ }
21
+ return 'Ready to import';
22
+ }
23
+
24
+ function strategyCounts(row) {
25
+ return {
26
+ sequenceCount: row.cloning_strategy?.sequences?.length ?? 0,
27
+ primerCount: row.cloning_strategy?.primers?.length ?? 0,
28
+ };
29
+ }
30
+
31
+ function CloningStrategyBulkUploadPreviewTableRow({ row }) {
32
+ const issues = cloningStrategyRowIssues(row);
33
+ const status = cloningStrategyRowStatus(row);
34
+ const { sequenceCount, primerCount } = strategyCounts(row);
35
+
36
+ return (
37
+ <CommonTableRow status={status} issues={issues}>
38
+ <TableCell>{row.file_name || '—'}</TableCell>
39
+ <TableCell>{statusLabel(status)}</TableCell>
40
+ <TableCell>{sequenceCount}</TableCell>
41
+ <TableCell>{primerCount}</TableCell>
42
+ </CommonTableRow>
43
+ );
44
+ }
45
+
46
+ function TableComponent({ rows }) {
47
+ return (
48
+ <Table stickyHeader size="small" sx={{ tableLayout: 'fixed', width: '100%' }}>
49
+ <colgroup>
50
+ <col style={{ width: '48px' }} />
51
+ <col style={{ width: '25%' }} />
52
+ <col style={{ width: '17%' }} />
53
+ <col style={{ width: '10%' }} />
54
+ <col style={{ width: '10%' }} />
55
+ <col style={{ width: '38%' }} />
56
+ </colgroup>
57
+ <TableHead sx={{ textAlign: 'center' }}>
58
+ <TableRow sx={{ textAlign: 'center' }}>
59
+ <TableCell padding="checkbox"></TableCell>
60
+ <TableCell>File Name</TableCell>
61
+ <TableCell>Sync State</TableCell>
62
+ <TableCell>Sequences</TableCell>
63
+ <TableCell>Primers</TableCell>
64
+ <TableCell>Issues</TableCell>
65
+ </TableRow>
66
+ </TableHead>
67
+ <TableBody>
68
+ {rows.map((row, index) => (
69
+ <CloningStrategyBulkUploadPreviewTableRow
70
+ key={`${row.file_name || 'cloning-strategy'}-${index}`}
71
+ row={row}
72
+ />
73
+ ))}
74
+ </TableBody>
75
+ </Table>
76
+ );
77
+ }
78
+
79
+ export default function CloningStrategyBulkUploadPreviewTable({
80
+ rows,
81
+ handleSubmit,
82
+ handleCancel,
83
+ isSubmitting,
84
+ isValidating,
85
+ bulkTags,
86
+ onBulkTagsChange,
87
+ onTagCreateError,
88
+ }) {
89
+ return (
90
+ <BulkUploadPreview
91
+ rows={rows}
92
+ getRowsInfo={getCloningStrategyRowsInfo}
93
+ renderTable={(orderedRows) => <TableComponent rows={orderedRows} />}
94
+ handleSubmit={handleSubmit}
95
+ handleCancel={handleCancel}
96
+ isSubmitting={isSubmitting}
97
+ isValidating={isValidating}
98
+ bulkTags={bulkTags}
99
+ onBulkTagsChange={onBulkTagsChange}
100
+ onTagCreateError={onTagCreateError}
101
+ />
102
+ );
103
+ }
@@ -0,0 +1,76 @@
1
+ /** Helpers to display the validation rows returned by the cloning strategy bulk validate endpoint. */
2
+
3
+ function formatMismatchKind(kind) {
4
+ return String(kind ?? '').replaceAll('_', ' ');
5
+ }
6
+
7
+ export function cloningStrategyRowIssues(row) {
8
+ const issues = [];
9
+ (row.parsing_errors ?? []).forEach((error) => issues.push(error));
10
+ (row.parsing_warnings ?? []).forEach((warning) => issues.push(warning));
11
+ (row.primer_database_id_mismatches ?? []).forEach((mismatch) => {
12
+ issues.push(
13
+ `Primer ${mismatch.primer_id} database_id mismatch: ${formatMismatchKind(mismatch.kind)} (provided ${mismatch.provided_database_id})`,
14
+ );
15
+ });
16
+ (row.sequence_database_id_mismatches ?? []).forEach((mismatch) => {
17
+ issues.push(
18
+ `Sequence ${mismatch.sequence_id} database_id mismatch: ${formatMismatchKind(mismatch.kind)} (provided ${mismatch.provided_database_id})`,
19
+ );
20
+ });
21
+ if (row.already_synced) {
22
+ issues.push('Already synced to this workspace');
23
+ }
24
+ return issues;
25
+ }
26
+
27
+ export function cloningStrategyRowStatus(row) {
28
+ if ((row.parsing_errors ?? []).length > 0) {
29
+ return 'error';
30
+ }
31
+ if (row.already_synced) {
32
+ return 'info';
33
+ }
34
+ if ((row.parsing_warnings ?? []).length > 0) {
35
+ return 'warning';
36
+ }
37
+ if ((row.primer_database_id_mismatches ?? []).length > 0) {
38
+ return 'warning';
39
+ }
40
+ if ((row.sequence_database_id_mismatches ?? []).length > 0) {
41
+ return 'warning';
42
+ }
43
+ return 'clear';
44
+ }
45
+
46
+ export function getCloningStrategyRowsInfo(rows) {
47
+ const errorRows = rows.filter((row) => cloningStrategyRowStatus(row) === 'error');
48
+ const warningRows = rows.filter((row) => cloningStrategyRowStatus(row) === 'warning');
49
+ const infoRows = rows.filter((row) => cloningStrategyRowStatus(row) === 'info');
50
+ const clearRows = rows.filter((row) => cloningStrategyRowStatus(row) === 'clear');
51
+ const orderedRows = [...errorRows, ...warningRows, ...infoRows, ...clearRows];
52
+ const clearAndWarningRows = [...warningRows, ...clearRows];
53
+ return {
54
+ errorRows,
55
+ warningRows,
56
+ infoRows,
57
+ clearRows,
58
+ orderedRows,
59
+ clearAndWarningRows,
60
+ clearRowsCount: clearRows.length,
61
+ warningRowsCount: warningRows.length,
62
+ infoRowsCount: infoRows.length,
63
+ errorRowsCount: errorRows.length,
64
+ totalRowsCount: rows.length,
65
+ };
66
+ }
67
+
68
+ /** The rows that can be submitted (not already synced, with a cloning strategy), in the format of the submit endpoint. */
69
+ export function rowsToCloningStrategySyncResults(rows) {
70
+ return rows
71
+ .filter((row) => !row.already_synced && row.cloning_strategy)
72
+ .map(({ file_name: fileName, cloning_strategy: cloningStrategy }) => ({
73
+ ['file_name']: fileName,
74
+ ['cloning_strategy']: cloningStrategy,
75
+ }));
76
+ }
@@ -0,0 +1,85 @@
1
+ import { cloningStrategyRowIssues, cloningStrategyRowStatus, getCloningStrategyRowsInfo, rowsToCloningStrategySyncResults } from './bulk_upload_utils';
2
+
3
+ const clearRow = { file_name: 'clear.json', cloning_strategy: {} };
4
+ const errorRow = { file_name: 'error.json', parsing_errors: ['Cloning strategy is not valid JSON'] };
5
+ const syncedRow = { file_name: 'synced.json', cloning_strategy: {}, already_synced: true };
6
+ const parsingWarningRow = { file_name: 'warning.json', cloning_strategy: {}, parsing_warnings: ['old schema'] };
7
+ const primerMismatchRow = {
8
+ file_name: 'primer.json',
9
+ cloning_strategy: {},
10
+ primer_database_id_mismatches: [{ primer_id: 3, kind: 'not_in_database', provided_database_id: 7 }],
11
+ };
12
+ const sequenceMismatchRow = {
13
+ file_name: 'sequence.json',
14
+ cloning_strategy: {},
15
+ sequence_database_id_mismatches: [{ sequence_id: 2, kind: 'different_sequence', provided_database_id: 10 }],
16
+ };
17
+
18
+ describe('cloningStrategyRowStatus', () => {
19
+ it('returns the status of each kind of row', () => {
20
+ expect(cloningStrategyRowStatus(clearRow)).toBe('clear');
21
+ expect(cloningStrategyRowStatus(errorRow)).toBe('error');
22
+ expect(cloningStrategyRowStatus(syncedRow)).toBe('info');
23
+ expect(cloningStrategyRowStatus(parsingWarningRow)).toBe('warning');
24
+ expect(cloningStrategyRowStatus(primerMismatchRow)).toBe('warning');
25
+ expect(cloningStrategyRowStatus(sequenceMismatchRow)).toBe('warning');
26
+ });
27
+
28
+ it('gives precedence to errors, then already synced, then warnings', () => {
29
+ expect(cloningStrategyRowStatus({ ...syncedRow, ...parsingWarningRow, parsing_errors: ['bad'] })).toBe('error');
30
+ expect(cloningStrategyRowStatus({ ...parsingWarningRow, ...primerMismatchRow, already_synced: true })).toBe('info');
31
+ });
32
+ });
33
+
34
+ describe('cloningStrategyRowIssues', () => {
35
+ it('returns no issues for a clear row', () => {
36
+ expect(cloningStrategyRowIssues(clearRow)).toEqual([]);
37
+ });
38
+
39
+ it('lists errors, warnings, mismatches and sync state', () => {
40
+ const row = {
41
+ ...primerMismatchRow,
42
+ ...sequenceMismatchRow,
43
+ parsing_errors: ['error 1'],
44
+ parsing_warnings: ['warning 1'],
45
+ already_synced: true,
46
+ };
47
+ expect(cloningStrategyRowIssues(row)).toEqual([
48
+ 'error 1',
49
+ 'warning 1',
50
+ 'Primer 3 database_id mismatch: not in database (provided 7)',
51
+ 'Sequence 2 database_id mismatch: different sequence (provided 10)',
52
+ 'Already synced to this workspace',
53
+ ]);
54
+ });
55
+ });
56
+
57
+ describe('getCloningStrategyRowsInfo', () => {
58
+ it('orders rows (errors, warnings, info, clear) and counts them', () => {
59
+ const rows = [clearRow, syncedRow, parsingWarningRow, errorRow, sequenceMismatchRow];
60
+ const info = getCloningStrategyRowsInfo(rows);
61
+ expect(info.orderedRows).toEqual([errorRow, parsingWarningRow, sequenceMismatchRow, syncedRow, clearRow]);
62
+ expect(info.clearRows).toEqual([clearRow]);
63
+ expect(info.clearAndWarningRows).toEqual([parsingWarningRow, sequenceMismatchRow, clearRow]);
64
+ expect(info.errorRowsCount).toBe(1);
65
+ expect(info.warningRowsCount).toBe(2);
66
+ expect(info.infoRowsCount).toBe(1);
67
+ expect(info.clearRowsCount).toBe(1);
68
+ expect(info.totalRowsCount).toBe(5);
69
+ });
70
+ });
71
+
72
+ describe('rowsToCloningStrategySyncResults', () => {
73
+ it('keeps the rows with a strategy that are not synced, with only file_name and cloning_strategy', () => {
74
+ const rows = [clearRow, syncedRow, parsingWarningRow, errorRow, sequenceMismatchRow];
75
+ expect(rowsToCloningStrategySyncResults(rows)).toEqual([
76
+ { file_name: 'clear.json', cloning_strategy: {} },
77
+ { file_name: 'warning.json', cloning_strategy: {} },
78
+ { file_name: 'sequence.json', cloning_strategy: {} },
79
+ ]);
80
+ });
81
+
82
+ it('returns an empty array if nothing can be submitted', () => {
83
+ expect(rowsToCloningStrategySyncResults([syncedRow, errorRow])).toEqual([]);
84
+ });
85
+ });
@@ -0,0 +1,28 @@
1
+ import { openCloningDBHttpClient } from '../common';
2
+ import endpoints from '../endpoints';
3
+
4
+ /**
5
+ * Validate cloning strategy files (OpenCloning JSON or SnapGene .dna) against the database.
6
+ * files: array of File. Resolves to the validation rows, one per file.
7
+ */
8
+ export async function validateCloningStrategyFiles(files) {
9
+ const formData = new FormData();
10
+ files.forEach((file) => {
11
+ formData.append('files', file);
12
+ });
13
+ const { data } = await openCloningDBHttpClient.post(endpoints.sequencesCloningStrategyBulkValidate, formData, {
14
+ headers: { 'Content-Type': 'multipart/form-data' },
15
+ });
16
+ return data;
17
+ }
18
+
19
+ /**
20
+ * Submit validated cloning strategies. syncResults as returned by rowsToCloningStrategySyncResults,
21
+ * tags: tag ids to apply to the new sequences and primers. Resolves to the sequence refs of the strategies.
22
+ */
23
+ export async function submitCloningStrategies({ syncResults, tags }) {
24
+ const { data } = await openCloningDBHttpClient.post(endpoints.sequencesCloningStrategyBulk, syncResults, {
25
+ params: tags?.length ? { tags } : {},
26
+ });
27
+ return data;
28
+ }
@@ -0,0 +1,142 @@
1
+ import React from 'react';
2
+ import { Typography, TableContainer, Paper, Button, CircularProgress, Box, TableRow, TableCell, FormControl } from '@mui/material';
3
+ import TagMultiSelect from '../TagMultiSelect';
4
+ import { Cancel as CancelIcon, CheckCircle as CheckCircleIcon, Info as InfoIcon, Warning as WarningIcon } from '@mui/icons-material';
5
+
6
+
7
+ /**
8
+ * Wraps a bulk upload preview table with the summary and the import / cancel buttons.
9
+ * The tag field is shown if onBulkTagsChange is passed, onTagCreateError receives the error if creating a tag fails.
10
+ */
11
+ export function BulkUploadPreviewTableWrapper({
12
+ rowsInfo,
13
+ handleSubmit,
14
+ handleCancel,
15
+ isSubmitting,
16
+ children,
17
+ bulkTags = [],
18
+ onBulkTagsChange,
19
+ onTagCreateError,
20
+ importMode = 'clearAndWarnings',
21
+ }) {
22
+ const { clearRows, clearAndWarningRows, warningRowsCount, errorRowsCount, infoRowsCount = 0, orderedRows } = rowsInfo;
23
+ const allClearRequired = importMode === 'allClearRequired';
24
+ const canImportAll = clearRows.length === orderedRows.length && orderedRows.length > 0;
25
+ const summaryText = allClearRequired
26
+ ? `${clearRows.length} clear, ${errorRowsCount} error${infoRowsCount ? `, ${infoRowsCount} informational` : ''} out of ${orderedRows.length} uploaded items.`
27
+ : `${clearRows.length} clear, ${warningRowsCount} warning, ${errorRowsCount} error${infoRowsCount ? `, ${infoRowsCount} informational` : ''} out of ${orderedRows.length} uploaded items.`;
28
+ const orderingText = allClearRequired
29
+ ? 'All rows must be clear to import. Errors are shown first.'
30
+ : infoRowsCount > 0
31
+ ? 'Errors are shown first, then warnings, informational rows, and clear rows.'
32
+ : 'Errors are shown first, then warnings, then clear rows.';
33
+
34
+ const clearButtonText = warningRowsCount === 0 ? 'Import' : 'Import entries without warnings';
35
+ return (
36
+ <>
37
+ {onBulkTagsChange && (
38
+ <FormControl fullWidth sx={{ mb: 2 }}>
39
+ <TagMultiSelect
40
+ label="Tags to apply (optional)"
41
+ value={bulkTags}
42
+ onChange={onBulkTagsChange}
43
+ onCreateError={onTagCreateError}
44
+ data-testid="bulk-upload-tags"
45
+ />
46
+ </FormControl>
47
+ )}
48
+ <Typography variant="body2" sx={{ mb: 2 }}>
49
+ {orderingText}
50
+ </Typography>
51
+
52
+ <TableContainer
53
+ component={Paper}
54
+ variant="outlined"
55
+ sx={{ mb: 2, maxHeight: '55vh', overflowY: 'auto', flex: 1, minHeight: 0 }}
56
+ >
57
+ {children}
58
+ </TableContainer>
59
+
60
+ <Typography variant="body2" sx={{ mb: 2 }}>
61
+ {summaryText}
62
+ </Typography>
63
+
64
+ <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
65
+ <Button
66
+ variant="outlined"
67
+ color="error"
68
+ onClick={handleCancel}
69
+ disabled={isSubmitting}
70
+ >
71
+ Cancel
72
+ </Button>
73
+ {allClearRequired ? (
74
+ <Button
75
+ variant="contained"
76
+ onClick={() => handleSubmit(orderedRows)}
77
+ disabled={!canImportAll || isSubmitting}
78
+ data-testid="bulk-upload-import-button"
79
+ >
80
+ {isSubmitting ? <CircularProgress size={24} /> : 'Import'}
81
+ </Button>
82
+ ) : (
83
+ <>
84
+ <Button
85
+ variant="contained"
86
+ onClick={() => handleSubmit(clearRows, 'clear')}
87
+ disabled={clearRows.length < 1 || isSubmitting}
88
+ >
89
+ {isSubmitting ? <CircularProgress size={24} /> : clearButtonText}
90
+ </Button>
91
+ {warningRowsCount > 0 && (
92
+ <Button
93
+ variant="contained"
94
+ color="warning"
95
+ onClick={() => handleSubmit(clearAndWarningRows, 'with warnings')}
96
+ disabled={clearAndWarningRows.length < 1 || isSubmitting}
97
+ >
98
+ {isSubmitting ? <CircularProgress size={24} /> : 'Import entries with warnings too'}
99
+ </Button>
100
+ )}
101
+ </>
102
+ )}
103
+ </Box>
104
+ </>
105
+ );
106
+ }
107
+
108
+ export function StatusIcon({ status }) {
109
+ const isClear = status === 'clear';
110
+ const isError = status === 'error';
111
+ const isInfo = status === 'info';
112
+
113
+ return isClear ? (
114
+ <CheckCircleIcon color="success" />
115
+ ) : isError ? (
116
+ <CancelIcon color="error" />
117
+ ) : isInfo ? (
118
+ <InfoIcon color="info" />
119
+ ) : (
120
+ <WarningIcon color="warning" />
121
+ );
122
+ }
123
+
124
+
125
+ export function CommonTableRow({ status, issues, children }) {
126
+
127
+ return (
128
+ <TableRow>
129
+ <TableCell align="center" padding="checkbox">
130
+ <StatusIcon status={status} />
131
+ </TableCell>
132
+ {children}
133
+ <TableCell>
134
+ {status !== 'clear' && (
135
+ issues.map((issue) => (
136
+ <div key={issue}>{issue}</div>
137
+ ))
138
+ )}
139
+ </TableCell>
140
+ </TableRow>
141
+ );
142
+ }
package/src/endpoints.js CHANGED
@@ -6,6 +6,7 @@ const endpoints = {
6
6
  sequences: '/sequences',
7
7
  sequence: (id) => `/sequences/${e(id)}`,
8
8
  sequenceTextFile: (id) => `/sequences/${e(id)}/text_file_sequence`,
9
+ sequenceTextFiles: '/sequences/text_file_sequences',
9
10
  sequenceChangeAnnotation: (id) => `/sequences/${e(id)}/change_annotation`,
10
11
  sequenceCloningStrategy: (id) => `/sequences/${e(id)}/cloning_strategy`,
11
12
  sequenceChildren: (id) => `/sequences/${e(id)}/children`,
package/src/index.js CHANGED
@@ -2,3 +2,8 @@ export { default as OpenCloningDBInterface } from './OpenCloningDBInterface.js';
2
2
  export { openCloningDBHttpClient, setUnauthorizedHandler, setHttpClientTokenGetter, setWorkspaceHeader, clearWorkspaceHeader, baseUrl, formatSequenceName } from './common.js';
3
3
  export { default as endpoints } from './endpoints.js';
4
4
  export { default as SequenceSelect } from './SequenceSelect.jsx';
5
+ export { default as TagMultiSelect } from './TagMultiSelect.jsx';
6
+ export { default as SubmitCloningStrategiesInBulkComponent } from './SubmitCloningStrategiesInBulkComponent.jsx';
7
+ export { default as BulkUploadPreview } from './bulk_upload/BulkUploadPreview.jsx';
8
+ export { default as BulkUploadModal } from './bulk_upload/BulkUploadModal.jsx';
9
+ export { CommonTableRow } from './bulk_upload/common.jsx';
@@ -0,0 +1,21 @@
1
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
2
+ import { openCloningDBHttpClient } from './common';
3
+ import endpoints from './endpoints';
4
+
5
+ /**
6
+ * Mutation to create a tag, called with the tag name, resolves to the created tag.
7
+ * onError receives the error if the creation fails.
8
+ */
9
+ export default function useCreateTagMutation({ onError } = {}) {
10
+ const queryClient = useQueryClient();
11
+ return useMutation({
12
+ mutationFn: async (tagName) => {
13
+ const { data } = await openCloningDBHttpClient.post(endpoints.tagPost, { name: tagName });
14
+ return data;
15
+ },
16
+ onSuccess: () => {
17
+ queryClient.invalidateQueries({ queryKey: ['tags'] });
18
+ },
19
+ onError,
20
+ });
21
+ }