@opencloning/ui 1.5.5 → 1.5.6

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # @opencloning/ui
2
2
 
3
+ ## 1.5.6
4
+
5
+ ### Patch Changes
6
+
7
+ - [#665](https://github.com/manulera/OpenCloning_frontend/pull/665) [`d3477f1`](https://github.com/manulera/OpenCloning_frontend/commit/d3477f1354b92b61cb8f4299b4a9648b67f26ab0) Thanks [@manulera](https://github.com/manulera)! - Add support for user-defined recombinases
8
+
9
+ - Updated dependencies [[`d3477f1`](https://github.com/manulera/OpenCloning_frontend/commit/d3477f1354b92b61cb8f4299b4a9648b67f26ab0)]:
10
+ - @opencloning/utils@1.5.6
11
+ - @opencloning/store@1.5.6
12
+
3
13
  ## 1.5.5
4
14
 
5
15
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencloning/ui",
3
- "version": "1.5.5",
3
+ "version": "1.5.6",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
@@ -26,8 +26,8 @@
26
26
  "@mui/icons-material": "^5.15.17",
27
27
  "@mui/material": "^5.15.17",
28
28
  "@mui/x-data-grid": "^8.25.0",
29
- "@opencloning/store": "1.5.5",
30
- "@opencloning/utils": "1.5.5",
29
+ "@opencloning/store": "1.5.6",
30
+ "@opencloning/utils": "1.5.6",
31
31
  "@teselagen/bio-parsers": "^0.4.34",
32
32
  "@teselagen/ove": "^0.8.34",
33
33
  "@teselagen/range-utils": "^0.3.20",
@@ -416,6 +416,7 @@ function SyntaxOverviewButton({ syntax }) {
416
416
  open={open}
417
417
  onClose={() => setOpen(false)}
418
418
  fullWidth
419
+ fullScreen
419
420
  maxWidth="xl"
420
421
  PaperProps={{ sx: { height: '90vh' } }}
421
422
  >
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { useSelector } from 'react-redux';
3
- import { Alert, Button, Dialog, DialogContent } from '@mui/material';
3
+ import { Alert, Box, Button, Dialog, DialogContent, DialogTitle, Divider, Typography } from '@mui/material';
4
4
  import { isEqual } from 'lodash-es';
5
5
  import { enzymesInRestrictionEnzymeDigestionSource } from '@opencloning/utils/sourceFunctions';
6
6
  import PlannotateAnnotationReport from '../annotation/PlannotateAnnotationReport';
@@ -163,6 +163,50 @@ function GatewayMessage({ source }) {
163
163
  return `Gateway ${source.reaction_type} reaction`;
164
164
  }
165
165
 
166
+ function RecombinaseMessage({ source }) {
167
+ const [dialogOpen, setDialogOpen] = React.useState(false);
168
+ const recombinases = source.recombinases || [];
169
+ return (
170
+ <>
171
+ <Box>
172
+ Recombinase reaction
173
+ </Box>
174
+ <Button onClick={() => setDialogOpen(true)}>
175
+ See info
176
+ </Button>
177
+ <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
178
+ <DialogTitle>Recombinase details</DialogTitle>
179
+ <DialogContent dividers>
180
+ {recombinases.map((r, i) => (
181
+ <Box key={`${r.site1}-${r.site2}-${i}`}>
182
+ {i > 0 && <Divider sx={{ my: 2 }} />}
183
+ <Box sx={{ py: 1 }}>
184
+ {r.name && (
185
+ <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
186
+ {r.name}
187
+ </Typography>
188
+ )}
189
+ <Typography variant="body2" component="div" sx={{ fontFamily: 'monospace', fontSize: '0.9em' }}>
190
+ <Box component="span" sx={{ color: 'text.secondary', fontFamily: 'inherit', fontWeight: 500 }}>
191
+ {r.site1_name || 'site1'}:
192
+ </Box>
193
+ {' '}{r.site1}
194
+ </Typography>
195
+ <Typography variant="body2" component="div" sx={{ fontFamily: 'monospace', fontSize: '0.9em', mt: 0.5 }}>
196
+ <Box component="span" sx={{ color: 'text.secondary', fontFamily: 'inherit', fontWeight: 500 }}>
197
+ {r.site2_name || 'site2'}:
198
+ </Box>
199
+ {' '}{r.site2}
200
+ </Typography>
201
+ </Box>
202
+ </Box>
203
+ ))}
204
+ </DialogContent>
205
+ </Dialog>
206
+ </>
207
+ );
208
+ }
209
+
166
210
  function PlannotateAnnotationMessage({ source }) {
167
211
  const [dialogOpen, setDialogOpen] = React.useState(false);
168
212
  return (
@@ -338,6 +382,7 @@ function FinishedSource({ sourceId }) {
338
382
  case 'OverlapExtensionPCRLigationSource': message = 'Overlap extension PCR ligation'; break;
339
383
  case 'InFusionSource': message = 'In-Fusion assembly of fragments'; break;
340
384
  case 'CreLoxRecombinationSource': message = 'Cre/Lox recombination'; break;
385
+ case 'RecombinaseSource': message = <RecombinaseMessage source={source} />; break;
341
386
  case 'InVivoAssemblySource': message = 'In vivo assembly of fragments'; break;
342
387
  case 'RestrictionEnzymeDigestionSource': {
343
388
  const uniqueEnzymes = enzymesInRestrictionEnzymeDigestionSource(source);
@@ -0,0 +1,103 @@
1
+ import React from 'react';
2
+ import RecombinaseList from './RecombinaseList';
3
+ import { clearInputValue, setInputValue } from '../../../../../cypress/e2e/common_functions';
4
+
5
+ const initialRecombinases = [
6
+ { site1: 'AAaaTTC', site2: 'CCaaGC', site1_name: 'attB', site2_name: 'attP' },
7
+ ];
8
+
9
+ const twoRecombinases = [
10
+ ...initialRecombinases,
11
+ { site1: 'ATGCCCTAAaaCT', site2: 'CAaaTTTTTTTCCCT', site1_name: 'attB', site2_name: 'attP' },
12
+ ];
13
+
14
+ describe('<RecombinaseList />', () => {
15
+ it('enforces site pattern constraints and does not add invalid sites', () => {
16
+ const setRecombinasesSpy = cy.spy().as('setRecombinasesSpy');
17
+ cy.mount(<RecombinaseList recombinases={[]} setRecombinases={setRecombinasesSpy} />);
18
+
19
+ // Invalid: all uppercase (no lowercase region)
20
+ setInputValue('Site 1', 'AAAA', 'body');
21
+ setInputValue('Site 2', 'CCaaGC', 'body');
22
+ cy.get('button').contains('Add recombinase').click();
23
+ cy.get('@setRecombinasesSpy').should('not.have.been.called');
24
+ cy.contains('Sites must match').should('exist');
25
+
26
+ // Invalid: all lowercase
27
+ setInputValue('Site 1', 'aaaa', 'body');
28
+ setInputValue('Site 2', 'CCaaGC', 'body');
29
+ cy.get('button').contains('Add recombinase').click();
30
+ cy.get('@setRecombinasesSpy').should('not.have.been.called');
31
+
32
+ // Invalid: empty site
33
+ clearInputValue('Site 1', 'body');
34
+ setInputValue('Site 2', 'CCaaGC', 'body');
35
+ cy.get('button').contains('Add recombinase').click();
36
+ cy.get('@setRecombinasesSpy').should('not.have.been.called');
37
+ cy.contains('Required').should('exist');
38
+
39
+ // Valid: add with correct pattern
40
+ setInputValue('Site 1', 'AAaaTTC', 'body');
41
+ setInputValue('Site 2', 'CCaaGC', 'body');
42
+ cy.get('button').contains('Add recombinase').click();
43
+ cy.get('@setRecombinasesSpy').should('have.been.calledOnce');
44
+ cy.get('@setRecombinasesSpy').should('have.been.calledWith', [{
45
+ site1: 'AAaaTTC',
46
+ site2: 'CCaaGC',
47
+ site1_name: 'attB',
48
+ site2_name: 'attP',
49
+ }]);
50
+ });
51
+
52
+ it('displays initial recombinases and appends when adding (does not overwrite)', () => {
53
+ const setRecombinasesSpy = cy.spy().as('setRecombinasesSpy');
54
+ cy.mount(
55
+ <RecombinaseList recombinases={initialRecombinases} setRecombinases={setRecombinasesSpy} />
56
+ );
57
+
58
+ cy.get('.MuiChip-root').should('have.length', 1);
59
+ cy.get('.MuiChip-root').contains('AAaaTTC');
60
+
61
+ setInputValue('Site 1', 'ATGCCCTAAaaCT', 'body');
62
+ setInputValue('Site 2', 'CAaaTTTTTTTCCCT', 'body');
63
+ cy.get('button').contains('Add recombinase').click();
64
+
65
+ cy.get('@setRecombinasesSpy').should('have.been.calledWith', [
66
+ { site1: 'AAaaTTC', site2: 'CCaaGC', site1_name: 'attB', site2_name: 'attP' },
67
+ { site1: 'ATGCCCTAAaaCT', site2: 'CAaaTTTTTTTCCCT', site1_name: 'attB', site2_name: 'attP' },
68
+ ]);
69
+ });
70
+
71
+ it('calls setRecombinases with filtered array when deleting a chip', () => {
72
+ const setRecombinasesSpy = cy.spy().as('setRecombinasesSpy');
73
+ cy.mount(
74
+ <RecombinaseList recombinases={twoRecombinases} setRecombinases={setRecombinasesSpy} />
75
+ );
76
+
77
+ cy.get('.MuiChip-root').should('have.length', 2);
78
+ cy.get('.MuiChip-root').first().find('.MuiChip-deleteIcon').click();
79
+ cy.get('@setRecombinasesSpy').should('have.been.calledWith', [
80
+ { site1: 'ATGCCCTAAaaCT', site2: 'CAaaTTTTTTTCCCT', site1_name: 'attB', site2_name: 'attP' },
81
+ ]);
82
+ });
83
+
84
+ it('includes optional name and custom site names when provided', () => {
85
+ const setRecombinasesSpy = cy.spy().as('setRecombinasesSpy');
86
+ cy.mount(<RecombinaseList recombinases={[]} setRecombinases={setRecombinasesSpy} />);
87
+
88
+ setInputValue('Name (optional)', 'MyRecombinase', 'body');
89
+ setInputValue('Site 1', 'AAaaTTC', 'body');
90
+ setInputValue('Site 1 name', 'loxP', 'body');
91
+ setInputValue('Site 2', 'CCaaGC', 'body');
92
+ setInputValue('Site 2 name', 'loxR', 'body');
93
+ cy.get('button').contains('Add recombinase').click();
94
+
95
+ cy.get('@setRecombinasesSpy').should('have.been.calledWith', [{
96
+ name: 'MyRecombinase',
97
+ site1: 'AAaaTTC',
98
+ site2: 'CCaaGC',
99
+ site1_name: 'loxP',
100
+ site2_name: 'loxR',
101
+ }]);
102
+ });
103
+ });
@@ -0,0 +1,131 @@
1
+ import React from 'react';
2
+ import { Box, Button, Chip, FormControl, TextField } from '@mui/material';
3
+ import LabelWithTooltip from '../form/LabelWithTooltip';
4
+
5
+ const SITE_PATTERN = /^[A-Z]+[a-z]+[A-Z]+$/;
6
+ const DEFAULT_SITE1_NAME = 'attB';
7
+ const DEFAULT_SITE2_NAME = 'attP';
8
+ const SITE_PATTERN_HELP = `
9
+ Sites must match: uppercase-lowercase-uppercase (e.g. AAaaTTC, ATGCCCTAAaaCT).
10
+ Lowercase letters represent the homology region where sequences will be joined,
11
+ so the lowercase parts must match in both sites.
12
+ `;
13
+
14
+ function RecombinaseList({ recombinases, setRecombinases }) {
15
+ const [name, setName] = React.useState('');
16
+ const [site1, setSite1] = React.useState('');
17
+ const [site2, setSite2] = React.useState('');
18
+ const [site1Name, setSite1Name] = React.useState(DEFAULT_SITE1_NAME);
19
+ const [site2Name, setSite2Name] = React.useState(DEFAULT_SITE2_NAME);
20
+ const [site1Error, setSite1Error] = React.useState('');
21
+ const [site2Error, setSite2Error] = React.useState('');
22
+
23
+ const validateSite = (value) => {
24
+ if (!value.trim()) return 'Required';
25
+ if (!SITE_PATTERN.test(value)) return SITE_PATTERN_HELP;
26
+ return '';
27
+ };
28
+
29
+ const onAdd = () => {
30
+ const err1 = validateSite(site1);
31
+ const err2 = validateSite(site2);
32
+ setSite1Error(err1);
33
+ setSite2Error(err2);
34
+ if (err1 || err2) return;
35
+
36
+ const rec = {
37
+ site1: site1.trim(),
38
+ site2: site2.trim(),
39
+ site1_name: site1Name.trim() || DEFAULT_SITE1_NAME,
40
+ site2_name: site2Name.trim() || DEFAULT_SITE2_NAME,
41
+ ...(name.trim() && { name: name.trim() }),
42
+ };
43
+ setRecombinases([...recombinases, rec]);
44
+ setName('');
45
+ setSite1('');
46
+ setSite2('');
47
+ setSite1Name(DEFAULT_SITE1_NAME);
48
+ setSite2Name(DEFAULT_SITE2_NAME);
49
+ setSite1Error('');
50
+ setSite2Error('');
51
+ };
52
+
53
+ const onRemove = (index) => {
54
+ setRecombinases(recombinases.filter((_, i) => i !== index));
55
+ };
56
+
57
+ return (
58
+ <>
59
+ <FormControl fullWidth sx={{ mt: 1 }}>
60
+ <LabelWithTooltip
61
+ label="Recombinase recognition sites"
62
+ tooltip={SITE_PATTERN_HELP}
63
+ />
64
+ </FormControl>
65
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
66
+ <TextField
67
+ fullWidth
68
+ label="Name (optional)"
69
+ value={name}
70
+ onChange={(e) => setName(e.target.value)}
71
+ size="small"
72
+ />
73
+ <TextField
74
+ label="Site 1"
75
+ fullWidth
76
+ value={site1}
77
+ onChange={(e) => { setSite1(e.target.value); setSite1Error(''); }}
78
+ error={Boolean(site1Error)}
79
+ helperText={site1Error}
80
+ size="small"
81
+ placeholder="e.g. AAaaTTC"
82
+ sx={{ '& input': { fontFamily: 'monospace' } }}
83
+ />
84
+ <TextField
85
+ label="Site 1 name"
86
+ fullWidth
87
+ value={site1Name}
88
+ onChange={(e) => setSite1Name(e.target.value)}
89
+ size="small"
90
+ placeholder={DEFAULT_SITE1_NAME}
91
+ />
92
+ <TextField
93
+ label="Site 2"
94
+ fullWidth
95
+ value={site2}
96
+ onChange={(e) => { setSite2(e.target.value); setSite2Error(''); }}
97
+ error={Boolean(site2Error)}
98
+ helperText={site2Error}
99
+ size="small"
100
+ placeholder="e.g. CCaaGC"
101
+ sx={{ '& input': { fontFamily: 'monospace' } }}
102
+ />
103
+ <TextField
104
+ label="Site 2 name"
105
+ fullWidth
106
+ value={site2Name}
107
+ onChange={(e) => setSite2Name(e.target.value)}
108
+ size="small"
109
+ placeholder={DEFAULT_SITE2_NAME}
110
+ />
111
+ <Button variant="outlined" onClick={onAdd} size="small" sx={{ alignSelf: 'flex-start' }}>
112
+ Add recombinase
113
+ </Button>
114
+ </Box>
115
+ {recombinases.length > 0 && (
116
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1 }}>
117
+ {recombinases.map((rec, i) => (
118
+ <Chip
119
+ key={`${rec.site1}-${rec.site2}-${i}`}
120
+ label={rec.name || `${rec.site1} / ${rec.site2}`}
121
+ onDelete={() => onRemove(i)}
122
+ size="small"
123
+ />
124
+ ))}
125
+ </Box>
126
+ )}
127
+ </>
128
+ );
129
+ }
130
+
131
+ export default React.memo(RecombinaseList);
@@ -80,6 +80,8 @@ function Source({ sourceId }) {
80
80
  specificSource = <SourceAssembly {...{ source, requestStatus, sendPostRequest }} />; break;
81
81
  case 'CreLoxRecombinationSource':
82
82
  specificSource = <SourceAssembly {...{ source, requestStatus, sendPostRequest }} />; break;
83
+ case 'RecombinaseSource':
84
+ specificSource = <SourceAssembly {...{ source, requestStatus, sendPostRequest }} />; break;
83
85
  case 'HomologousRecombinationSource':
84
86
  specificSource = <SourceHomologousRecombination {...{ source, requestStatus, sendPostRequest }} />; break;
85
87
  case 'PCRSource':
@@ -8,6 +8,7 @@ import SubmitButtonBackendAPI from '../form/SubmitButtonBackendAPI';
8
8
  import { classNameToEndPointMap } from '@opencloning/utils/sourceFunctions';
9
9
  import { cloningActions } from '@opencloning/store/cloning';
10
10
  import LabelWithTooltip from '../form/LabelWithTooltip';
11
+ import RecombinaseList from './RecombinaseList';
11
12
 
12
13
  const helpSingleSite = 'Even if input sequences contain multiple att sites '
13
14
  + '(typically 2), a product could be generated where only one site recombines. '
@@ -25,6 +26,8 @@ function SourceAssembly({ source, requestStatus, sendPostRequest }) {
25
26
  const [bluntLigation, setBluntLigation] = React.useState(false);
26
27
  const [gatewaySettings, setGatewaySettings] = React.useState({ greedy: false, reactionType: null, onlyMultiSite: true });
27
28
  const [enzymes, setEnzymes] = React.useState([]);
29
+ const [recombinases, setRecombinases] = React.useState([]);
30
+ const [reverseRecombinase, setReverseRecombinase] = React.useState(false);
28
31
 
29
32
  const dispatch = useDispatch();
30
33
 
@@ -45,6 +48,7 @@ function SourceAssembly({ source, requestStatus, sendPostRequest }) {
45
48
  const preventSubmit = (
46
49
  (assemblyType === 'RestrictionAndLigationSource' && enzymes.length === 0)
47
50
  || (assemblyType === 'GatewaySource' && gatewaySettings.reactionType === null)
51
+ || (assemblyType === 'RecombinaseSource' && recombinases.length === 0)
48
52
  || inputContainsTemplates
49
53
  );
50
54
 
@@ -90,6 +94,11 @@ function SourceAssembly({ source, requestStatus, sendPostRequest }) {
90
94
  sendPostRequest({ endpoint: 'gateway', requestData, config, source });
91
95
  } else if (assemblyType === 'CreLoxRecombinationSource') {
92
96
  sendPostRequest({ endpoint: 'cre_lox_recombination', requestData, source });
97
+ } else if (assemblyType === 'RecombinaseSource') {
98
+ if (recombinases.length === 0) { return; }
99
+ requestData.source.recombinases = recombinases;
100
+ const config = { params: { reverse_recombinase: reverseRecombinase } };
101
+ sendPostRequest({ endpoint: 'recombinase', requestData, config, source });
93
102
  } else {
94
103
  const config = { params: {
95
104
  allow_partial_overlap: allowPartialOverlap,
@@ -120,22 +129,38 @@ function SourceAssembly({ source, requestStatus, sendPostRequest }) {
120
129
  </FormControl>
121
130
  { ['GibsonAssemblySource', 'OverlapExtensionPCRLigationSource', 'InFusionSource', 'InVivoAssemblySource'].includes(assemblyType) && (
122
131
  // I don't really understand why fullWidth is required here
123
- <FormControl fullWidth>
124
- <TextField
125
- label="Minimal homology length"
126
- value={minimalHomology}
127
- onChange={(e) => { setMinimalHomology(e.target.value); }}
128
- type="number"
129
- defaultValue={20}
130
- InputProps={{
131
- endAdornment: <InputAdornment position="end">bp</InputAdornment>,
132
- sx: { '& input': { textAlign: 'center' } },
133
- }}
134
- />
135
- </FormControl>
132
+ <FormControl fullWidth>
133
+ <TextField
134
+ label="Minimal homology length"
135
+ value={minimalHomology}
136
+ onChange={(e) => { setMinimalHomology(e.target.value); }}
137
+ type="number"
138
+ defaultValue={20}
139
+ InputProps={{
140
+ endAdornment: <InputAdornment position="end">bp</InputAdornment>,
141
+ sx: { '& input': { textAlign: 'center' } },
142
+ }}
143
+ />
144
+ </FormControl>
136
145
  )}
137
146
  { (assemblyType === 'RestrictionAndLigationSource') && (
138
- <EnzymeMultiSelect setEnzymes={setEnzymes} />
147
+ <EnzymeMultiSelect setEnzymes={setEnzymes} />
148
+ )}
149
+ { (assemblyType === 'RecombinaseSource') && (
150
+ <>
151
+ <RecombinaseList recombinases={recombinases} setRecombinases={setRecombinases} />
152
+ <FormControl fullWidth style={{ textAlign: 'left' }}>
153
+ <FormControlLabel
154
+ control={<Checkbox checked={reverseRecombinase} onChange={() => setReverseRecombinase(!reverseRecombinase)} />}
155
+ label={(
156
+ <LabelWithTooltip
157
+ label="Reverse reaction"
158
+ tooltip="Include the reverse reaction of each recombinase (e.g. excision in addition to integration)"
159
+ />
160
+ )}
161
+ />
162
+ </FormControl>
163
+ </>
139
164
  )}
140
165
  { (assemblyType === 'GatewaySource') && (
141
166
  <>
@@ -181,9 +206,9 @@ function SourceAssembly({ source, requestStatus, sendPostRequest }) {
181
206
  </FormControl>
182
207
  )}
183
208
  { (assemblyType === 'LigationSource') && (
184
- <FormControl fullWidth style={{ textAlign: 'left' }}>
185
- <FormControlLabel control={<Checkbox checked={bluntLigation} onChange={flipBluntLigation} />} label="Blunt ligation" />
186
- </FormControl>
209
+ <FormControl fullWidth style={{ textAlign: 'left' }}>
210
+ <FormControlLabel control={<Checkbox checked={bluntLigation} onChange={flipBluntLigation} />} label="Blunt ligation" />
211
+ </FormControl>
187
212
  )}
188
213
 
189
214
  {!preventSubmit && (
@@ -64,6 +64,7 @@ function SourceTypeSelector({ source }) {
64
64
  options.push(<MenuItem key="InVivoAssemblySource" value="InVivoAssemblySource">In vivo assembly</MenuItem>);
65
65
  options.push(<MenuItem key="GatewaySource" value="GatewaySource">Gateway</MenuItem>);
66
66
  options.push(<MenuItem key="CreLoxRecombinationSource" value="CreLoxRecombinationSource">Cre/Lox recombination</MenuItem>);
67
+ options.push(<MenuItem key="RecombinaseSource" value="RecombinaseSource">Recombinase</MenuItem>);
67
68
  }
68
69
 
69
70
  // Sort options by text content
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Version placeholder - replaced at publish time via prepack script
2
- export const version = "1.5.5";
2
+ export const version = "1.5.6";