@opencloning/ui 1.7.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 +102 -0
- package/package.json +20 -14
- package/src/components/AppAlerts.jsx +7 -7
- package/src/components/CloningHistory.jsx +2 -0
- package/src/components/CloningSpeedDial.jsx +102 -0
- package/src/components/DownloadCloningStrategyDialog.jsx +2 -2
- package/src/components/LoadCloningHistoryWrapper.jsx +2 -2
- package/src/components/MainSequenceEditor.jsx +41 -34
- package/src/components/NetworkNode.jsx +35 -33
- package/src/components/OpenCloning.jsx +4 -3
- package/src/components/SequenceViewer.cy.jsx +27 -0
- package/src/components/SequenceViewer.jsx +211 -0
- package/src/components/annotation/AnnotationChangedAlert.jsx +26 -0
- package/src/components/annotation/useUpdateAnnotationInMainSequence.js +2 -2
- package/src/components/assembler/Assembler.jsx +3 -3
- package/src/components/domesticate/DomesticateDialog.jsx +274 -0
- package/src/components/form/GetRequestMultiSelect.jsx +0 -1
- package/src/components/form/PostRequestSelect.jsx +0 -1
- package/src/components/form/QuerySelect.jsx +128 -0
- package/src/components/form/QueryStatusWrapper.jsx +28 -0
- package/src/components/form/SubmitToDatabaseDialog.jsx +63 -58
- package/src/components/form/TextFieldQueryValidated.jsx +93 -0
- package/src/components/index.js +4 -0
- package/src/components/navigation/FeedbackDialog.jsx +1 -1
- package/src/components/navigation/MainAppBar.jsx +5 -5
- package/src/components/primers/import_primers/ImportPrimersButton.jsx +2 -2
- package/src/components/primers/import_primers/PrimerDatabaseImportForm.jsx +6 -3
- package/src/components/sources/FinishedSource.jsx +3 -0
- package/src/components/sources/MultipleOutputsSelector.cy.jsx +95 -0
- package/src/components/sources/MultipleOutputsSelector.jsx +32 -19
- package/src/components/sources/RecombinaseList.cy.jsx +1 -1
- package/src/components/sources/RecombinaseList.jsx +1 -1
- package/src/components/sources/SourceAssembly.jsx +36 -2
- package/src/components/sources/SourceRepositoryId.jsx +0 -1
- package/src/components/sources/SourceTypeSelector.jsx +1 -1
- package/src/components/verification/ImportSequencingFilesInput.jsx +17 -0
- package/src/components/verification/LoadFromDatabaseButton.jsx +17 -10
- package/src/components/verification/VerificationFileDialog.jsx +16 -22
- package/src/components/verification/index.js +1 -0
- package/src/config/defaultMainEditorProps.js +1 -0
- package/src/hooks/useAlerts.js +3 -4
- package/src/hooks/useBackendAPI.js +5 -2
- package/src/hooks/useCloningAlerts.js +7 -0
- package/src/hooks/useDebouncedSearchQuery.js +42 -0
- package/src/hooks/useHttpClient.js +7 -5
- package/src/hooks/useLoadDatabaseFile.js +6 -2
- package/src/hooks/useSequencingAlignment.js +26 -0
- package/src/hooks/useSnapgeneHistoryEndpoint.js +3 -3
- package/src/hooks/useStoreEditor.js +10 -68
- package/src/hooks/useUrlParamsLoader.js +2 -2
- package/src/hooks/useValidateState.js +3 -3
- package/src/index.css +3 -11
- package/src/providers/ConfigProvider.jsx +1 -0
- package/src/version.js +1 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { useSelector, useStore } from 'react-redux';
|
|
4
|
+
import { Editor, updateEditor, addAlignment } from '@teselagen/ove';
|
|
5
|
+
import { Paper, IconButton } from '@mui/material';
|
|
6
|
+
import {Fullscreen as FullscreenIcon, FullscreenExit as FullscreenExitIcon} from '@mui/icons-material';
|
|
7
|
+
import defaultMainEditorProps from '../config/defaultMainEditorProps';
|
|
8
|
+
import { updatePanelsShownWithAlignment, removePanelFromShown } from '@opencloning/utils/alignmentUtils';
|
|
9
|
+
import useAppAlerts from '../../../../apps/opencloningdb/src/hooks/useAppAlerts';
|
|
10
|
+
import { jsonToGenbank } from '@teselagen/bio-parsers';
|
|
11
|
+
import AnnotationChangedAlert from './annotation/AnnotationChangedAlert';
|
|
12
|
+
|
|
13
|
+
const EDITOR_NAME = 'sequenceViewer';
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
function regionRightClickedOverride(items, { annotation }, props) {
|
|
17
|
+
const items2keep = items.filter((i) => i.text === 'Copy');
|
|
18
|
+
return [
|
|
19
|
+
...items2keep,
|
|
20
|
+
{
|
|
21
|
+
text: 'Create',
|
|
22
|
+
submenu: [
|
|
23
|
+
"newFeature",
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
...(props.sequenceData.circular === true ? [
|
|
27
|
+
"--",
|
|
28
|
+
"selectInverse",
|
|
29
|
+
"--",
|
|
30
|
+
] : []),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function primerRightClickedOverride(items, { annotation }, props) {
|
|
35
|
+
return [
|
|
36
|
+
...regionRightClickedOverride(items, { annotation }, props),
|
|
37
|
+
"--",
|
|
38
|
+
{
|
|
39
|
+
text: 'Delete Primer annotation',
|
|
40
|
+
cmd: 'deletePrimer',
|
|
41
|
+
}
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function featureRightClickedOverride(items, { annotation }, props) {
|
|
46
|
+
return [
|
|
47
|
+
...regionRightClickedOverride(items, { annotation }, props),
|
|
48
|
+
"--",
|
|
49
|
+
"editFeature",
|
|
50
|
+
"deleteFeature",
|
|
51
|
+
"showRemoveDuplicatesDialogFeatures",
|
|
52
|
+
"--",
|
|
53
|
+
"toggleCdsFeatureTranslations",
|
|
54
|
+
"viewFeatureProperties",
|
|
55
|
+
"--",
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
const rightClickOverrides = {
|
|
59
|
+
selectionLayerRightClicked: regionRightClickedOverride,
|
|
60
|
+
primerRightClicked: primerRightClickedOverride,
|
|
61
|
+
translationRightClicked: regionRightClickedOverride,
|
|
62
|
+
searchLayerRightClicked: regionRightClickedOverride,
|
|
63
|
+
featureRightClicked: featureRightClickedOverride,
|
|
64
|
+
partRightClicked: {},
|
|
65
|
+
orfRightClicked: {},
|
|
66
|
+
backgroundRightClicked: {},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const baseViewerProps = {
|
|
70
|
+
...defaultMainEditorProps,
|
|
71
|
+
annotationVisibility: {featureTypesToHyde: {source: true}},
|
|
72
|
+
readOnly: false,
|
|
73
|
+
disableBpEditing: true,
|
|
74
|
+
selectionLayer: {},
|
|
75
|
+
sequenceData: {},
|
|
76
|
+
rightClickOverrides,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function SequenceViewer({ sequenceData, alignmentData, onUpdateAnnotation }) {
|
|
80
|
+
const store = useStore();
|
|
81
|
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
82
|
+
|
|
83
|
+
const handleFullScreenChange = React.useCallback((newValue) => {
|
|
84
|
+
setIsFullscreen(newValue);
|
|
85
|
+
updateEditor(store, EDITOR_NAME, { isFullscreen: newValue });
|
|
86
|
+
}, [store, setIsFullscreen]);
|
|
87
|
+
|
|
88
|
+
const { addAlert } = useAppAlerts();
|
|
89
|
+
|
|
90
|
+
const annotationChanged = useSelector(
|
|
91
|
+
(state) => {
|
|
92
|
+
const history = state.VectorEditor.sequenceViewer?.sequenceDataHistory;
|
|
93
|
+
if (!history) return false;
|
|
94
|
+
return Object.keys(history).length > 0 && history.past.length !== 0;
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const clearHistory = React.useCallback(() => {
|
|
99
|
+
updateEditor(store, EDITOR_NAME, { sequenceDataHistory: {}, sequenceData });
|
|
100
|
+
}, [store, sequenceData]);
|
|
101
|
+
|
|
102
|
+
const handleUpdateAnnotation = React.useCallback(async () => {
|
|
103
|
+
const newSequenceData = store.getState().VectorEditor.sequenceViewer.sequenceData;
|
|
104
|
+
if (newSequenceData.sequence.toUpperCase() !== sequenceData.sequence.toUpperCase()) {
|
|
105
|
+
addAlert({
|
|
106
|
+
message: 'Sequence bases have been changed, we cannot update the annotation',
|
|
107
|
+
severity: 'error',
|
|
108
|
+
});
|
|
109
|
+
clearHistory();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const newFileContent = jsonToGenbank(newSequenceData);
|
|
113
|
+
try {
|
|
114
|
+
await onUpdateAnnotation(newFileContent);
|
|
115
|
+
clearHistory();
|
|
116
|
+
} catch (error) {
|
|
117
|
+
console.error(error);
|
|
118
|
+
}
|
|
119
|
+
}, [store, sequenceData, clearHistory, addAlert, onUpdateAnnotation]);
|
|
120
|
+
|
|
121
|
+
React.useEffect(() => {
|
|
122
|
+
if (!isFullscreen) return;
|
|
123
|
+
const onKeyDown = (e) => {
|
|
124
|
+
if (e.key === 'Escape') handleFullScreenChange(false);
|
|
125
|
+
};
|
|
126
|
+
window.addEventListener('keydown', onKeyDown);
|
|
127
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
128
|
+
}, [handleFullScreenChange, isFullscreen]);
|
|
129
|
+
|
|
130
|
+
const viewerProps = {
|
|
131
|
+
...baseViewerProps,
|
|
132
|
+
isFullscreen,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
React.useEffect(() => {
|
|
136
|
+
updateEditor(store, EDITOR_NAME, { ...baseViewerProps, sequenceData, sequenceDataHistory: {} });
|
|
137
|
+
}, [store, sequenceData]);
|
|
138
|
+
|
|
139
|
+
React.useEffect(() => {
|
|
140
|
+
const editorUpdate = {};
|
|
141
|
+
|
|
142
|
+
if (alignmentData) {
|
|
143
|
+
addAlignment(store, alignmentData);
|
|
144
|
+
const editorState = store.getState().VectorEditor?.[EDITOR_NAME];
|
|
145
|
+
const currentPanels = editorState?.panelsShown || [[]];
|
|
146
|
+
editorUpdate.panelsShown = updatePanelsShownWithAlignment(currentPanels);
|
|
147
|
+
} else {
|
|
148
|
+
const editorState = store.getState().VectorEditor?.[EDITOR_NAME];
|
|
149
|
+
if (editorState?.panelsShown) {
|
|
150
|
+
editorUpdate.panelsShown = removePanelFromShown(editorState.panelsShown, 'simpleAlignment');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
editorUpdate.panelsShown[0].find((panel) => panel.id === 'rail').active = !sequenceData.circular;
|
|
155
|
+
editorUpdate.panelsShown[0].find((panel) => panel.id === 'circular').active = sequenceData.circular;
|
|
156
|
+
|
|
157
|
+
updateEditor(store, EDITOR_NAME, editorUpdate);
|
|
158
|
+
|
|
159
|
+
}, [sequenceData.circular, store, alignmentData]);
|
|
160
|
+
|
|
161
|
+
if (!sequenceData || !sequenceData.sequence) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const fullscreenExitButton = isFullscreen && createPortal(
|
|
166
|
+
<IconButton
|
|
167
|
+
aria-label="Exit fullscreen"
|
|
168
|
+
onClick={() => setIsFullscreen(false)}
|
|
169
|
+
sx={{
|
|
170
|
+
position: 'fixed',
|
|
171
|
+
top: 8,
|
|
172
|
+
right: 8,
|
|
173
|
+
zIndex: 2147483647,
|
|
174
|
+
bgcolor: 'background.paper',
|
|
175
|
+
boxShadow: 3,
|
|
176
|
+
'&:hover': { bgcolor: 'action.hover' },
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<FullscreenExitIcon />
|
|
180
|
+
</IconButton>,
|
|
181
|
+
document.body,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<Paper sx={{ p: 1, overflow: 'auto', position: 'relative' }}>
|
|
186
|
+
{!isFullscreen && (
|
|
187
|
+
<IconButton
|
|
188
|
+
aria-label="Fullscreen"
|
|
189
|
+
onClick={() => setIsFullscreen(true)}
|
|
190
|
+
sx={{
|
|
191
|
+
position: 'absolute',
|
|
192
|
+
top: 8,
|
|
193
|
+
right: 8,
|
|
194
|
+
zIndex: 1,
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
<FullscreenIcon />
|
|
198
|
+
</IconButton>
|
|
199
|
+
)}
|
|
200
|
+
{fullscreenExitButton}
|
|
201
|
+
{annotationChanged && <AnnotationChangedAlert onSave={handleUpdateAnnotation} onCancel={clearHistory} />}
|
|
202
|
+
<Editor
|
|
203
|
+
editorName={EDITOR_NAME}
|
|
204
|
+
{...viewerProps}
|
|
205
|
+
height="800"
|
|
206
|
+
/>
|
|
207
|
+
</Paper>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export default React.memo(SequenceViewer);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Alert, Button } from '@mui/material';
|
|
3
|
+
|
|
4
|
+
function AnnotationChangedAlert({ onSave, onCancel }) {
|
|
5
|
+
return (
|
|
6
|
+
<Alert
|
|
7
|
+
style={{maxWidth: '500px', margin: '10px auto'}}
|
|
8
|
+
severity="info"
|
|
9
|
+
data-testid="annotation-changed-alert"
|
|
10
|
+
action={
|
|
11
|
+
<>
|
|
12
|
+
<Button color="primary" onClick={onSave}>
|
|
13
|
+
Save
|
|
14
|
+
</Button>
|
|
15
|
+
<Button color="secondary" onClick={onCancel}>
|
|
16
|
+
Cancel
|
|
17
|
+
</Button>
|
|
18
|
+
</>
|
|
19
|
+
}
|
|
20
|
+
>
|
|
21
|
+
<strong>Annotation Changed</strong>
|
|
22
|
+
</Alert>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default AnnotationChangedAlert
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { useDispatch, useStore } from 'react-redux';
|
|
3
|
-
import
|
|
3
|
+
import useCloningAlerts from '../../hooks/useCloningAlerts';
|
|
4
4
|
import { jsonToGenbank } from '@teselagen/bio-parsers';
|
|
5
5
|
import { cloningActions } from '@opencloning/store/cloning';
|
|
6
6
|
import useStoreEditor from '../../hooks/useStoreEditor';
|
|
@@ -9,7 +9,7 @@ const { updateSequence } = cloningActions;
|
|
|
9
9
|
|
|
10
10
|
export default function useUpdateAnnotationInMainSequence() {
|
|
11
11
|
const store = useStore();
|
|
12
|
-
const { addAlert } =
|
|
12
|
+
const { addAlert } = useCloningAlerts();
|
|
13
13
|
const dispatch = useDispatch();
|
|
14
14
|
const { updateStoreEditor } = useStoreEditor();
|
|
15
15
|
|
|
@@ -21,7 +21,7 @@ import error2String from '@opencloning/utils/error2String';
|
|
|
21
21
|
import { categoryFilter, downloadAssemblerFilesAsZip, getFilesToExportFromAssembler, getDefaultAssemblyOutputName, MAX_OUTPUT_NAME_LENGTH, sanitizeOutputName } from './assembler_utils';
|
|
22
22
|
import useBackendRoute from '../../hooks/useBackendRoute';
|
|
23
23
|
import useHttpClient from '../../hooks/useHttpClient';
|
|
24
|
-
import
|
|
24
|
+
import useCloningAlerts from '../../hooks/useCloningAlerts';
|
|
25
25
|
import UploadPlasmidsButton from './UploadPlasmidsButton';
|
|
26
26
|
import { useConfig } from '../../providers';
|
|
27
27
|
import { isEqual } from 'lodash-es';
|
|
@@ -379,7 +379,7 @@ function LoadSyntaxButton({ setSyntax, addPlasmids, clearPlasmids }) {
|
|
|
379
379
|
const httpClient = useHttpClient();
|
|
380
380
|
const { staticContentPath } = useConfig();
|
|
381
381
|
const backendRoute = useBackendRoute();
|
|
382
|
-
const { addAlert } =
|
|
382
|
+
const { addAlert } = useCloningAlerts();
|
|
383
383
|
const onSyntaxSelect = React.useCallback(async (syntax, plasmids) => {
|
|
384
384
|
const url = backendRoute('validate_syntax');
|
|
385
385
|
try {
|
|
@@ -440,7 +440,7 @@ function SyntaxOverviewButton({ syntax }) {
|
|
|
440
440
|
function Assembler() {
|
|
441
441
|
const [syntax, setSyntax] = React.useState(null);
|
|
442
442
|
const [plasmids, setPlasmids] = React.useState([])
|
|
443
|
-
const { addAlert } =
|
|
443
|
+
const { addAlert } = useCloningAlerts();
|
|
444
444
|
const appInfo = useSelector(({ cloning }) => cloning.appInfo, isEqual);
|
|
445
445
|
|
|
446
446
|
const categories = React.useMemo(() => {
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/* eslint-disable camelcase -- request/response fields match OpenAPI (snake_case) */
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import {
|
|
4
|
+
Alert,
|
|
5
|
+
Box,
|
|
6
|
+
Button,
|
|
7
|
+
Checkbox,
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogActions,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
FormControl,
|
|
13
|
+
FormControlLabel,
|
|
14
|
+
FormGroup,
|
|
15
|
+
FormHelperText,
|
|
16
|
+
InputLabel,
|
|
17
|
+
MenuItem,
|
|
18
|
+
Select,
|
|
19
|
+
TextField,
|
|
20
|
+
} from '@mui/material';
|
|
21
|
+
import error2String from '@opencloning/utils/error2String';
|
|
22
|
+
import useBackendRoute from '../../hooks/useBackendRoute';
|
|
23
|
+
import useHttpClient from '../../hooks/useHttpClient';
|
|
24
|
+
import { featureToGenbankLocationString, mergeStates } from '@opencloning/utils';
|
|
25
|
+
import { useDispatch, useStore } from 'react-redux';
|
|
26
|
+
import { cloningActions } from '@opencloning/store/cloning';
|
|
27
|
+
|
|
28
|
+
/** Matches OpenAPI `CloningType` */
|
|
29
|
+
export const CLONING_TYPES = ['domestication', 'synthesis'];
|
|
30
|
+
|
|
31
|
+
/** Matches OpenAPI `EnzymeToRemove` */
|
|
32
|
+
export const ENZYMES_TO_REMOVE = ['BsmBI', 'BsaI', 'BtgZI', 'BpiI'];
|
|
33
|
+
|
|
34
|
+
/** Matches OpenAPI `AllowedCategory` (exact strings) */
|
|
35
|
+
export const ALLOWED_CATEGORIES = [
|
|
36
|
+
'PROM+5UTR (A1-A2-A3-B1-B2)',
|
|
37
|
+
"PROM+5UTR(f) (A1-A2-A3-B1)",
|
|
38
|
+
'DIST+PROX (A1-A2)',
|
|
39
|
+
'CORE+5UTR (A3-B1-B2)',
|
|
40
|
+
'DIST(A1)',
|
|
41
|
+
'PROX (A2)',
|
|
42
|
+
'INTERACTION ADAPTOR (A1-A2-A3-B1-B2b)',
|
|
43
|
+
'PROM+5UTR+mir173 (A1-A2-A3-B1b)',
|
|
44
|
+
'NTAG (B2)',
|
|
45
|
+
'CDS (B3-B4-B5)',
|
|
46
|
+
'CDS1 (B3)',
|
|
47
|
+
'CDS2 (B4)',
|
|
48
|
+
'CDS2+CTAG (B4-B5)',
|
|
49
|
+
'CDS1+CDS2 (B3-B4)',
|
|
50
|
+
'CTAG (B5)',
|
|
51
|
+
"5'FS (B2-B3b)",
|
|
52
|
+
'Target (B4b)',
|
|
53
|
+
"'3FS (B5b)",
|
|
54
|
+
'goi (B2-B3)',
|
|
55
|
+
'int (B4)',
|
|
56
|
+
'iog (B5)',
|
|
57
|
+
'fgoi (B2-B3-B4-B5)',
|
|
58
|
+
'3UTR+TERM (B6-C1)',
|
|
59
|
+
'PROM DPolIII+DRCas12 (A1-A2-A3-B1-B2e)',
|
|
60
|
+
'3_prime processing (B6c-C1)',
|
|
61
|
+
'PROM DPolIII (A1-A2-A3-B1-B2c)',
|
|
62
|
+
'PROM MPolIII (A1-A2-A3-B1-B2d)',
|
|
63
|
+
'sgRNA (B6b-C1)',
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const FOUR_BP = /^[ACGT]{4}$/;
|
|
67
|
+
|
|
68
|
+
const { setState: setCloningState, setCurrentTab } = cloningActions;
|
|
69
|
+
|
|
70
|
+
function categoryPrefixSuffixValid(category, prefix, suffix) {
|
|
71
|
+
const pref = prefix.toUpperCase();
|
|
72
|
+
const suff = suffix.toUpperCase();
|
|
73
|
+
if (!category) {
|
|
74
|
+
return FOUR_BP.test(pref) && FOUR_BP.test(suff);
|
|
75
|
+
}
|
|
76
|
+
return prefix === '' && suffix === '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default function DomesticateDialog({ open, onClose, initialContext, mainSequenceId }) {
|
|
80
|
+
const httpClient = useHttpClient();
|
|
81
|
+
const backendRoute = useBackendRoute();
|
|
82
|
+
const store = useStore();
|
|
83
|
+
|
|
84
|
+
const location = React.useMemo(() => featureToGenbankLocationString(initialContext.annotation), [initialContext]);
|
|
85
|
+
const [cloningType, setCloningType] = React.useState('domestication');
|
|
86
|
+
const [partName, setPartName] = React.useState(initialContext.annotation.name);
|
|
87
|
+
const [prefix, setPrefix] = React.useState('');
|
|
88
|
+
const [suffix, setSuffix] = React.useState('');
|
|
89
|
+
const [category, setCategory] = React.useState('');
|
|
90
|
+
const [enzymes, setEnzymes] = React.useState(['BsaI', 'BsmBI']);
|
|
91
|
+
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
|
92
|
+
const [error, setError] = React.useState(null);
|
|
93
|
+
const dispatch = useDispatch();
|
|
94
|
+
|
|
95
|
+
const categoryValueForApi = category || null;
|
|
96
|
+
|
|
97
|
+
const validationOk = Boolean(
|
|
98
|
+
partName.trim() && enzymes.length >= 1 && categoryPrefixSuffixValid(category, prefix, suffix),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const helperCategory = category
|
|
102
|
+
? 'With a category selected, prefix and suffix must be empty.'
|
|
103
|
+
: 'Without a category, prefix and suffix must each be exactly 4 bases (A/C/G/T).';
|
|
104
|
+
|
|
105
|
+
const toggleEnzyme = (name) => {
|
|
106
|
+
setEnzymes((prev) => (prev.includes(name) ? prev.filter((e) => e !== name) : [...prev, name]));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const handleCategoryChange = (e) => {
|
|
110
|
+
const v = e.target.value;
|
|
111
|
+
setCategory(v);
|
|
112
|
+
if (v) {
|
|
113
|
+
setPrefix('');
|
|
114
|
+
setSuffix('');
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const handleSubmit = async (e) => {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
if (!validationOk || !initialContext) return;
|
|
121
|
+
setIsSubmitting(true);
|
|
122
|
+
const sequence = store.getState().cloning.sequences.find((s) => s.id === mainSequenceId);
|
|
123
|
+
const body = {
|
|
124
|
+
sequence,
|
|
125
|
+
location: location.trim(),
|
|
126
|
+
cloning_type: cloningType,
|
|
127
|
+
part_name: partName.trim(),
|
|
128
|
+
prefix: categoryValueForApi ? '' : prefix.toUpperCase(),
|
|
129
|
+
suffix: categoryValueForApi ? '' : suffix.toUpperCase(),
|
|
130
|
+
category: categoryValueForApi,
|
|
131
|
+
enzymes,
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
const url = backendRoute('batch_cloning/domesticate');
|
|
135
|
+
const { data } = await httpClient.post(url, body);
|
|
136
|
+
|
|
137
|
+
const { mergedState, idShift } = mergeStates(data, store.getState().cloning);
|
|
138
|
+
const originalSequenceIdInNewState = data.sources.find((s) => s.type === 'ManuallyTypedSource')?.id;
|
|
139
|
+
if (!originalSequenceIdInNewState) {
|
|
140
|
+
throw new Error('Original sequence not found in new state');
|
|
141
|
+
}
|
|
142
|
+
const originalSequenceIdInMergedState = originalSequenceIdInNewState + idShift;
|
|
143
|
+
mergedState.sequences = mergedState.sequences.filter((s) => s.id !== originalSequenceIdInMergedState);
|
|
144
|
+
mergedState.sources = mergedState.sources.filter((s) => s.id !== originalSequenceIdInMergedState);
|
|
145
|
+
mergedState.sources.map((s) => {
|
|
146
|
+
s.input = s.input.map((i) => {
|
|
147
|
+
if (i.sequence === originalSequenceIdInMergedState) {
|
|
148
|
+
return { ...i, sequence: mainSequenceId };
|
|
149
|
+
}
|
|
150
|
+
return i;
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
dispatch(setCloningState(mergedState));
|
|
155
|
+
dispatch(setCurrentTab(0));
|
|
156
|
+
onClose();
|
|
157
|
+
} catch (err) {
|
|
158
|
+
setError(error2String(err));
|
|
159
|
+
} finally {
|
|
160
|
+
setIsSubmitting(false);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
|
166
|
+
<form onSubmit={handleSubmit}>
|
|
167
|
+
<DialogTitle>Domesticate (experimental)</DialogTitle>
|
|
168
|
+
<DialogContent>
|
|
169
|
+
<Alert severity="info" sx={{ mb: 2 }}>
|
|
170
|
+
{helperCategory}
|
|
171
|
+
</Alert>
|
|
172
|
+
<TextField
|
|
173
|
+
margin="dense"
|
|
174
|
+
label="Location"
|
|
175
|
+
value={location}
|
|
176
|
+
disabled
|
|
177
|
+
fullWidth
|
|
178
|
+
required
|
|
179
|
+
/>
|
|
180
|
+
<TextField
|
|
181
|
+
margin="dense"
|
|
182
|
+
label="Part name"
|
|
183
|
+
value={partName}
|
|
184
|
+
onChange={(ev) => setPartName(ev.target.value)}
|
|
185
|
+
fullWidth
|
|
186
|
+
required
|
|
187
|
+
/>
|
|
188
|
+
<FormControl fullWidth margin="dense">
|
|
189
|
+
<InputLabel id="cloning-type-label">Cloning type</InputLabel>
|
|
190
|
+
<Select
|
|
191
|
+
labelId="cloning-type-label"
|
|
192
|
+
label="Cloning type"
|
|
193
|
+
value={cloningType}
|
|
194
|
+
onChange={(ev) => setCloningType(ev.target.value)}
|
|
195
|
+
>
|
|
196
|
+
{CLONING_TYPES.map((t) => (
|
|
197
|
+
<MenuItem key={t} value={t}>{t}</MenuItem>
|
|
198
|
+
))}
|
|
199
|
+
</Select>
|
|
200
|
+
</FormControl>
|
|
201
|
+
{location.includes('join') && cloningType === 'synthesis' &&
|
|
202
|
+
<Alert severity="info" sx={{ mb: 2 }}>Synthesis will remove introns in this feature</Alert>
|
|
203
|
+
}
|
|
204
|
+
<FormControl fullWidth margin="dense">
|
|
205
|
+
<InputLabel id="category-label" shrink>
|
|
206
|
+
Category
|
|
207
|
+
</InputLabel>
|
|
208
|
+
<Select
|
|
209
|
+
labelId="category-label"
|
|
210
|
+
label="Category"
|
|
211
|
+
value={category}
|
|
212
|
+
onChange={handleCategoryChange}
|
|
213
|
+
displayEmpty
|
|
214
|
+
notched
|
|
215
|
+
>
|
|
216
|
+
<MenuItem value="">
|
|
217
|
+
<em>None — use 4 bp prefix/suffix</em>
|
|
218
|
+
</MenuItem>
|
|
219
|
+
{ALLOWED_CATEGORIES.map((c) => (
|
|
220
|
+
<MenuItem key={c} value={c}>{c}</MenuItem>
|
|
221
|
+
))}
|
|
222
|
+
</Select>
|
|
223
|
+
</FormControl>
|
|
224
|
+
<TextField
|
|
225
|
+
margin="dense"
|
|
226
|
+
label="Prefix"
|
|
227
|
+
value={prefix}
|
|
228
|
+
onChange={(ev) => setPrefix(ev.target.value.toUpperCase().replace(/[^ACGT]/g, ''))}
|
|
229
|
+
fullWidth
|
|
230
|
+
disabled={Boolean(category)}
|
|
231
|
+
inputProps={{ maxLength: 4 }}
|
|
232
|
+
helperText={category ? 'Clear category to edit' : 'Exactly 4 A/C/G/T when no category'}
|
|
233
|
+
/>
|
|
234
|
+
<TextField
|
|
235
|
+
margin="dense"
|
|
236
|
+
label="Suffix"
|
|
237
|
+
value={suffix}
|
|
238
|
+
onChange={(ev) => setSuffix(ev.target.value.toUpperCase().replace(/[^ACGT]/g, ''))}
|
|
239
|
+
fullWidth
|
|
240
|
+
disabled={Boolean(category)}
|
|
241
|
+
inputProps={{ maxLength: 4 }}
|
|
242
|
+
/>
|
|
243
|
+
<Box sx={{ mt: 1 }}>
|
|
244
|
+
<FormControl component="fieldset" variant="standard" required error={enzymes.length === 0}>
|
|
245
|
+
<FormHelperText>Enzymes to remove (at least one)</FormHelperText>
|
|
246
|
+
<FormGroup row>
|
|
247
|
+
{ENZYMES_TO_REMOVE.map((enz) => (
|
|
248
|
+
<FormControlLabel
|
|
249
|
+
key={enz}
|
|
250
|
+
control={(
|
|
251
|
+
<Checkbox
|
|
252
|
+
checked={enzymes.includes(enz)}
|
|
253
|
+
onChange={() => toggleEnzyme(enz)}
|
|
254
|
+
name={enz}
|
|
255
|
+
/>
|
|
256
|
+
)}
|
|
257
|
+
label={enz}
|
|
258
|
+
/>
|
|
259
|
+
))}
|
|
260
|
+
</FormGroup>
|
|
261
|
+
</FormControl>
|
|
262
|
+
</Box>
|
|
263
|
+
{error && <Alert severity="error">{error}</Alert>}
|
|
264
|
+
</DialogContent>
|
|
265
|
+
<DialogActions>
|
|
266
|
+
<Button onClick={onClose} color="inherit">Cancel</Button>
|
|
267
|
+
<Button type="submit" variant="contained" disabled={!validationOk || isSubmitting}>
|
|
268
|
+
{isSubmitting ? 'Submitting…' : 'Submit'}
|
|
269
|
+
</Button>
|
|
270
|
+
</DialogActions>
|
|
271
|
+
</form>
|
|
272
|
+
</Dialog>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
@@ -65,7 +65,6 @@ export default function PostRequestSelect({ setValue, getOptions, getOptionLabel
|
|
|
65
65
|
onChange={(event, value) => { setValue(value); setUserInput(getOptionLabel(value)); }}
|
|
66
66
|
// Change options only when input changes (not when an option is picked)
|
|
67
67
|
onInputChange={(event, newInputValue, reason) => { if (reason === 'input') { setUserInput(newInputValue); setConnectAttemp(connectAttempt + 1); } }}
|
|
68
|
-
id="tags-standard"
|
|
69
68
|
options={options}
|
|
70
69
|
noOptionsText={noOptionsText || 'Type at least 3 characters to search'}
|
|
71
70
|
getOptionLabel={getOptionLabel}
|