@opencloning/ui 1.7.2 → 1.8.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/package.json +20 -14
  3. package/src/components/AppAlerts.jsx +7 -7
  4. package/src/components/CloningHistory.jsx +2 -0
  5. package/src/components/CloningSpeedDial.jsx +102 -0
  6. package/src/components/DownloadCloningStrategyDialog.jsx +2 -2
  7. package/src/components/LoadCloningHistoryWrapper.jsx +29 -22
  8. package/src/components/MainSequenceEditor.jsx +41 -34
  9. package/src/components/NetworkNode.jsx +35 -33
  10. package/src/components/OpenCloning.jsx +4 -3
  11. package/src/components/SequenceViewer.cy.jsx +27 -0
  12. package/src/components/SequenceViewer.jsx +211 -0
  13. package/src/components/annotation/AnnotationChangedAlert.jsx +26 -0
  14. package/src/components/annotation/useUpdateAnnotationInMainSequence.js +2 -2
  15. package/src/components/assembler/Assembler.jsx +3 -3
  16. package/src/components/domesticate/DomesticateDialog.jsx +274 -0
  17. package/src/components/form/GetRequestMultiSelect.jsx +0 -1
  18. package/src/components/form/PostRequestSelect.jsx +0 -1
  19. package/src/components/form/QuerySelect.jsx +128 -0
  20. package/src/components/form/QueryStatusWrapper.jsx +31 -0
  21. package/src/components/form/SubmitToDatabaseDialog.jsx +63 -58
  22. package/src/components/form/TextFieldQueryValidated.jsx +93 -0
  23. package/src/components/index.js +4 -0
  24. package/src/components/navigation/FeedbackDialog.jsx +1 -1
  25. package/src/components/navigation/MainAppBar.jsx +5 -5
  26. package/src/components/primers/import_primers/ImportPrimersButton.jsx +2 -2
  27. package/src/components/primers/import_primers/PrimerDatabaseImportForm.jsx +6 -3
  28. package/src/components/sources/FinishedSource.jsx +3 -0
  29. package/src/components/sources/MultipleOutputsSelector.cy.jsx +95 -0
  30. package/src/components/sources/MultipleOutputsSelector.jsx +32 -19
  31. package/src/components/sources/RecombinaseList.cy.jsx +1 -1
  32. package/src/components/sources/RecombinaseList.jsx +1 -1
  33. package/src/components/sources/SourceAssembly.jsx +36 -2
  34. package/src/components/sources/SourceFile.jsx +9 -1
  35. package/src/components/sources/SourceRepositoryId.jsx +0 -1
  36. package/src/components/sources/SourceTypeSelector.jsx +1 -1
  37. package/src/components/verification/ImportSequencingFilesInput.jsx +17 -0
  38. package/src/components/verification/LoadFromDatabaseButton.jsx +17 -10
  39. package/src/components/verification/VerificationFileDialog.jsx +16 -22
  40. package/src/components/verification/index.js +1 -0
  41. package/src/config/defaultMainEditorProps.js +1 -0
  42. package/src/hooks/useAlerts.js +3 -4
  43. package/src/hooks/useBackendAPI.js +5 -2
  44. package/src/hooks/useCloningAlerts.js +7 -0
  45. package/src/hooks/useDebouncedSearchQuery.js +42 -0
  46. package/src/hooks/useLoadDatabaseFile.js +6 -2
  47. package/src/hooks/useSequencingAlignment.js +26 -0
  48. package/src/hooks/useSnapgeneHistoryEndpoint.js +72 -0
  49. package/src/hooks/useStoreEditor.js +10 -68
  50. package/src/hooks/useUrlParamsLoader.js +2 -2
  51. package/src/hooks/useValidateState.js +3 -3
  52. package/src/index.css +3 -11
  53. package/src/version.js +1 -1
@@ -1,7 +1,6 @@
1
1
  import React, { useRef, useState } from 'react';
2
2
  import { useDispatch, useSelector } from 'react-redux';
3
3
  import { Tabs } from '@mui/material';
4
- import { isEqual } from 'lodash-es';
5
4
  import DescriptionEditor from './DescriptionEditor';
6
5
  import PrimerList from './primers/PrimerList';
7
6
  import SettingsTab from './settings/SettingsTab';
