@opencloning/opencloningdb 1.9.1 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -7
- package/src/GetSequenceFileAndDatabaseIdComponent.cy.jsx +0 -1
- package/src/GetSequencesInBulkComponent.cy.jsx +55 -5
- package/src/GetSequencesInBulkComponent.jsx +21 -4
- package/src/LoadHistoryComponent.cy.jsx +25 -23
- package/src/OpenCloningDBInterface.test.js +3 -1
- package/src/SequenceTypeSelect.cy.jsx +52 -0
- package/src/SequenceTypeSelect.jsx +56 -0
- package/src/SubmitCloningStrategiesInBulkComponent.cy.jsx +2 -1
- package/src/bulk_upload/BulkUploadPreview.jsx +1 -1
- package/src/bulk_upload/common.jsx +26 -10
- package/src/endpoints.js +2 -0
- package/src/index.js +4 -0
- package/src/sequenceTypes.js +10 -0
- package/src/sequencing_check/SequencingAlignmentViewer.jsx +49 -0
- package/src/sequencing_check/SequencingCheckModal.cy.jsx +258 -0
- package/src/sequencing_check/SequencingCheckModal.jsx +90 -0
- package/src/sequencing_check/SequencingCheckTable.jsx +112 -0
- package/src/sequencing_check/sequencingCheckUtils.js +134 -0
- package/src/sequencing_check/sequencingCheckUtils.test.js +143 -0
- package/src/sequencing_check/useSequencingCheck.js +114 -0
- package/src/sequencing_check/useSequencingQueries.js +51 -0
- package/src/testUtils.test.js +2 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
buildSortedRows,
|
|
4
|
+
fileRowStatus,
|
|
5
|
+
formatIdentity,
|
|
6
|
+
percentIdentity,
|
|
7
|
+
runWithConcurrency,
|
|
8
|
+
sequenceRowStatus,
|
|
9
|
+
} from './sequencingCheckUtils';
|
|
10
|
+
|
|
11
|
+
describe('percentIdentity', () => {
|
|
12
|
+
it('returns 100 for identical sequences, ignoring case', () => {
|
|
13
|
+
expect(percentIdentity('ACGTACGTAC', 'acgtACGTac')).toBe(100);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('counts mismatches', () => {
|
|
17
|
+
expect(percentIdentity('ACGTACGTAC', 'ACGTTCGTAC')).toBe(90);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('counts internal and terminal gaps as mismatches', () => {
|
|
21
|
+
expect(percentIdentity('ACGTACGTAC', 'ACG-ACGTAC')).toBe(90);
|
|
22
|
+
expect(percentIdentity('ACGTACGTAC', '--GTACGTAC')).toBe(80);
|
|
23
|
+
expect(percentIdentity('ACGT-CGTAC', 'ACGTACGTAC')).toBe(90);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('ignores columns where both sequences have a gap', () => {
|
|
27
|
+
expect(percentIdentity('AC--GT', 'AC--GT')).toBe(100);
|
|
28
|
+
expect(percentIdentity('AC--GT', 'AC--GA')).toBe(75);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns 0 for empty alignments', () => {
|
|
32
|
+
expect(percentIdentity('', '')).toBe(0);
|
|
33
|
+
expect(percentIdentity('--', '--')).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('throws if the aligned sequences have different lengths', () => {
|
|
37
|
+
expect(() => percentIdentity('ACGT', 'ACG')).toThrow();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('formatIdentity', () => {
|
|
42
|
+
it('uses 2 decimals and never rounds up to 100', () => {
|
|
43
|
+
expect(formatIdentity(100)).toBe('100.00');
|
|
44
|
+
expect(formatIdentity(99.999)).toBe('99.99');
|
|
45
|
+
expect(formatIdentity(87.5)).toBe('87.50');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('runWithConcurrency', () => {
|
|
50
|
+
it('runs all items, never more than limit at a time, and keeps the order of results', async () => {
|
|
51
|
+
let active = 0;
|
|
52
|
+
let maxActive = 0;
|
|
53
|
+
const items = [5, 1, 8, 2, 7, 3, 6, 4, 9];
|
|
54
|
+
const results = await runWithConcurrency(items, 4, async (item) => {
|
|
55
|
+
active++;
|
|
56
|
+
maxActive = Math.max(maxActive, active);
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, item));
|
|
58
|
+
active--;
|
|
59
|
+
return item * 2;
|
|
60
|
+
});
|
|
61
|
+
expect(maxActive).toBe(4);
|
|
62
|
+
expect(results).toEqual(items.map((item) => item * 2));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('works with fewer items than the limit, and with no items', async () => {
|
|
66
|
+
expect(await runWithConcurrency([1], 4, async (item) => item)).toEqual([1]);
|
|
67
|
+
expect(await runWithConcurrency([], 4, async (item) => item)).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('fileRowStatus and sequenceRowStatus', () => {
|
|
72
|
+
it('maps file results to StatusIcon statuses', () => {
|
|
73
|
+
expect(fileRowStatus(undefined)).toBe(null);
|
|
74
|
+
expect(fileRowStatus({ status: 'pending' })).toBe('pending');
|
|
75
|
+
expect(fileRowStatus({ status: 'error', message: 'x' })).toBe('error');
|
|
76
|
+
expect(fileRowStatus({ status: 'match', identity: 100 })).toBe('clear');
|
|
77
|
+
expect(fileRowStatus({ status: 'aligned', identity: 100 })).toBe('clear');
|
|
78
|
+
expect(fileRowStatus({ status: 'aligned', identity: 99.5 })).toBe('warning');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('uses the worst status of the files, or info if there are no files', () => {
|
|
82
|
+
const files = [{ id: 1 }, { id: 2 }];
|
|
83
|
+
expect(sequenceRowStatus([], {})).toBe('info');
|
|
84
|
+
expect(sequenceRowStatus(files, {})).toBe(null);
|
|
85
|
+
expect(sequenceRowStatus(files, { 1: { status: 'match', identity: 100 }, 2: { status: 'match', identity: 100 } })).toBe('clear');
|
|
86
|
+
expect(sequenceRowStatus(files, { 1: { status: 'match', identity: 100 }, 2: { status: 'pending' } })).toBe('pending');
|
|
87
|
+
expect(sequenceRowStatus(files, { 1: { status: 'aligned', identity: 90 }, 2: { status: 'pending' } })).toBe('warning');
|
|
88
|
+
expect(sequenceRowStatus(files, { 1: { status: 'aligned', identity: 90 }, 2: { status: 'error' } })).toBe('error');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('buildSortedRows', () => {
|
|
93
|
+
const sequences = [
|
|
94
|
+
{ databaseId: 1, name: 'perfect' },
|
|
95
|
+
{ databaseId: 2, name: 'no files' },
|
|
96
|
+
{ databaseId: 3, name: 'low' },
|
|
97
|
+
{ databaseId: 4, name: 'loading' },
|
|
98
|
+
{ databaseId: 5, name: 'error' },
|
|
99
|
+
{ databaseId: 6, name: 'not checked' },
|
|
100
|
+
];
|
|
101
|
+
const filesBySequenceId = {
|
|
102
|
+
1: [{ id: 10, name: 'a.fa' }],
|
|
103
|
+
2: [],
|
|
104
|
+
3: [{ id: 30, name: 'high.fa' }, { id: 31, name: 'low.fa' }],
|
|
105
|
+
// 4 is still loading
|
|
106
|
+
5: [{ id: 50, name: 'ok.fa' }, { id: 51, name: 'broken.fa' }],
|
|
107
|
+
6: [{ id: 60, name: 'b.fa' }],
|
|
108
|
+
};
|
|
109
|
+
const results = {
|
|
110
|
+
10: { status: 'match', identity: 100 },
|
|
111
|
+
30: { status: 'aligned', identity: 99 },
|
|
112
|
+
31: { status: 'aligned', identity: 80 },
|
|
113
|
+
50: { status: 'aligned', identity: 100 },
|
|
114
|
+
51: { status: 'error', message: 'Boom' },
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
it('sorts sequences: errors, lowest identity first, not checked or loading, no files last', () => {
|
|
118
|
+
const rows = buildSortedRows(sequences, filesBySequenceId, results);
|
|
119
|
+
expect(rows.map((row) => row.sequence.name)).toEqual(['error', 'low', 'perfect', 'loading', 'not checked', 'no files']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('sorts the files within each sequence and sets the identity of the sequence to the minimum', () => {
|
|
123
|
+
const rows = buildSortedRows(sequences, filesBySequenceId, results);
|
|
124
|
+
const low = rows.find((row) => row.sequence.name === 'low');
|
|
125
|
+
expect(low.files.map(({ file }) => file.name)).toEqual(['low.fa', 'high.fa']);
|
|
126
|
+
expect(low.identity).toBe(80);
|
|
127
|
+
expect(low.status).toBe('warning');
|
|
128
|
+
const error = rows.find((row) => row.sequence.name === 'error');
|
|
129
|
+
expect(error.files.map(({ file }) => file.name)).toEqual(['broken.fa', 'ok.fa']);
|
|
130
|
+
expect(error.status).toBe('error');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('sets the status of sequences with no files and of sequences whose files are loading', () => {
|
|
134
|
+
const rows = buildSortedRows(sequences, filesBySequenceId, results);
|
|
135
|
+
const noFiles = rows.find((row) => row.sequence.name === 'no files');
|
|
136
|
+
expect(noFiles.status).toBe('info');
|
|
137
|
+
expect(noFiles.filesLoaded).toBe(true);
|
|
138
|
+
expect(noFiles.identity).toBeUndefined();
|
|
139
|
+
const loading = rows.find((row) => row.sequence.name === 'loading');
|
|
140
|
+
expect(loading.status).toBe('pending');
|
|
141
|
+
expect(loading.filesLoaded).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useMutation, useQueries } from '@tanstack/react-query';
|
|
3
|
+
import useBackendHttpClient from '@opencloning/ui/hooks/useBackendHttpClient';
|
|
4
|
+
import { convertToTeselaJson } from '@opencloning/utils/readNwrite';
|
|
5
|
+
import error2String from '@opencloning/utils/error2String';
|
|
6
|
+
import OpenCloningDBInterface from '../OpenCloningDBInterface';
|
|
7
|
+
import { MAX_CONCURRENT_CHECKS, buildSortedRows, percentIdentity, runWithConcurrency } from './sequencingCheckUtils';
|
|
8
|
+
import useSequencingQueries from './useSequencingQueries';
|
|
9
|
+
|
|
10
|
+
const { getSequencingFiles } = OpenCloningDBInterface;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Compare a sequencing file to its reference sequence. Plasmids are expected to match exactly, so if the
|
|
14
|
+
* query has the same length as the reference, its seguid (computed by the backend with the topology of the
|
|
15
|
+
* reference) is compared to the one stored in the database first, and the alignment is skipped if they match.
|
|
16
|
+
*
|
|
17
|
+
* The parsed file and the alignment come from useSequencingQueries, so they are reused by the alignment viewer
|
|
18
|
+
* and the next checks (only the seguid is computed again). Failed alignments are not cached, so they are retried.
|
|
19
|
+
* reference is { circular, length } of the sequence, parsed once per sequence.
|
|
20
|
+
*/
|
|
21
|
+
async function checkSequencingFile({ httpClient, getParsedFile, getAlignment }, { sequence, file, reference }) {
|
|
22
|
+
const { sequence: query } = await getParsedFile(file);
|
|
23
|
+
if (!query) {
|
|
24
|
+
throw new Error('The file has no sequence');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (sequence.seguid && query.length === reference.length) {
|
|
28
|
+
try {
|
|
29
|
+
const { data: querySeguid } = await httpClient.post('seguid', { sequence: query, circular: reference.circular });
|
|
30
|
+
if (querySeguid === sequence.seguid) {
|
|
31
|
+
return { status: 'match', identity: 100 };
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// The seguid could not be computed (e.g. unexpected characters), the alignment decides
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { alignedSequences } = await getAlignment(sequence, file);
|
|
39
|
+
return { status: 'aligned', identity: percentIdentity(alignedSequences[0], alignedSequences[1]) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Defined outside the hook so that its reference is stable: useQueries then only re-runs it (and returns
|
|
44
|
+
* a new object) when one of the queries changes, so filesData can be used as a memo dependency.
|
|
45
|
+
* filesData[i] is the list of files of the i-th sequence, undefined while loading.
|
|
46
|
+
*/
|
|
47
|
+
function combineFilesQueries(queries) {
|
|
48
|
+
return {
|
|
49
|
+
filesData: queries.map((query) => query.data),
|
|
50
|
+
filesError: queries.find((query) => query.error)?.error,
|
|
51
|
+
filesLoading: queries.some((query) => query.isPending),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check sequences (output of GetSequencesInBulkComponent) against their sequencing files.
|
|
57
|
+
*
|
|
58
|
+
* The sequencing files of each sequence are listed on mount, run() checks all the files, at most
|
|
59
|
+
* MAX_CONCURRENT_CHECKS at the same time, and the rows are updated as each check finishes.
|
|
60
|
+
*/
|
|
61
|
+
export default function useSequencingCheck(sequences) {
|
|
62
|
+
const httpClient = useBackendHttpClient();
|
|
63
|
+
const { getParsedFile, getAlignment } = useSequencingQueries();
|
|
64
|
+
const [results, setResults] = React.useState({});
|
|
65
|
+
|
|
66
|
+
// Same query key as the sequence detail page, so the cache is shared
|
|
67
|
+
const { filesData, filesError, filesLoading } = useQueries({
|
|
68
|
+
queries: sequences.map(({ databaseId }) => ({
|
|
69
|
+
queryKey: ['sequence', databaseId, 'sequencing_files'],
|
|
70
|
+
queryFn: () => getSequencingFiles(databaseId),
|
|
71
|
+
retry: false,
|
|
72
|
+
})),
|
|
73
|
+
combine: combineFilesQueries,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const filesBySequenceId = React.useMemo(() => Object.fromEntries(
|
|
77
|
+
sequences.map(({ databaseId }, i) => [databaseId, filesData[i]]),
|
|
78
|
+
), [sequences, filesData]);
|
|
79
|
+
|
|
80
|
+
const references = React.useMemo(() => Object.fromEntries(sequences.map((sequence) => {
|
|
81
|
+
const { circular, sequence: bases } = convertToTeselaJson(sequence.textFileSequence);
|
|
82
|
+
return [sequence.databaseId, { circular, length: bases.length }];
|
|
83
|
+
})), [sequences]);
|
|
84
|
+
|
|
85
|
+
const jobs = React.useMemo(() => sequences.flatMap((sequence) => (filesBySequenceId[sequence.databaseId] ?? [])
|
|
86
|
+
.map((file) => ({ sequence, file, reference: references[sequence.databaseId] }))), [sequences, filesBySequenceId, references]);
|
|
87
|
+
|
|
88
|
+
const setResult = (fileId, result) => setResults((prev) => ({ ...prev, [fileId]: result }));
|
|
89
|
+
|
|
90
|
+
const mutation = useMutation({
|
|
91
|
+
mutationFn: async () => {
|
|
92
|
+
setResults(Object.fromEntries(jobs.map(({ file }) => [file.id, { status: 'pending' }])));
|
|
93
|
+
await runWithConcurrency(jobs, MAX_CONCURRENT_CHECKS, async (job) => {
|
|
94
|
+
try {
|
|
95
|
+
setResult(job.file.id, await checkSequencingFile({ httpClient, getParsedFile, getAlignment }, job));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
setResult(job.file.id, { status: 'error', message: error2String(error) });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const rows = React.useMemo(() => buildSortedRows(sequences, filesBySequenceId, results), [sequences, filesBySequenceId, results]);
|
|
104
|
+
const done = jobs.filter(({ file }) => results[file.id] && results[file.id].status !== 'pending').length;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
rows,
|
|
108
|
+
filesLoading,
|
|
109
|
+
filesError,
|
|
110
|
+
run: mutation.mutate,
|
|
111
|
+
isRunning: mutation.isPending,
|
|
112
|
+
progress: { done, total: jobs.length },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import useBackendHttpClient from '@opencloning/ui/hooks/useBackendHttpClient';
|
|
4
|
+
import { readSequencingFile } from '@opencloning/utils/readNwrite';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The content of a sequencing file and its alignment to a sequence do not change, so they never go stale.
|
|
8
|
+
* They are kept for a while after nothing uses them (the parsed files may include chromatograms).
|
|
9
|
+
* Failed queries are not cached, so they are retried the next time.
|
|
10
|
+
*/
|
|
11
|
+
const cacheOptions = { staleTime: Infinity, gcTime: 30 * 60 * 1000, retry: false };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Cached sequencing file contents and alignments, shared by the sequencing check and the alignment viewer,
|
|
15
|
+
* so that a file is downloaded and aligned at most once.
|
|
16
|
+
*
|
|
17
|
+
* - getParsedFile(file): parsed content (teselaJson) of a sequencing file (from the output of getSequencingFiles)
|
|
18
|
+
* - getAlignment(sequence, file): alignment of a sequencing file to its sequence (an item of the output of
|
|
19
|
+
* GetSequencesInBulkComponent), { fileContent, alignedSequences } where alignedSequences is the align_sanger
|
|
20
|
+
* response ([aligned reference, aligned file])
|
|
21
|
+
* - alignmentQuery(sequence, file): the query options of getAlignment, to use with useQuery
|
|
22
|
+
*/
|
|
23
|
+
export default function useSequencingQueries() {
|
|
24
|
+
const queryClient = useQueryClient();
|
|
25
|
+
const httpClient = useBackendHttpClient();
|
|
26
|
+
|
|
27
|
+
return React.useMemo(() => {
|
|
28
|
+
const getParsedFile = (file) => queryClient.ensureQueryData({
|
|
29
|
+
queryKey: ['sequencing_file', file.id, 'parsed'],
|
|
30
|
+
queryFn: async () => (await readSequencingFile(await file.getFile())).teselaJson,
|
|
31
|
+
...cacheOptions,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const alignmentQuery = (sequence, file) => ({
|
|
35
|
+
queryKey: ['sequence', sequence.databaseId, 'sequencing_alignment', file.id],
|
|
36
|
+
queryFn: async () => {
|
|
37
|
+
const fileContent = await getParsedFile(file);
|
|
38
|
+
const { data: alignedSequences } = await httpClient.post('align_sanger', {
|
|
39
|
+
sequence: sequence.textFileSequence,
|
|
40
|
+
traces: [fileContent.sequence],
|
|
41
|
+
});
|
|
42
|
+
return { fileContent, alignedSequences };
|
|
43
|
+
},
|
|
44
|
+
...cacheOptions,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const getAlignment = (sequence, file) => queryClient.ensureQueryData(alignmentQuery(sequence, file));
|
|
48
|
+
|
|
49
|
+
return { getParsedFile, getAlignment, alignmentQuery };
|
|
50
|
+
}, [queryClient, httpClient]);
|
|
51
|
+
}
|
package/src/testUtils.test.js
CHANGED
|
@@ -3,7 +3,8 @@ import { http, HttpResponse } from 'msw';
|
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
4
|
import { baseUrl, setHttpClientTokenGetter } from './common';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// Generated by `opencloning-cli db stubs` in python/
|
|
7
|
+
const STUB_FOLDER = `${__dirname}/../../../../python/stubs/db`;
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* @typedef {Object} DbStub
|