@opencloning/opencloningdb 1.9.1 → 2.2.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/package.json CHANGED
@@ -1,23 +1,24 @@
1
1
  {
2
2
  "license": "MIT",
3
3
  "name": "@opencloning/opencloningdb",
4
- "version": "1.9.1",
4
+ "version": "2.2.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
8
- ".": "./src/index.js"
8
+ ".": "./src/index.js",
9
+ "./sequenceTypes": "./src/sequenceTypes.js"
9
10
  },
10
11
  "repository": {
11
12
  "type": "git",
12
- "url": "https://github.com/OpenCloning/OpenCloning_frontend.git",
13
- "directory": "packages/opencloningdb"
13
+ "url": "https://github.com/OpenCloning/OpenCloning.git",
14
+ "directory": "js/packages/opencloningdb"
14
15
  },
15
16
  "dependencies": {
16
17
  "@mui/icons-material": "^5.18.0",
17
18
  "@mui/material": "^5.18.0",
18
- "@opencloning/ui": "2.1.1",
19
- "@opencloning/utils": "2.1.1",
20
- "axios": "^1.20.0"
19
+ "@opencloning/ui": "2.2.0",
20
+ "@opencloning/utils": "2.2.0",
21
+ "axios": "^1.21.0"
21
22
  },
22
23
  "peerDependencies": {
23
24
  "react": "^18.3.1",
@@ -101,4 +101,3 @@ describe('<GetSequenceFileAndDatabaseIdComponent />', () => {
101
101
 
102
102
  });
103
103
  });
104
-
@@ -13,6 +13,7 @@ function expectLoadedSequences(spy, stubBody) {
13
13
  expect(sequences).to.deep.equal(stubBody.map(({ sequence_ref, sequence }) => ({
14
14
  databaseId: sequence_ref.id,
15
15
  name: formatSequenceName(sequence_ref),
16
+ seguid: sequence_ref.seguid,
16
17
  textFileSequence: sequence,
17
18
  })));
18
19
  }