@@ -14,6 +13,7 @@ import SequenceTab from './SequenceTab';
14
13
  import AppAlerts from './AppAlerts';
15
14
  import Assembler from './assembler/Assembler';
16
15
  import { useConfig } from '../hooks/useConfig';
16
+ import useCloningAlerts from '../hooks/useCloningAlerts';
17
17
 
18
18
  const { setCurrentTab } = cloningActions;
19
19
 
@@ -23,6 +23,7 @@ function OpenCloning() {
23
23
  const tabPanelsRef = useRef(null);
24
24
  const [smallDevice, setSmallDevice] = useState(window.innerWidth < 600);
25
25
  const { showAppBar: hasAppBar, enableAssembler } = useConfig();
26
+ const { removeAlert } = useCloningAlerts();
26
27
 
27
28
  React.useEffect(() => {
28
29
  const handleResize = () => {
@@ -44,7 +45,6 @@ function OpenCloning() {
44
45
 
45
46
  return (
46
47
  <div className="app-container" style={{ height: hasAppBar ? 'calc(100vh - 114px - 10px)' : '100vh' }}>
47
- <AppAlerts />
48
48
  <Tabs
49
49
  variant={smallDevice ? 'scrollable' : 'standard'}
50
50
  scrollButtons={smallDevice ? 'auto' : false}
@@ -52,7 +52,7 @@ function OpenCloning() {
52
52
  centered={!smallDevice}
53
53
  value={currentTab}
54
54
  onChange={changeTab}
55
- sx={{ pb: 3, pt: 1 }}
55
+ sx={{ pb: 2, pt: 1 }}
56
56
  id="opencloning-app-tabs"
57
57
  >
58
58
  <CustomTab label="Cloning" index={0} />
@@ -64,6 +64,7 @@ function OpenCloning() {
64
64
  {enableAssembler && <CustomTab label="Assembler" index={6} />}
65
65
  </Tabs>
66
66
  <div className="tab-panels-container" ref={tabPanelsRef}>
67
+ <AppAlerts reducerName="cloning" removeAlert={removeAlert} />
67
68
  <TabPanel index={1} value={currentTab} className="primer-tab-pannel">
68
69
  <div className="primer-list-container">
69
70
  <PrimerList />
@@ -0,0 +1,27 @@
1
+ /* eslint-disable camelcase */
2
+ import React from 'react';
3
+ import SequenceViewer from './SequenceViewer';
4
+ import { convertToTeselaJson } from '@opencloning/utils/readNwrite';
5
+
6
+ describe('<SequenceViewer /> fullscreen', () => {
7
+ it('opens and closes fullscreen', () => {
8
+ cy.readFile('cypress/test_files/sequencing/locus.gb', 'utf8').then((fileContent) => {
9
+ const sequenceModel = { id: 'locus-1', file_content: fileContent };
10
+ const sequenceData = convertToTeselaJson(sequenceModel);
11
+
12
+ cy.mount(<SequenceViewer sequenceData={sequenceData} alignmentData={null} />);
13
+
14
+ cy.contains('2026 bps').should('be.visible');
15
+ cy.get('[aria-label="Fullscreen"]').should('be.visible').click();
16
+
17
+ cy.get('[aria-label="Exit fullscreen"]').should('be.visible');
18
+ cy.get('[aria-label="Fullscreen"]').should('not.exist');
19
+
20
+ cy.get('[aria-label="Exit fullscreen"]').click();
21
+
22
+ cy.get('[aria-label="Exit fullscreen"]').should('not.exist');
23
+ cy.get('[aria-label="Fullscreen"]').should('be.visible');
24
+ });
25
+ });
26
+ });
27
+
@@ -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 useAlerts from '../../hooks/useAlerts';
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 } = useAlerts();
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 useAlerts from '../../hooks/useAlerts';
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 } = useAlerts();
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 } = useAlerts();
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
+ }
@@ -66,7 +66,6 @@ export default function GetRequestMultiSelect({
66
66
  <Autocomplete
67
67
  multiple={multiple}
68
68
  onChange={(event, value) => { onChange(value); }}
69
- id="tags-standard"
70
69
  options={options}
71
70
  getOptionLabel={getOptionLabel}
72
71
  defaultValue={multiple ? [] : ''}
@@ -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}