@opencloning/opencloningdb 1.8.3 → 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 +82 -0
- package/package.json +4 -4
- package/src/GetSequencesInBulkComponent.cy.jsx +116 -0
- package/src/GetSequencesInBulkComponent.jsx +74 -0
- package/src/OpenCloningDBInterface.js +8 -0
- package/src/OpenCloningDBInterface.multipart.test.js +2 -0
- package/src/OpenCloningDBInterface.test.js +2 -0
- package/src/SubmitCloningStrategiesInBulkComponent.cy.jsx +179 -0
- package/src/SubmitCloningStrategiesInBulkComponent.jsx +105 -0
- package/src/TagMultiSelect.cy.jsx +88 -0
- package/src/TagMultiSelect.jsx +61 -0
- package/src/bulk_upload/BulkUploadModal.jsx +40 -0
- package/src/bulk_upload/BulkUploadPreview.jsx +41 -0
- package/src/bulk_upload/CloningStrategyBulkUploadPreviewTable.jsx +103 -0
- package/src/bulk_upload/bulk_upload_utils.js +76 -0
- package/src/bulk_upload/bulk_upload_utils.test.js +85 -0
- package/src/bulk_upload/cloning_strategy_bulk_api.js +28 -0
- package/src/bulk_upload/common.jsx +142 -0
- package/src/common.js +7 -2
- package/src/common.test.js +8 -7
- package/src/endpoints.js +28 -21
- package/src/endpoints.test.js +17 -0
- package/src/index.js +6 -1
- package/src/testUtils.test.js +3 -3
- package/src/useCreateTagMutation.js +21 -0
|
@@ -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
|
+
}
|