@@ -20,11 +21,12 @@ function expectLoadedSequences(spy, stubBody) {
20
21
  describe('<GetSequencesInBulkComponent />', () => {
21
22
  beforeEach(() => {
22
23
  cy.setupOpenCloningDBTestAuth();
24
+ // TagMultiSelect fetches the tags on mount, in every test
25
+ cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
23
26
  });
24
27
 
25
28
  it('loads all sequences with a tag', () => {
26
29
  const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
27
- cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
28
30
  cy.interceptOpenCloningDBStub('get_text_file_sequences_by_tag', { alias: 'getSequences' });
29
31
 
30
32
  cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
@@ -41,6 +43,56 @@ describe('<GetSequencesInBulkComponent />', () => {
41
43
  });
42
44
  });
43
45
 
46
+ it('loads only the sequences of the selected types', () => {
47
+ const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
48
+ cy.interceptOpenCloningDBStub('get_text_file_sequences_by_tag_plasmids', { alias: 'getSequences' });
49
+
50
+ cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
51
+
52
+ cy.wait('@getTags');
53
+ clickMultiSelectOption('Tags', TAG_NAME, 'div');
54
+ cy.get('input[placeholder="All types"]').should('exist');
55
+ clickMultiSelectOption('Sequence types', 'Plasmid', 'div');
56
+ cy.contains('.MuiChip-root', 'Plasmid').should('exist');
57
+ cy.contains('button', 'Load sequences').click();
58
+ cy.wait('@getSequences');
59
+
60
+ cy.getStub('get_text_file_sequences_by_tag_plasmids').then((stub) => {
61
+ expect(stub.response.body).to.have.length.greaterThan(0);
62
+ stub.response.body.forEach(({ sequence_ref }) => expect(sequence_ref.sequence_type).to.equal('plasmid'));
63
+ cy.get('@onSequencesLoadedSpy').should((spy) => expectLoadedSequences(spy, stub.response.body));
64
+ });
65
+ });
66
+
67
+ it('uses defaultSequenceTypes as the initial type filter, which can be cleared', () => {
68
+ const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
69
+ cy.interceptOpenCloningDBStub('get_text_file_sequences_by_tag_plasmids', { alias: 'getPlasmids' });
70
+
71
+ cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} defaultSequenceTypes={['plasmid']} />);
72
+
73
+ cy.wait('@getTags');
74
+ clickMultiSelectOption('Tags', TAG_NAME, 'div');
75
+ cy.contains('button', 'Load sequences').click();
76
+ cy.wait('@getPlasmids');
77
+ cy.getStub('get_text_file_sequences_by_tag_plasmids').then((stub) => {
78
+ cy.get('@onSequencesLoadedSpy').should((spy) => expectLoadedSequences(spy, stub.response.body));
79
+ });
80
+
81
+ // Unselecting the default type loads sequences of all types
82
+ cy.interceptOpenCloningDBStub('get_text_file_sequences_by_tag', { alias: 'getAll' });
83
+ cy.contains('.MuiChip-root', 'Plasmid').find('.MuiChip-deleteIcon').click();
84
+ cy.get('input[placeholder="All types"]').should('exist');
85
+ cy.contains('button', 'Load sequences').click();
86
+ cy.wait('@getAll');
87
+ });
88
+
89
+ it('does not show the type filter when picking sequences', () => {
90
+ cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={cy.spy()} defaultSequenceTypes={['plasmid']} />);
91
+ cy.contains('label', 'Sequence types').should('exist');
92
+ cy.contains('button', 'Pick sequences').click();
93
+ cy.contains('label', 'Sequence types').should('not.exist');
94
+ });
95
+
44
96
  it('loads the picked sequences', () => {
45
97
  const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
46
98
  cy.interceptOpenCloningDBStub('get_sequences_search_by_name', { alias: 'searchByName' });
@@ -64,7 +116,6 @@ describe('<GetSequencesInBulkComponent />', () => {
64
116
 
65
117
  it('shows the error returned by the server', () => {
66
118
  const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
67
- cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
68
119
  cy.intercept('GET', `**${endpoints.sequenceTextFiles}*`, {
69
120
  statusCode: 400,
70
121
  body: { detail: 'Too many sequences (300), the maximum is 200.' },
@@ -82,7 +133,6 @@ describe('<GetSequencesInBulkComponent />', () => {
82
133
 
83
134
  it('shows a message when no sequences have the tag', () => {
84
135
  const onSequencesLoadedSpy = cy.spy().as('onSequencesLoadedSpy');
85
- cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
86
136
  cy.intercept('GET', `**${endpoints.sequenceTextFiles}*`, { statusCode: 200, body: [] }).as('getSequences');
87
137
 
88
138
  cy.mount(<GetSequencesInBulkComponent onSequencesLoaded={onSequencesLoadedSpy} />);
@@ -91,7 +141,7 @@ describe('<GetSequencesInBulkComponent />', () => {
91
141
  clickMultiSelectOption('Tags', TAG_NAME, 'div');
92
142
  cy.contains('button', 'Load sequences').click();
93
143
  cy.wait('@getSequences');
94
- cy.contains('No sequences found with the selected tags.').should('exist');
144
+ cy.contains('No sequences found with the selected tags and types.').should('exist');
95
145
  cy.get('@onSequencesLoadedSpy').should('not.have.been.called');
96
146
  });
97
147
 
@@ -110,7 +160,7 @@ describe('<GetSequencesInBulkComponent />', () => {
110
160
  cy.contains('button', 'Load sequences').click();
111
161
  cy.wait('@getSequences');
112
162
  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');
163
+ cy.contains('No sequences found with the selected tags and types.').should('not.exist');
114
164
  cy.get('@onSequencesLoadedSpy').should('not.have.been.called');
115
165
  });
116
166
  });
@@ -4,17 +4,21 @@ import { Alert, Button, CircularProgress, FormControl, Stack, ToggleButton, Togg
4
4
  import SequenceSelect from './SequenceSelect';
5
5
  import TagMultiSelect from './TagMultiSelect';
6
6
  import { formatSequenceName, openCloningDBHttpClient } from './common';
7
+ import SequenceTypeSelect from './SequenceTypeSelect';
7
8
  import endpoints from './endpoints';
8
9
 
9
10
  /**
10
11
  * Load several sequences from OpenCloningDB, either all the sequences with any of the selected tags,
11
12
  * or the sequences picked by the user. Template sequences are not included (they have no content).
13
+ * When loading by tag, the sequences can be restricted to some sequence types (none selected means all types),
14
+ * defaultSequenceTypes sets the initial selection (e.g. ['plasmid']).
12
15
  *
13
- * onSequencesLoaded receives an array of { databaseId, name, textFileSequence }.
16
+ * onSequencesLoaded receives an array of { databaseId, name, seguid, textFileSequence }.
14
17
  */
15
- function GetSequencesInBulkComponent({ onSequencesLoaded }) {
18
+ function GetSequencesInBulkComponent({ onSequencesLoaded, defaultSequenceTypes = [] }) {
16
19
  const [mode, setMode] = React.useState('tags');
17
20
  const [selectedTags, setSelectedTags] = React.useState([]);
21
+ const [selectedSequenceTypes, setSelectedSequenceTypes] = React.useState(defaultSequenceTypes);
18
22
  const [selectedSequences, setSelectedSequences] = React.useState([]);
19
23
  const [emptyResult, setEmptyResult] = React.useState(false);
20
24
 
@@ -24,6 +28,7 @@ function GetSequencesInBulkComponent({ onSequencesLoaded }) {
24
28
  return data.map(({ sequence_ref, sequence }) => ({
25
29
  databaseId: sequence_ref.id,
26
30
  name: formatSequenceName(sequence_ref),
31
+ seguid: sequence_ref.seguid,
27
32
  textFileSequence: sequence,
28
33
  }));
29
34
  },
@@ -48,7 +53,9 @@ function GetSequencesInBulkComponent({ onSequencesLoaded }) {
48
53
  const onLoad = () => {
49
54
  setEmptyResult(false);
50
55
  // selectedTags are ids, selectedSequences are sequence objects
51
- mutate(mode === 'tags' ? { tags: selectedTags } : { ids: selectedSequences.map((item) => item.id) });
56
+ mutate(mode === 'tags'
57
+ ? { tags: selectedTags, sequence_types: selectedSequenceTypes }
58
+ : { ids: selectedSequences.map((item) => item.id) });
52
59
  };
53
60
 
54
61
  return (
@@ -62,8 +69,18 @@ function GetSequencesInBulkComponent({ onSequencesLoaded }) {
62
69
  ? <TagMultiSelect label="Tags" value={selectedTags} onChange={setSelectedTags} allowCreate={false} />
63
70
  : <SequenceSelect label="Sequences" value={selectedSequences} onChange={setSelectedSequences} />}
64
71
  </FormControl>
72
+ {mode === 'tags' && (
73
+ <SequenceTypeSelect
74
+ multiple
75
+ label="Sequence types"
76
+ placeholder="All types"
77
+ size="medium"
78
+ value={selectedSequenceTypes}
79
+ onChange={setSelectedSequenceTypes}
80
+ />
81
+ )}
65
82
  {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>}
83
+ {emptyResult && <Alert severity="info">{mode === 'tags' ? 'No sequences found with the selected tags and types.' : 'The selected sequences have no content (template sequences cannot be loaded).'}</Alert>}
67
84
  {isPending
68
85
  ? <CircularProgress sx={{ alignSelf: 'center' }} />
69
86
  : <Button variant="contained" disabled={selection.length === 0} onClick={onLoad}>Load sequences</Button>}
@@ -1,7 +1,8 @@
1
1
  import React from 'react';
2
2
  import LoadHistoryComponent from './LoadHistoryComponent';
3
3
 
4
- const DATABASE_ID = 10;
4
+ // The sequence id comes from the stub (/db/sequences/<id>/cloning_strategy), so it follows the seed data
5
+ const databaseIdFromStub = (stub) => Number(stub.endpoint.match(/\/db\/sequences\/(\d+)\//)[1]);
5
6
 
6
7
  describe('<LoadHistoryComponent />', () => {
7
8
  it('calls loadDatabaseFile with the file returned by the API', () => {
@@ -10,22 +11,23 @@ describe('<LoadHistoryComponent />', () => {
10
11
 
11
12
  cy.interceptOpenCloningDBStub('get_cloning_strategy', { alias: 'getCloningStrategy' });
12
13
 
13
- cy.mount(
14
- <LoadHistoryComponent
15
- databaseId={DATABASE_ID}
16
- loadDatabaseFile={loadDatabaseFileSpy}
17
- />,
18
- );
19
-
20
14
  cy.getStub('get_cloning_strategy').then((cloningStrategyStub) => {
15
+ const databaseId = databaseIdFromStub(cloningStrategyStub);
21
16
  const expectedContent = JSON.stringify(cloningStrategyStub.response.body);
22
17
 
18
+ cy.mount(
19
+ <LoadHistoryComponent
20
+ databaseId={databaseId}
21
+ loadDatabaseFile={loadDatabaseFileSpy}
22
+ />,
23
+ );
24
+
23
25
  cy.wait('@getCloningStrategy').its('request.url').should('include', cloningStrategyStub.endpoint);
24
26
  cy.get('@loadDatabaseFileSpy').should('have.been.calledOnce');
25
27
 
26
28
  cy.get('@loadDatabaseFileSpy').then((spy) => {
27
29
  const [file, id, flag] = spy.lastCall.args;
28
- expect(id).to.equal(DATABASE_ID);
30
+ expect(id).to.equal(databaseId);
29
31
  expect(flag).to.equal(true);
30
32
 
31
33
  return cy.readFileAsText(file).then((content) => {
@@ -47,14 +49,14 @@ describe('<LoadHistoryComponent />', () => {
47
49
  headers: cloningStrategyStub.response.headers,
48
50
  });
49
51
  }).as('getCloningStrategy');
50
- });
51
52
 
52
- cy.mount(
53
- <LoadHistoryComponent
54
- databaseId={DATABASE_ID}
55
- loadDatabaseFile={cy.stub()}
56
- />,
57
- );
53
+ cy.mount(
54
+ <LoadHistoryComponent
55
+ databaseId={databaseIdFromStub(cloningStrategyStub)}
56
+ loadDatabaseFile={cy.stub()}
57
+ />,
58
+ );
59
+ });
58
60
 
59
61
  cy.get('.MuiCircularProgress-svg', { timeout: 500 }).should('exist');
60
62
  cy.wait('@getCloningStrategy');
@@ -77,14 +79,14 @@ describe('<LoadHistoryComponent />', () => {
77
79
  });
78
80
  }
79
81
  }).as('getCloningStrategy');
80
- });
81
82
 
82
- cy.mount(
83
- <LoadHistoryComponent
84
- databaseId={DATABASE_ID}
85
- loadDatabaseFile={cy.stub()}
86
- />,
87
- );
83
+ cy.mount(
84
+ <LoadHistoryComponent
85
+ databaseId={databaseIdFromStub(cloningStrategyStub)}
86
+ loadDatabaseFile={cy.stub()}
87
+ />,
88
+ );
89
+ });
88
90
 
89
91
  cy.wait('@getCloningStrategy');
90
92
  cy.contains('Failed to load history file.').should('exist');
@@ -108,7 +108,9 @@ describe('getSequencingFiles', () => {
108
108
  addStubToServer(server, listStub);
109
109
  addStubToServer(server, downloadStub);
110
110
 
111
- const files = await OpenCloningDBInterface.getSequencingFiles(10);
111
+ // The sequence id comes from the stub (/db/sequences/<id>/sequencing_files), so it follows the seed data
112
+ const sequenceId = Number(listStub.endpoint.match(/\/db\/sequences\/(\d+)\//)[1]);
113
+ const files = await OpenCloningDBInterface.getSequencingFiles(sequenceId);
112
114
 
113
115
  expect(files).toHaveLength(1);
114
116
  expect(files[0].id).toBe(listStub.response.body[0].id);
@@ -0,0 +1,52 @@
1
+ import React from 'react';
2
+ import SequenceTypeSelect from './SequenceTypeSelect';
3
+ import { SEQUENCE_TYPE_LABELS } from './sequenceTypes';
4
+ import { clickMultiSelectOption } from '../../../cypress/e2e/common_functions';
5
+
6
+ describe('<SequenceTypeSelect />', () => {
7
+ it('single: shows all the types and calls onChange with the selected type', () => {
8
+ const onChangeSpy = cy.spy().as('onChangeSpy');
9
+ cy.mount(<SequenceTypeSelect value="locus" onChange={onChangeSpy} labelId="my-type-label" />);
10
+
11
+ cy.contains('label', 'Type').should('have.attr', 'id', 'my-type-label');
12
+ cy.contains('Locus').click();
13
+ cy.get('ul[aria-labelledby="my-type-label"] li').should('have.length', Object.keys(SEQUENCE_TYPE_LABELS).length);
14
+ cy.get('ul[aria-labelledby="my-type-label"] li').contains('PCR product').click();
15
+ cy.get('@onChangeSpy').should('have.been.calledOnceWith', 'pcr_product');
16
+ });
17
+
18
+ it('single: types restricts the options', () => {
19
+ cy.mount(<SequenceTypeSelect value="locus" onChange={cy.spy()} labelId="my-type-label" types={['locus', 'allele']} />);
20
+ cy.contains('Locus').click();
21
+ cy.get('ul[aria-labelledby="my-type-label"] li').should('have.length', 2);
22
+ cy.get('ul[aria-labelledby="my-type-label"] li').contains('Allele').should('exist');
23
+ });
24
+
25
+ it('multiple: adds and removes types, and shows the placeholder when empty', () => {
26
+ function Wrapper({ onChange }) {
27
+ const [value, setValue] = React.useState(['plasmid']);
28
+ return (
29
+ <SequenceTypeSelect
30
+ multiple
31
+ label="Sequence types"
32
+ placeholder="All types"
33
+ value={value}
34
+ onChange={(v) => { setValue(v); onChange(v); }}
35
+ />
36
+ );
37
+ }
38
+ const onChangeSpy = cy.spy().as('onChangeSpy');
39
+ cy.mount(<Wrapper onChange={onChangeSpy} />);
40
+
41
+ cy.contains('.MuiChip-root', 'Plasmid').should('exist');
42
+ cy.get('input[placeholder="All types"]').should('not.exist');
43
+
44
+ clickMultiSelectOption('Sequence types', 'Allele', 'div');
45
+ cy.get('@onChangeSpy').should('have.been.calledWith', ['plasmid', 'allele']);
46
+
47
+ cy.contains('.MuiChip-root', 'Plasmid').find('.MuiChip-deleteIcon').click();
48
+ cy.contains('.MuiChip-root', 'Allele').find('.MuiChip-deleteIcon').click();
49
+ cy.get('@onChangeSpy').should('have.been.calledWith', []);
50
+ cy.get('input[placeholder="All types"]').should('exist');
51
+ });
52
+ });
@@ -0,0 +1,56 @@
1
+ import React from 'react';
2
+ import { FormControl, InputLabel, MenuItem, Select } from '@mui/material';
3
+ import { ChipMultiSelect } from '@opencloning/ui';
4
+ import { SEQUENCE_TYPE_LABELS } from './sequenceTypes';
5
+
6
+ const ALL_SEQUENCE_TYPES = Object.keys(SEQUENCE_TYPE_LABELS);
7
+
8
+ /**
9
+ * Select of sequence types (e.g. 'plasmid'), shown with their labels.
10
+ *
11
+ * - multiple=false: value is a type and onChange receives a type.
12
+ * - multiple=true: value is an array of types and onChange receives an array of types.
13
+ * placeholder is shown when nothing is selected (e.g. 'All types' when used as a filter).
14
+ *
15
+ * types restricts the options (all types by default). labelId sets the id of the label,
16
+ * which the options list references with aria-labelledby (single select only).
17
+ */
18
+ function SequenceTypeSelect({
19
+ value, onChange, multiple = false, types = ALL_SEQUENCE_TYPES, label = 'Type', labelId, placeholder,
20
+ disabled, size = 'small', sx,
21
+ }) {
22
+ const generatedId = React.useId();
23
+ const id = labelId ?? `sequence-type-select-${generatedId}`;
24
+ const options = React.useMemo(
25
+ () => types.map((type) => ({ id: type, label: SEQUENCE_TYPE_LABELS[type] ?? type })),
26
+ [types],
27
+ );
28
+
29
+ if (multiple) {
30
+ return (
31
+ <ChipMultiSelect
32
+ label={label}
33
+ options={options}
34
+ value={value ?? []}
35
+ onChange={onChange}
36
+ placeholder={placeholder}
37
+ disabled={disabled}
38
+ size={size}
39
+ sx={sx}
40
+ />
41
+ );
42
+ }
43
+
44
+ return (
45
+ <FormControl size={size} sx={sx} disabled={disabled}>
46
+ <InputLabel id={id}>{label}</InputLabel>
47
+ <Select labelId={id} label={label} value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
48
+ {options.map((option) => (
49
+ <MenuItem key={option.id} value={option.id}>{option.label}</MenuItem>
50
+ ))}
51
+ </Select>
52
+ </FormControl>
53
+ );
54
+ }
55
+
56
+ export default SequenceTypeSelect;
@@ -34,6 +34,8 @@ function mountComponent() {
34
34
  describe('<SubmitCloningStrategiesInBulkComponent />', () => {
35
35
  beforeEach(() => {
36
36
  cy.setupOpenCloningDBTestAuth();
37
+ // TagMultiSelect fetches the tags on mount, in every test
38
+ cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
37
39
  });
38
40
 
39
41
  it('validates the files as multipart and shows the status of each one', () => {
@@ -62,7 +64,6 @@ describe('<SubmitCloningStrategiesInBulkComponent />', () => {
62
64
 
63
65
  it('submits the importable rows with the selected tags', () => {
64
66
  cy.intercept(validateUrl, { statusCode: 200, body: validationRows }).as('validate');
65
- cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
66
67
  cy.intercept(submitUrl, (req) => {
67
68
  req.reply({ statusCode: 200, body: createdSequences });
68
69
  }).as('submit');
@@ -38,4 +38,4 @@ export default function BulkUploadPreview({
38
38
  {renderTable(rowsInfo.orderedRows)}
39
39
  </BulkUploadPreviewTableWrapper>
40
40
  );
41
- }
41
+ }
@@ -4,6 +4,19 @@ import TagMultiSelect from '../TagMultiSelect';
4
4
  import { Cancel as CancelIcon, CheckCircle as CheckCircleIcon, Info as InfoIcon, Warning as WarningIcon } from '@mui/icons-material';
5
5
 
6
6
 
7
+ /** Scrollable container for the bulk tables, so that they fit in a BulkUploadModal. */
8
+ export function BulkTableContainer({ children }) {
9
+ return (
10
+ <TableContainer
11
+ component={Paper}
12
+ variant="outlined"
13
+ sx={{ mb: 2, maxHeight: '55vh', overflowY: 'auto', flex: 1, minHeight: 0 }}
14
+ >
15
+ {children}
16
+ </TableContainer>
17
+ );
18
+ }
19
+
7
20
  /**
8
21
  * Wraps a bulk upload preview table with the summary and the import / cancel buttons.
9
22
  * The tag field is shown if onBulkTagsChange is passed, onTagCreateError receives the error if creating a tag fails.
@@ -49,13 +62,9 @@ export function BulkUploadPreviewTableWrapper({
49
62
  {orderingText}
50
63
  </Typography>
51
64
 
52
- <TableContainer
53
- component={Paper}
54
- variant="outlined"
55
- sx={{ mb: 2, maxHeight: '55vh', overflowY: 'auto', flex: 1, minHeight: 0 }}
56
- >
65
+ <BulkTableContainer>
57
66
  {children}
58
- </TableContainer>
67
+ </BulkTableContainer>
59
68
 
60
69
  <Typography variant="body2" sx={{ mb: 2 }}>
61
70
  {summaryText}
@@ -104,13 +113,19 @@ export function BulkUploadPreviewTableWrapper({
104
113
  </>
105
114
  );
106
115
  }
107
-
116
+
108
117
  export function StatusIcon({ status }) {
118
+ if (!status) {
119
+ return null;
120
+ }
109
121
  const isClear = status === 'clear';
110
122
  const isError = status === 'error';
111
123
  const isInfo = status === 'info';
124
+ const isPending = status === 'pending';
112
125
 
113
- return isClear ? (
126
+ return isPending ? (
127
+ <CircularProgress size={20} data-testid="status-icon-pending" />
128
+ ) : isClear ? (
114
129
  <CheckCircleIcon color="success" />
115
130
  ) : isError ? (
116
131
  <CancelIcon color="error" />
@@ -122,10 +137,11 @@ export function StatusIcon({ status }) {
122
137
  }
123
138
 
124
139
 
125
- export function CommonTableRow({ status, issues, children }) {
140
+ /** Table row with a status icon cell, the children cells and an issues cell. Other props are passed to the TableRow. */
141
+ export function CommonTableRow({ status, issues = [], children, ...rowProps }) {
126
142
 
127
143
  return (
128
- <TableRow>
144
+ <TableRow {...rowProps}>
129
145
  <TableCell align="center" padding="checkbox">
130
146
  <StatusIcon status={status} />
131
147
  </TableCell>
package/src/endpoints.js CHANGED
@@ -14,7 +14,9 @@ const endpoints = {
14
14
  sequenceChangeCircularity: (id) => `/sequences/${e(id)}/change_circularity`,
15
15
  sequencePrimers: (id) => `/sequences/${e(id)}/primers`,
16
16
  sequenceSequencingFiles: (id) => `/sequences/${e(id)}/sequencing_files`,
17
+ sequencingFilesBulk: '/sequences/sequencing_files/bulk',
17
18
  sequenceSearch: '/sequences/search',
19
+ sequencesBySeguid: (seguid) => `/sequences/by-seguid/${e(seguid)}`,
18
20
  sequencesValidateUpload: '/sequences/validate-upload',
19
21
  sequencesBulk: '/sequences/bulk',
20
22
  sequencesCloningStrategyBulkValidate: '/sequences/cloning_strategy/bulk/validate',
package/src/index.js CHANGED
@@ -1,9 +1,13 @@
1
1
  export { default as OpenCloningDBInterface } from './OpenCloningDBInterface.js';
2
2
  export { openCloningDBHttpClient, setUnauthorizedHandler, setHttpClientTokenGetter, setWorkspaceHeader, clearWorkspaceHeader, baseUrl, formatSequenceName } from './common.js';
3
+ export { SEQUENCE_TYPE_LABELS } from './sequenceTypes.js';
3
4
  export { default as endpoints } from './endpoints.js';
4
5
  export { default as SequenceSelect } from './SequenceSelect.jsx';
5
6
  export { default as TagMultiSelect } from './TagMultiSelect.jsx';
7
+ export { default as SequenceTypeSelect } from './SequenceTypeSelect.jsx';
6
8
  export { default as SubmitCloningStrategiesInBulkComponent } from './SubmitCloningStrategiesInBulkComponent.jsx';
7
9
  export { default as BulkUploadPreview } from './bulk_upload/BulkUploadPreview.jsx';
8
10
  export { default as BulkUploadModal } from './bulk_upload/BulkUploadModal.jsx';
9
11
  export { CommonTableRow } from './bulk_upload/common.jsx';
12
+ export { default as SequencingCheckModal } from './sequencing_check/SequencingCheckModal.jsx';
13
+ export { MAX_CONCURRENT_CHECKS, runWithConcurrency } from './sequencing_check/sequencingCheckUtils.js';
@@ -0,0 +1,10 @@
1
+ // Display labels of the sequence types (SequenceType enum in the backend).
2
+ // Kept free of imports, so that it can be imported without loading the rest of the package.
3
+ export const SEQUENCE_TYPE_LABELS = {
4
+ locus: 'Locus',
5
+ allele: 'Allele',
6
+ plasmid: 'Plasmid',
7
+ pcr_product: 'PCR product',
8
+ restriction_fragment: 'Restriction fragment',
9
+ linear_dna: 'Linear DNA',
10
+ };
@@ -0,0 +1,49 @@
1
+ import React from 'react';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import { Alert, Box, CircularProgress, IconButton, Tooltip, Typography } from '@mui/material';
4
+ import { Close as CloseIcon } from '@mui/icons-material';
5
+ import SequenceViewer from '@opencloning/ui/components/SequenceViewer';
6
+ import { buildSequencingAlignmentConfig } from '@opencloning/utils/alignmentUtils';
7
+ import { convertToTeselaJson } from '@opencloning/utils/readNwrite';
8
+ import error2String from '@opencloning/utils/error2String';
9
+ import useSequencingQueries from './useSequencingQueries';
10
+
11
+ /**
12
+ * Alignment of one sequencing file to its sequence, shown in the SequenceViewer with the alignment tab selected.
13
+ * sequence is an item of the output of GetSequencesInBulkComponent, file a sequencing file of that sequence.
14
+ */
15
+ export default function SequencingAlignmentViewer({ sequence, file, onClose }) {
16
+ const sequenceData = React.useMemo(() => convertToTeselaJson(sequence.textFileSequence), [sequence]);
17
+
18
+ // Shared with the sequencing check, so files that were already aligned are not aligned again
19
+ const { alignmentQuery } = useSequencingQueries();
20
+ const { data, isPending, error } = useQuery(alignmentQuery(sequence, file));
21
+ const alignmentData = React.useMemo(() => data && buildSequencingAlignmentConfig(
22
+ sequence.textFileSequence,
23
+ sequenceData,
24
+ [{ fileName: file.name, fileContent: data.fileContent }],
25
+ data.alignedSequences,
26
+ ), [data, sequence, sequenceData, file.name]);
27
+
28
+ return (
29
+ <Box sx={{ mb: 2 }} data-testid="sequencing-alignment-viewer">
30
+ <Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
31
+ <Typography variant="subtitle1" sx={{ flex: 1, overflowWrap: 'anywhere' }}>
32
+ Alignment of <Box component="span" sx={{ fontFamily: 'monospace' }}>{file.name}</Box> to {sequence.name}
33
+ </Typography>
34
+ <Tooltip title="Close alignment">
35
+ <IconButton size="small" onClick={onClose} aria-label="Close alignment">
36
+ <CloseIcon />
37
+ </IconButton>
38
+ </Tooltip>
39
+ </Box>
40
+ {isPending && (
41
+ <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
42
+ <CircularProgress />
43
+ </Box>
44
+ )}
45
+ {error && <Alert severity="error">Could not align the sequencing file: {error2String(error)}</Alert>}
46
+ {alignmentData && <SequenceViewer sequenceData={sequenceData} alignmentData={alignmentData} readOnly />}
47
+ </Box>
48
+ );
49
+ }