@opencloning/ui 2.1.0 → 2.2.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/package.json +8 -8
  3. package/src/StandAloneOpenCloning.js +4 -4
  4. package/src/components/MainSequenceEditor.jsx +1 -1
  5. package/src/components/SequenceViewer.cy.jsx +0 -1
  6. package/src/components/SequenceViewer.jsx +13 -16
  7. package/src/components/assembler/Assembler.cy.jsx +3 -3
  8. package/src/components/assembler/ExistingSyntaxDialog.cy.jsx +1 -1
  9. package/src/components/assembler/ExistingSyntaxDialog.jsx +1 -1
  10. package/src/components/assembler/SyntaxOverviewTable.jsx +3 -3
  11. package/src/components/assembler/UploadPlasmidsButton.cy.jsx +35 -5
  12. package/src/components/assembler/UploadPlasmidsButton.jsx +1 -1
  13. package/src/components/assembler/assembler_utils.js +3 -1
  14. package/src/components/assembler/assembler_utils.test.js +25 -6
  15. package/src/components/assembler/graph_utils.js +17 -17
  16. package/src/components/assembler/graph_utils.test.js +7 -7
  17. package/src/components/assembler/sbol_visual_glyphs.js +0 -1
  18. package/src/components/assembler/useAssembler.js +0 -1
  19. package/src/components/domesticate/DomesticateDialog.jsx +1 -1
  20. package/src/components/dummy/DummyInterface.js +2 -1
  21. package/src/components/form/ChipMultiSelect.cy.jsx +43 -0
  22. package/src/components/form/ChipMultiSelect.jsx +60 -0
  23. package/src/components/form/GetRequestMultiSelect.jsx +1 -1
  24. package/src/components/index.js +1 -0
  25. package/src/components/navigation/MainAppBar.css +0 -2
  26. package/src/components/navigation/MainAppBar.jsx +1 -1
  27. package/src/components/primers/PrimerList.jsx +1 -1
  28. package/src/components/primers/import_primers/styles.css +0 -1
  29. package/src/components/settings/SettingsTab.jsx +0 -2
  30. package/src/components/sources/SourceGenomeRegion.cy.jsx +2 -4
  31. package/src/components/sources/repositoryMetadata.js +0 -1
  32. package/src/components/verification/VerificationFileDialog.jsx +3 -3
  33. package/src/hooks/useInitializeApp.js +0 -1
  34. package/src/hooks/useSequencingAlignment.js +2 -9
  35. package/src/hooks/useUrlParamsLoader.js +24 -0
  36. package/src/index.js +0 -1
  37. package/src/providers/ConfigProvider.jsx +4 -4
  38. package/src/version.js +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @opencloning/ui
2
2
 
3
+ ## 2.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#790](https://github.com/OpenCloning/OpenCloning_frontend/pull/790) [`0b2a21d`](https://github.com/OpenCloning/OpenCloning_frontend/commit/0b2a21dde79f5539579bee39f0de49b01cd6c10c) Thanks [@manulera](https://github.com/manulera)! - Support for fetchin sequences created by the OC mcp server
8
+
9
+ - Updated dependencies [[`0b2a21d`](https://github.com/OpenCloning/OpenCloning_frontend/commit/0b2a21dde79f5539579bee39f0de49b01cd6c10c)]:
10
+ - @opencloning/utils@2.1.1
11
+ - @opencloning/store@2.1.1
12
+
3
13
  ## 2.1.0
4
14
 
5
15
  ### Minor Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "license": "MIT",
3
3
  "name": "@opencloning/ui",
4
- "version": "2.1.0",
4
+ "version": "2.2.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "scripts": {
@@ -31,8 +31,8 @@
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
34
- "url": "https://github.com/OpenCloning/OpenCloning_frontend.git",
35
- "directory": "packages/ui"
34
+ "url": "https://github.com/OpenCloning/OpenCloning.git",
35
+ "directory": "js/packages/ui"
36
36
  },
37
37
  "dependencies": {
38
38
  "@emotion/react": "^11.14.0",
@@ -40,16 +40,16 @@
40
40
  "@mui/icons-material": "^5.18.0",
41
41
  "@mui/material": "^5.18.0",
42
42
  "@mui/x-data-grid": "^8.28.5",
43
- "@opencloning/store": "2.1.0",
44
- "@opencloning/utils": "2.1.0",
43
+ "@opencloning/store": "2.2.0",
44
+ "@opencloning/utils": "2.2.0",
45
45
  "@teselagen/bio-parsers": "^0.4.38",
46
46
  "@teselagen/ove": "^0.8.42",
47
47
  "@teselagen/range-utils": "^0.3.20",
48
48
  "@teselagen/sequence-utils": "^0.3.42",
49
- "@zip.js/zip.js": "^2.8.26",
50
- "axios": "^1.20.0",
49
+ "@zip.js/zip.js": "^2.24.0",
50
+ "axios": "^1.21.0",
51
51
  "lodash-es": "^4.18.1",
52
- "react-draggable": "^4.5.0"
52
+ "react-draggable": "^4.7.2"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "react": "^18.3.1",
@@ -9,24 +9,24 @@ import './index.css';
9
9
 
10
10
  /**
11
11
  * StandAloneOpenCloning - Mounts the OpenCloning component into a DOM element
12
- *
12
+ *
13
13
  * This function handles all the setup required for OpenCloning:
14
14
  * - Redux Provider with store
15
15
  * - ConfigProvider with config
16
16
  * - React rendering
17
- *
17
+ *
18
18
  * @param {Object} options - Configuration options
19
19
  * @param {Object} options.config - Configuration object for OpenCloning
20
20
  * @param {HTMLElement} options.element - DOM element where to mount the component
21
21
  * @returns {Function} Cleanup function that unmounts the component
22
- *
22
+ *
23
23
  * @example
24
24
  * const container = document.getElementById('opencloning-container');
25
25
  * const cleanup = StandAloneOpenCloning({
26
26
  * config: { /* config object *\/ },
27
27
  * element: container
28
28
  * });
29
- *
29
+ *
30
30
  * // Later, to unmount:
31
31
  * cleanup();
32
32
  */
@@ -169,7 +169,7 @@ function MainSequenceEditor() {
169
169
  <AnnotationChangedAlert onSave={updateAnnotationInMainSequence} onCancel={onAnnotationCancel} />
170
170
  }
171
171
  <Editor {...{ editorName, ...defaultMainEditorProps, ...extraProp, height: '800' }} />
172
- {domesticateDialogOpen &&
172
+ {domesticateDialogOpen &&
173
173
  <DomesticateDialog
174
174
  open={domesticateDialogOpen}
175
175
  onClose={() => {
@@ -24,4 +24,3 @@ describe('<SequenceViewer /> fullscreen', () => {
24
24
  });
25
25
  });
26
26
  });
27
-
@@ -88,7 +88,7 @@ const baseViewerProps = {
88
88
  rightClickOverrides,
89
89
  };
90
90
 
91
- function SequenceViewer({ sequenceData, alignmentData, onUpdateAnnotation }) {
91
+ function SequenceViewer({ sequenceData, alignmentData, onUpdateAnnotation, readOnly = false }) {
92
92
  const store = useStore();
93
93
  const [isFullscreen, setIsFullscreen] = useState(false);
94
94
 
@@ -141,32 +141,29 @@ function SequenceViewer({ sequenceData, alignmentData, onUpdateAnnotation }) {
141
141
 
142
142
  const viewerProps = {
143
143
  ...baseViewerProps,
144
+ readOnly,
144
145
  isFullscreen,
145
146
  };
146
147
 
147
148
  React.useEffect(() => {
148
- updateEditor(store, EDITOR_NAME, { ...baseViewerProps, sequenceData, sequenceDataHistory: {} });
149
- }, [store, sequenceData]);
149
+ updateEditor(store, EDITOR_NAME, { ...baseViewerProps, readOnly, sequenceData, sequenceDataHistory: {} });
150
+ }, [store, sequenceData, readOnly]);
150
151
 
151
152
  React.useEffect(() => {
152
- const editorUpdate = {};
153
+ const editorState = store.getState().VectorEditor?.[EDITOR_NAME];
154
+ let panelsShown = editorState?.panelsShown ?? baseViewerProps.panelsShown;
153
155
 
154
156
  if (alignmentData) {
155
157
  addAlignment(store, alignmentData);
156
- const editorState = store.getState().VectorEditor?.[EDITOR_NAME];
157
- const currentPanels = editorState?.panelsShown || [[]];
158
- editorUpdate.panelsShown = updatePanelsShownWithAlignment(currentPanels);
158
+ panelsShown = updatePanelsShownWithAlignment(panelsShown);
159
159
  } else {
160
- const editorState = store.getState().VectorEditor?.[EDITOR_NAME];
161
- if (editorState?.panelsShown) {
162
- editorUpdate.panelsShown = removePanelFromShown(editorState.panelsShown, 'simpleAlignment');
163
- }
160
+ panelsShown = removePanelFromShown(panelsShown, 'simpleAlignment');
164
161
  }
165
162
 
166
- editorUpdate.panelsShown[0].find((panel) => panel.id === 'rail').active = !sequenceData.circular;
167
- editorUpdate.panelsShown[0].find((panel) => panel.id === 'circular').active = sequenceData.circular;
168
-
169
- updateEditor(store, EDITOR_NAME, editorUpdate);
163
+ // A single active tab: the alignment if there is one, otherwise the map that matches the topology
164
+ const activePanelId = alignmentData ? 'simpleAlignment' : (sequenceData.circular ? 'circular' : 'rail');
165
+ const panelsWithActive = panelsShown[0].map((panel) => ({ ...panel, active: panel.id === activePanelId }));
166
+ updateEditor(store, EDITOR_NAME, { panelsShown: [panelsWithActive] });
170
167
 
171
168
  }, [sequenceData.circular, store, alignmentData]);
172
169
 
@@ -210,7 +207,7 @@ function SequenceViewer({ sequenceData, alignmentData, onUpdateAnnotation }) {
210
207
  </IconButton>
211
208
  )}
212
209
  {fullscreenExitButton}
213
- {annotationChanged && <AnnotationChangedAlert onSave={handleUpdateAnnotation} onCancel={clearHistory} />}
210
+ {annotationChanged && !readOnly && <AnnotationChangedAlert onSave={handleUpdateAnnotation} onCancel={clearHistory} />}
214
211
  <Editor
215
212
  editorName={EDITOR_NAME}
216
213
  {...viewerProps}
@@ -144,7 +144,7 @@ describe('<AssemblerComponent />', () => {
144
144
  cy.get('input').click();
145
145
  });
146
146
  cy.get('li').contains('Test Plasmid 1').click();
147
-
147
+
148
148
  // Select second plasmid
149
149
  cy.get('[data-testid="plasmid-select"]').eq(1).within(() => {
150
150
  cy.get('input').click();
@@ -284,13 +284,13 @@ describe('<AssemblerComponent />', () => {
284
284
  cy.get('[data-testid="assembler-download-assemblies-button"]').click();
285
285
 
286
286
  // Check that the download was triggered (createObjectURL receives the zip Blob)
287
-
287
+
288
288
  cy.get('@createObjectURL').should((stub) => {
289
289
  expect(stub).to.have.been.calledOnce;
290
290
  expect(stub.firstCall.args[0]).to.be.instanceOf(Blob);
291
291
  expect(stub.firstCall.args[0].type).to.equal('application/zip');
292
292
  });
293
-
293
+
294
294
  cy.get('@addAlert').should('have.not.been.called');
295
295
 
296
296
  });
@@ -194,7 +194,7 @@ describe('<ExistingSyntaxDialog />', () => {
194
194
  );
195
195
 
196
196
  cy.wait('@getSyntaxes');
197
-
197
+
198
198
  // Click first syntax (fails)
199
199
  cy.contains('Test Syntax 1').click();
200
200
  cy.wait('@getSyntaxDataError');
@@ -67,7 +67,7 @@ function SyntaxListItem({ syntax, onSyntaxClick }) {
67
67
  </AccordionDetails>
68
68
  </Accordion>
69
69
  </ListItem>
70
-
70
+
71
71
  )
72
72
  }
73
73
 
@@ -9,7 +9,7 @@ function OverhangRow({ row, parts, mode = 'detailed' }) {
9
9
 
10
10
  const rows2iterate = [...row];
11
11
  const actualRows =[];
12
-
12
+
13
13
  let currentCell = [rows2iterate.shift(), 1];
14
14
  while (currentCell[0] !== undefined) {
15
15
  if (rows2iterate[0] === GRAPH_SPACER) {
@@ -40,7 +40,7 @@ function OverhangRow({ row, parts, mode = 'detailed' }) {
40
40
  {actualRows.flatMap(
41
41
  (cell, index) => {
42
42
  const showRight = index === actualRows.length - 1;
43
-
43
+
44
44
  const colSpan = (cell[1]-1)*2 + 1;
45
45
  const { left_overhang, right_overhang, left_inside, color, glyph } = cell[2];
46
46
  const { leftTranslationOverhang, leftTranslationInside, leftOverhangRc, rightOverhangRc, leftInsideRc } = partDataToDisplayData(cell[2]);
@@ -58,7 +58,7 @@ function OverhangRow({ row, parts, mode = 'detailed' }) {
58
58
  </AssemblerPartContainer>
59
59
  </TableCell>
60
60
  {showRight && (
61
- <TableCell
61
+ <TableCell
62
62
  key={index}
63
63
  sx={{ padding: 0, borderRight: 'solid 1px gray' }}
64
64
  >
@@ -172,14 +172,14 @@ describe('<UploadPlasmidsButton />', () => {
172
172
  <UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
173
173
  </ConfigProvider>,
174
174
  );
175
-
175
+
176
176
  cy.get('[data-testid="load-plasmids-file-input"]').selectFile([
177
177
  'cypress/test_files/syntax/pYTK002.gb',
178
178
  'cypress/test_files/syntax/moclo_ytk_multi_part.gb',
179
179
  'cypress/test_files/syntax/pYTK095.gb',
180
180
  'cypress/test_files/sequencing/locus.gb'],
181
181
  { force: true });
182
-
182
+
183
183
  cy.get('.MuiDialog-root', { timeout: 10000 }).should('be.visible');
184
184
 
185
185
  cy.get('button').contains('Cancel').click();
@@ -240,7 +240,7 @@ describe('<UploadPlasmidsButton />', () => {
240
240
  return Promise.resolve({
241
241
  data: dummyIndex,
242
242
  });
243
- }
243
+ }
244
244
  if (url.endsWith('/example2.gb')) {
245
245
  return Promise.resolve({ data: 'wrong_format' });
246
246
  }
@@ -294,7 +294,10 @@ describe('<UploadPlasmidsButton />', () => {
294
294
  ];
295
295
  const mockDatabase = {
296
296
  name: 'MockDB',
297
- GetSequencesInBulkComponent: ({ onSequencesLoaded }) => <button type="button" onClick={() => onSequencesLoaded(entries)}>Mock load</button>,
297
+ // Shows the default sequence types it receives, to check that plasmids are selected by default
298
+ GetSequencesInBulkComponent: ({ onSequencesLoaded, defaultSequenceTypes }) => (
299
+ <button type="button" data-default-types={JSON.stringify(defaultSequenceTypes)} onClick={() => onSequencesLoaded(entries)}>Mock load</button>
300
+ ),
298
301
  };
299
302
  const addPlasmidsSpy = cy.spy().as('addPlasmidsSpy');
300
303
  cy.mount(
@@ -306,7 +309,7 @@ describe('<UploadPlasmidsButton />', () => {
306
309
  );
307
310
  cy.get('[data-testid="load-plasmids-button"]').click();
308
311
  cy.get('[data-testid="load-plasmids-from-database"]').contains('From MockDB').click();
309
- cy.contains('button', 'Mock load').click();
312
+ cy.contains('button', 'Mock load').should('have.attr', 'data-default-types', '["plasmid"]').click();
310
313
  // The bulk dialog closes and the valid / invalid dialog is shown
311
314
  cy.contains('button', 'Mock load').should('not.exist');
312
315
  cy.get('[data-testid="invalid-plasmids-box"]').contains('multi_part_db');
@@ -330,6 +333,33 @@ describe('<UploadPlasmidsButton />', () => {
330
333
  });
331
334
  });
332
335
 
336
+ it('shows linear sequences with a single cut (e.g. PCR products) as invalid, without errors', () => {
337
+ // Linear, with a single BsaI site, so all its fragments end at the sequence ends
338
+ const pcrProduct = [
339
+ 'LOCUS pcr_product 28 bp DNA linear UNK 01-JAN-1980',
340
+ 'ORIGIN',
341
+ ' 1 tgggtctcac cctagagtca cacaggac',
342
+ '//',
343
+ ].join('\n');
344
+ const mockDatabase = {
345
+ name: 'MockDB',
346
+ GetSequencesInBulkComponent: ({ onSequencesLoaded }) => <button type="button" onClick={() => onSequencesLoaded([{ databaseId: 1, name: 'pcr_product_db', textFileSequence: { file_content: pcrProduct } }])}>Mock load</button>,
347
+ };
348
+ cy.mount(
349
+ <ConfigProvider config={testConfig}>
350
+ <DatabaseProvider value={mockDatabase}>
351
+ <UploadPlasmidsButton addPlasmids={cy.spy()} syntax={mocloSyntax} />
352
+ </DatabaseProvider>
353
+ </ConfigProvider>,
354
+ );
355
+ cy.get('[data-testid="load-plasmids-button"]').click();
356
+ cy.get('[data-testid="load-plasmids-from-database"]').contains('From MockDB').click();
357
+ cy.contains('button', 'Mock load').click();
358
+ cy.get('[data-testid="invalid-plasmids-box"]').contains('pcr_product_db');
359
+ cy.get('[data-testid="valid-plasmids-box"]').should('not.exist');
360
+ cy.contains('button', 'Import valid plasmids').should('be.disabled');
361
+ });
362
+
333
363
  it('shows an error if a database sequence cannot be parsed', () => {
334
364
  const mockDatabase = {
335
365
  name: 'MockDB',
@@ -172,7 +172,7 @@ function UploadPlasmidsButton({ addPlasmids, syntax }) {
172
172
  )}
173
173
  {openDialog === 'database' && (
174
174
  <LoadPlasmidsDialog title={`Load Plasmids from ${database.name}`} onLoad={loadPlasmidsFromDatabase} onClose={closeDialog}>
175
- {(onLoad) => <database.GetSequencesInBulkComponent onSequencesLoaded={onLoad} />}
175
+ {(onLoad) => <database.GetSequencesInBulkComponent onSequencesLoaded={onLoad} defaultSequenceTypes={['plasmid']} />}
176
176
  </LoadPlasmidsDialog>
177
177
  )}
178
178
  <ImportPlasmidsDialog
@@ -92,11 +92,13 @@ export function longestFeatureInDigestFragment(digestFragment, sequenceData) {
92
92
  export function getSimplifiedDigestFragments(sequenceData, enzymes) {
93
93
  const { sequence, circular } = sequenceData;
94
94
 
95
+ // In linear sequences, the fragments at the ends have a "cut" at the start / end of the sequence,
96
+ // which has no overhang, so they cannot be parts.
95
97
  const digestFragments = getDigestFragmentsForRestrictionEnzymes(
96
98
  sequence,
97
99
  circular,
98
100
  enzymes,
99
- );
101
+ ).filter(({ cut1, cut2 }) => cut1.type !== 'START_OR_END_OF_SEQ' && cut2.type !== 'START_OR_END_OF_SEQ');
100
102
 
101
103
  const longestFeatures = digestFragments.map(fragment => longestFeatureInDigestFragment(fragment, sequenceData));
102
104
  const simplifiedDigestFragments = digestFragments.map(simplifyDigestFragment);
@@ -179,6 +179,25 @@ describe('assignSequenceToSyntaxPart', () => {
179
179
  const result = assignSequenceToSyntaxPart(sequenceData, enzymes, graph);
180
180
  expect(result).toEqual([]);
181
181
  })
182
+
183
+ it('ignores the fragments at the ends of linear sequences (e.g. PCR products)', () => {
184
+ const parts = [{left_overhang: 'TACT', right_overhang: 'AATG'}, {left_overhang: 'AATG', right_overhang: 'AGGT'}];
185
+ const graph = partsToEdgesGraph(parts);
186
+ const enzymes = [aliasedEnzymesByName["bsai"]];
187
+ // The part between the two cuts is found, the fragments up to the sequence ends are ignored
188
+ expect(assignSequenceToSyntaxPart({ sequence: sequenceBsaI, circular: false }, enzymes, graph))
189
+ .toEqual([{left_overhang: 'TACT', right_overhang: 'AATG', longestFeature: null}]);
190
+ // A single cut only gives fragments with a sequence end
191
+ expect(assignSequenceToSyntaxPart({ sequence: 'tgggtctcaTACTagagtc', circular: false }, enzymes, graph)).toEqual([]);
192
+ })
193
+
194
+ it('does not assign a sequence that the enzyme does not cut', () => {
195
+ const parts = [{left_overhang: 'TACT', right_overhang: 'AATG'}, {left_overhang: 'AATG', right_overhang: 'AGGT'}];
196
+ const graph = partsToEdgesGraph(parts);
197
+ const enzymes = [aliasedEnzymesByName["bsai"]];
198
+ expect(assignSequenceToSyntaxPart({ sequence: 'AAAAAAA', circular: true }, enzymes, graph)).toEqual([]);
199
+ expect(assignSequenceToSyntaxPart({ sequence: 'AAAAAAA', circular: false }, enzymes, graph)).toEqual([]);
200
+ })
182
201
  });
183
202
 
184
203
  describe('tripletsToTranslation', () => {
@@ -195,10 +214,10 @@ describe('tripletsToTranslation', () => {
195
214
  it('translates valid triplets to amino acid codes', () => {
196
215
  // ATG = Methionine (Met)
197
216
  expect(tripletsToTranslation(['ATG'])).toBe('Met');
198
-
217
+
199
218
  // TTT = Phenylalanine (Phe)
200
219
  expect(tripletsToTranslation(['TTT'])).toBe('Phe');
201
-
220
+
202
221
  // Multiple triplets
203
222
  expect(tripletsToTranslation(['ATG', 'TTT', 'GCA'])).toBe('MetPheAla');
204
223
  });
@@ -230,9 +249,9 @@ describe('partDataToDisplayData', () => {
230
249
  left_inside: 'TTAA',
231
250
  right_inside: 'AATT'
232
251
  };
233
-
252
+
234
253
  const result = partDataToDisplayData(data);
235
-
254
+
236
255
  expect(result.leftOverhangRc).toBe(getComplementSequenceString('ATGC'));
237
256
  expect(result.rightOverhangRc).toBe(getComplementSequenceString('CGTA'));
238
257
  expect(result.leftInsideRc).toBe(getComplementSequenceString('TTAA'));
@@ -252,9 +271,9 @@ describe('partDataToDisplayData', () => {
252
271
  right_overhang: '',
253
272
  right_inside: ''
254
273
  };
255
-
274
+
256
275
  const result = partDataToDisplayData(data);
257
-
276
+
258
277
  // ATG CGC A... should translate to Met Arg...
259
278
  expect(result.leftTranslationOverhang).toBe('Me');
260
279
  expect(result.leftTranslationInside).toBe('tArg');
@@ -46,41 +46,41 @@ export function openCycleAtNode(graph, cutNode) {
46
46
  }
47
47
  return newGraph;
48
48
  }
49
-
49
+
50
50
  // Convert DAG to MSA-like matrix (rows = paths, columns = topological generations)
51
51
  function dagToMSA(graph) {
52
52
  const generations = topologicalGenerations(graph);
53
53
  const nodeToCol = new Map();
54
54
  generations.forEach((gen, col) => gen.forEach(node => nodeToCol.set(node, col)));
55
-
55
+
56
56
  const sources = graph.nodes().filter(n => graph.inDegree(n) === 0);
57
57
  const sinks = graph.nodes().filter(n => graph.outDegree(n) === 0);
58
-
58
+
59
59
  const allPaths = sources.flatMap(src => sinks.flatMap(sink => allSimplePaths(graph, src, sink)));
60
-
60
+
61
61
  return allPaths.map(path => {
62
62
  const row = new Array(generations.length).fill(GRAPH_SPACER);
63
63
  path.forEach(node => { row[nodeToCol.get(node)] = node; });
64
64
  return row;
65
65
  });
66
66
  }
67
-
67
+
68
68
  // Find independent segments in the MSA
69
69
  // Two adjacent columns are in the same segment if they're correlated (vary together)
70
70
  // They're independent if all combinations of values exist
71
71
  function getIndependentSegments(msa) {
72
72
  if (msa.length === 0) return [];
73
-
73
+
74
74
  const numCols = msa[0].length;
75
75
  const uniqueValues = col => new Set(msa.map(row => row[col]));
76
76
  const isStable = col => uniqueValues(col).size === 1;
77
-
77
+
78
78
  // Check if two columns vary independently
79
79
  const areIndependent = (col1, col2) => {
80
80
  const pairs = new Set(msa.map(row => `${row[col1]}|${row[col2]}`));
81
81
  return pairs.size > Math.max(uniqueValues(col1).size, uniqueValues(col2).size);
82
82
  };
83
-
83
+
84
84
  // Find segment boundaries
85
85
  const boundaries = [0];
86
86
  for (let col = 0; col < numCols - 1; col++) {
@@ -90,7 +90,7 @@ function getIndependentSegments(msa) {
90
90
  }
91
91
  }
92
92
  boundaries.push(numCols);
93
-
93
+
94
94
  // Build segments
95
95
  return boundaries.slice(0, -1).map((start, i) => {
96
96
  const end = boundaries[i + 1] - 1;
@@ -98,17 +98,17 @@ function getIndependentSegments(msa) {
98
98
  return { start, end, stable: isStable(start), alternatives: alts };
99
99
  });
100
100
  }
101
-
101
+
102
102
  // Find minimum set of rows that covers all unique alternatives (greedy set cover)
103
103
  function minimumCoveringRows(msa) {
104
104
  if (msa.length === 0) return [];
105
-
105
+
106
106
  const segments = getIndependentSegments(msa).filter(s => !s.stable);
107
107
  if (segments.length === 0) return [msa[0]];
108
-
108
+
109
109
  // All alternatives that need to be covered
110
110
  const allAlts = new Set(segments.flatMap((seg, i) => seg.alternatives.map(alt => `${i}:${alt}`)));
111
-
111
+
112
112
  // Count non-gap elements in a row (gap is '---------')
113
113
  const countElements = row => row.filter(cell => cell !== GRAPH_SPACER).length;
114
114
 
@@ -118,12 +118,12 @@ function minimumCoveringRows(msa) {
118
118
  elements: countElements(row),
119
119
  covered: new Set(segments.map((seg, i) => `${i}:${row.slice(seg.start, seg.end + 1).join(' | ')}`))
120
120
  }));
121
-
121
+
122
122
  // Greedy: pick rows that cover the most uncovered alternatives
123
123
  // When tied, prefer rows with more elements (fewer gaps)
124
124
  const selected = [];
125
125
  const covered = new Set();
126
-
126
+
127
127
  while (covered.size < allAlts.size) {
128
128
  const best = rowCoverage.reduce((best, rc) => {
129
129
  const newCount = [...rc.covered].filter(a => !covered.has(a)).length;
@@ -132,12 +132,12 @@ function minimumCoveringRows(msa) {
132
132
  if (newCount === best.count && rc.elements > best.elements) return { rc, count: newCount, elements: rc.elements };
133
133
  return best;
134
134
  }, { rc: null, count: 0, elements: -1 });
135
-
135
+
136
136
  if (!best.rc) break;
137
137
  selected.push(best.rc.row);
138
138
  best.rc.covered.forEach(a => covered.add(a));
139
139
  }
140
-
140
+
141
141
  // Sort by number of elements (most elements first)
142
142
  return selected.sort((a, b) => countElements(b) - countElements(a));
143
143
  }
@@ -112,7 +112,7 @@ describe('openCycleAtNode', () => {
112
112
  graph.addEdge('A', 'B');
113
113
  graph.addEdge('B', 'C');
114
114
  graph.addEdge('C', 'B'); // Creates cycle
115
-
115
+
116
116
  expect(graph.inDegree('B')).toBe(2);
117
117
  const newGraph = openCycleAtNode(graph, 'B');
118
118
  expect(newGraph.inDegree('B')).toBe(0);
@@ -126,7 +126,7 @@ describe('openCycleAtNode', () => {
126
126
  graph.addNode('B');
127
127
  graph.addEdge('A', 'B');
128
128
  graph.addEdge('B', 'A'); // Cycle
129
-
129
+
130
130
  const newGraph = openCycleAtNode(graph, 'A');
131
131
  expect(newGraph.outDegree('A')).toBe(1);
132
132
  expect(newGraph.hasEdge('A', 'B')).toBe(true);
@@ -137,7 +137,7 @@ describe('openCycleAtNode', () => {
137
137
  graph.addNode('A');
138
138
  graph.addNode('B');
139
139
  graph.addEdge('A', 'B');
140
-
140
+
141
141
  const newGraph = openCycleAtNode(graph, 'A');
142
142
  expect(newGraph.inDegree('A')).toBe(0);
143
143
  expect(newGraph.hasEdge('A', 'B')).toBe(true);
@@ -203,7 +203,7 @@ describe('graphHasCycle', () => {
203
203
  graph.addNode('C');
204
204
  graph.addEdge('A', 'B');
205
205
  graph.addEdge('B', 'C');
206
-
206
+
207
207
  expect(graphHasCycle(graph)).toBe(false);
208
208
  });
209
209
 
@@ -213,7 +213,7 @@ describe('graphHasCycle', () => {
213
213
  graph.addNode('B');
214
214
  graph.addEdge('A', 'B');
215
215
  graph.addEdge('B', 'A');
216
-
216
+
217
217
  expect(graphHasCycle(graph)).toBe(true);
218
218
  });
219
219
 
@@ -221,7 +221,7 @@ describe('graphHasCycle', () => {
221
221
  const graph = new DirectedGraph();
222
222
  graph.addNode('A');
223
223
  graph.addEdge('A', 'A');
224
-
224
+
225
225
  expect(graphHasCycle(graph)).toBe(true);
226
226
  });
227
227
 
@@ -233,7 +233,7 @@ describe('graphHasCycle', () => {
233
233
  it('handles single node with no edges', () => {
234
234
  const graph = new DirectedGraph();
235
235
  graph.addNode('A');
236
-
236
+
237
237
  expect(graphHasCycle(graph)).toBe(false);
238
238
  });
239
239
  });
@@ -33,4 +33,3 @@ export const sbolVisualGlyphs = {
33
33
  export const getSvgByGlyph = (glyphName) => {
34
34
  return sbolVisualGlyphs[glyphName] || sbolVisualGlyphs['engineered-region'] // fallback to engineered-region if not found
35
35
  }
36
-
@@ -122,4 +122,3 @@ export const useAssembler = () => {
122
122
 
123
123
  return { requestSources, requestAssemblies }
124
124
  }
125
-
@@ -197,7 +197,7 @@ export default function DomesticateDialog({ open, onClose, initialContext, mainS
197
197
  ))}
198
198
  </Select>
199
199
  </FormControl>
200
- {location.includes('join') && cloningType === 'synthesis' &&
200
+ {location.includes('join') && cloningType === 'synthesis' &&
201
201
  <Alert severity="info" sx={{ mb: 2 }}>Synthesis will remove introns in this feature</Alert>
202
202
  }
203
203
  <FormControl fullWidth margin="dense">
@@ -38,7 +38,8 @@ export default {
38
38
  // Function to get the sequencing files from the database, see docs for what the return value should be
39
39
  getSequencingFiles: null,
40
40
  // Component for loading several sequences at once (e.g. in the assembler), calls
41
- // onSequencesLoaded with an array of { databaseId, name, textFileSequence }
41
+ // onSequencesLoaded with an array of { databaseId, name, textFileSequence }. Optional defaultSequenceTypes
42
+ // (e.g. ['plasmid']) sets which sequence types are selected by default when loading by tag
42
43
  GetSequencesInBulkComponent: null,
43
44
  // Dialog for submitting several cloning strategies at once (e.g. the assembler products), receives
44
45
  // { files, onClose, onSuccess }, where files is an array of File (cloning strategy JSON)
@@ -0,0 +1,43 @@
1
+ import React from 'react';
2
+ import ChipMultiSelect from './ChipMultiSelect';
3
+ import { clickMultiSelectOption } from '../../../../../cypress/e2e/common_functions';
4
+
5
+ const OPTIONS = [{ id: 1, label: 'One' }, { id: 2, label: 'Two' }, { id: 3, label: 'Three' }];
6
+
7
+ function Wrapper({ initialValue = [], onChange, ...rest }) {
8
+ const [value, setValue] = React.useState(initialValue);
9
+ return (
10
+ <ChipMultiSelect
11
+ label="Numbers"
12
+ options={OPTIONS}
13
+ value={value}
14
+ onChange={(ids) => { setValue(ids); onChange(ids); }}
15
+ {...rest}
16
+ />
17
+ );
18
+ }
19
+
20
+ describe('<ChipMultiSelect />', () => {
21
+ it('shows the selected options as chips and calls onChange with ids', () => {
22
+ const onChangeSpy = cy.spy().as('onChangeSpy');
23
+ cy.mount(<Wrapper initialValue={[2]} onChange={onChangeSpy} />);
24
+
25
+ cy.contains('.MuiChip-root', 'Two').should('exist');
26
+ clickMultiSelectOption('Numbers', 'Three', 'div');
27
+ cy.get('@onChangeSpy').should('have.been.calledWith', [2, 3]);
28
+ cy.contains('.MuiChip-root', 'Two').find('.MuiChip-deleteIcon').click();
29
+ cy.get('@onChangeSpy').should('have.been.calledWith', [3]);
30
+ });
31
+
32
+ it('ignores values that are not in the options', () => {
33
+ cy.mount(<Wrapper initialValue={[1, 99]} onChange={cy.spy()} />);
34
+ cy.get('.MuiChip-root').should('have.length', 1).and('contain', 'One');
35
+ });
36
+
37
+ it('shows the placeholder only when nothing is selected', () => {
38
+ cy.mount(<Wrapper initialValue={[1]} onChange={cy.spy()} placeholder="Any number" />);
39
+ cy.get('input[placeholder="Any number"]').should('not.exist');
40
+ cy.contains('.MuiChip-root', 'One').find('.MuiChip-deleteIcon').click();
41
+ cy.get('input[placeholder="Any number"]').should('exist');
42
+ });
43
+ });
@@ -0,0 +1,60 @@
1
+ import React from 'react';
2
+ import { Autocomplete, Chip, TextField } from '@mui/material';
3
+
4
+ /**
5
+ * Multi-select that shows the selected options as chips.
6
+ * options is an array of { id, label }, value and onChange use arrays of ids.
7
+ * placeholder is shown when nothing is selected, id is set on the input.
8
+ */
9
+ function ChipMultiSelect({ label, options, value, onChange, loading, disabled, sx, size = 'small', placeholder, id }) {
10
+ const optionById = React.useMemo(
11
+ () => new Map(options.map((opt) => [opt.id, opt])),
12
+ [options],
13
+ );
14
+
15
+ const selectedOptions = React.useMemo(
16
+ () => value.map((optionId) => optionById.get(optionId)).filter(Boolean),
17
+ [value, optionById],
18
+ );
19
+
20
+ return (
21
+ <Autocomplete
22
+ multiple
23
+ size={size}
24
+ options={options}
25
+ getOptionLabel={(option) => option.label}
26
+ isOptionEqualToValue={(option, selected) => option.id === selected.id}
27
+ value={selectedOptions}
28
+ onChange={(_, newValue) => {
29
+ const ids = newValue.map((opt) => opt.id);
30
+ onChange(ids);
31
+ }}
32
+ renderTags={(tagValue, getTagProps) =>
33
+ tagValue.map((option, index) => (
34
+ <Chip
35
+ {...getTagProps({ index })}
36
+ key={option.id}
37
+ label={option.label}
38
+ size="small"
39
+ />
40
+ ))
41
+ }
42
+ renderInput={(params) => (
43
+ <TextField
44
+ {...params}
45
+ id={id ?? params.id}
46
+ label={label}
47
+ variant="outlined"
48
+ placeholder={selectedOptions.length === 0 ? placeholder : undefined}
49
+ // Keep the label above the input, so that it does not cover the placeholder
50
+ InputLabelProps={placeholder ? { ...params.InputLabelProps, shrink: true } : params.InputLabelProps}
51
+ />
52
+ )}
53
+ loading={loading}
54
+ disabled={disabled}
55
+ sx={sx}
56
+ />
57
+ );
58
+ }
59
+
60
+ export default ChipMultiSelect;
@@ -1,7 +1,7 @@
1
1
  import * as React from 'react';
2
2
  import { Autocomplete, TextField, Alert, Button, CircularProgress, FormControl, FormHelperText, InputLabel, MenuItem, Select } from '@mui/material';
3
3
 
4
- export default function GetRequestMultiSelect({
4
+ export default function GetRequestMultiSelect({
5
5
  getOptionsFromResponse,
6
6
  url,
7
7
  label,
@@ -4,6 +4,7 @@ export { default as EditTextDialog } from './form/EditTextDialog';
4
4
  export { default as useUrlParamsLoader } from '../hooks/useUrlParamsLoader';
5
5
  export { default as useInitializeApp } from '../hooks/useInitializeApp';
6
6
  export { default as QuerySelect } from './form/QuerySelect';
7
+ export { default as ChipMultiSelect } from './form/ChipMultiSelect';
7
8
  export { default as QueryStatusWrapper } from './form/QueryStatusWrapper';
8
9
  export { default as TextFieldQueryValidated } from './form/TextFieldQueryValidated';
9
10
  export { default as useDebouncedSearchQuery } from '../hooks/useDebouncedSearchQuery';
@@ -22,5 +22,3 @@
22
22
  font-weight: 200;
23
23
  text-align: center;
24
24
  }
25
-
26
-
@@ -85,7 +85,7 @@ function MainAppBar() {
85
85
  const helpMenu = [
86
86
  { display: 'Newsletter', onClick: () => window.open('https://eepurl.com/h9-n71') },
87
87
  { display: 'About the project', onClick: () => window.open('https://github.com/OpenCloning/OpenCloning') },
88
- { display: 'GitHub repository', onClick: () => window.open('https://github.com/OpenCloning/OpenCloning_frontend') },
88
+ { display: 'GitHub repository', onClick: () => window.open('https://github.com/OpenCloning/OpenCloning') },
89
89
  { display: 'Demo videos', onClick: () => window.open('https://www.youtube.com/watch?v=n0hedzvpW88&list=PLpv3x-ensLZkJToD2E6ejefADmHcUPYSJ&index=1') },
90
90
  { display: 'App version', onClick: () => setOpenVersionDialog(true) },
91
91
  ];
@@ -113,7 +113,7 @@ function PrimerList() {
113
113
  >
114
114
  {`Import from ${database.name}`}
115
115
  </Button>
116
-
116
+
117
117
  )}
118
118
  </ButtonGroup>
119
119
  </div>
@@ -57,4 +57,3 @@
57
57
  align-items: center;
58
58
  gap: 2em;
59
59
  }
60
-
@@ -165,5 +165,3 @@ function SettingsTab() {
165
165
  }
166
166
 
167
167
  export default SettingsTab;
168
-
169
-
@@ -31,7 +31,7 @@ describe('<AssemblyIdSelector />', () => {
31
31
  }).as('getAssemblyInfo');
32
32
 
33
33
  cy.intercept('GET', 'https://api.ncbi.nlm.nih.gov/datasets/v2alpha/genome/accession/GCF_000002945.3/dataset_report*', {
34
- statusCode: 200,
34
+ statusCode: 200,
35
35
  body: {
36
36
  reports: [{
37
37
  accession: 'GCF_000002945.3',
@@ -49,7 +49,7 @@ describe('<AssemblyIdSelector />', () => {
49
49
 
50
50
  cy.mount(
51
51
  <ConfigProvider config={config}>
52
- <AssemblyIdSelector
52
+ <AssemblyIdSelector
53
53
  setAssemblyId={setAssemblyId}
54
54
  setHasAnnotation={setHasAnnotation}
55
55
  onAssemblyIdChange={onAssemblyIdChange}
@@ -188,5 +188,3 @@ describe('<SequenceAccessionPicker />', () => {
188
188
 
189
189
  });
190
190
  });
191
-
192
-
@@ -11,4 +11,3 @@ const repositoryMetadata = {
11
11
  };
12
12
 
13
13
  export default repositoryMetadata;
14
-
@@ -18,7 +18,7 @@ import {
18
18
  } from '@mui/material';
19
19
  import { batch, shallowEqual, useDispatch, useSelector, useStore } from 'react-redux';
20
20
  import { isEqual } from 'lodash-es';
21
- import { getTeselaJsonFromBase64, file2base64 } from '@opencloning/utils/readNwrite';
21
+ import { getTeselaJsonFromBase64, readSequencingFile } from '@opencloning/utils/readNwrite';
22
22
  import SequencingFileRow from './SequencingFileRow';
23
23
  import { cloningActions } from '@opencloning/store/cloning';
24
24
  import useStoreEditor from '../../hooks/useStoreEditor';
@@ -66,10 +66,10 @@ export default function VerificationFileDialog({ id, dialogOpen, setDialogOpen }
66
66
 
67
67
  // Read the new sequencing files
68
68
  const parsedSequencingFiles = await Promise.all(newFiles.map(async (newFile) => {
69
- const base64str = await file2base64(newFile);
69
+ const { base64str, teselaJson } = await readSequencingFile(newFile);
70
70
  return {
71
71
  sequence_id: id,
72
- trace: (await getTeselaJsonFromBase64(base64str, newFile.name)).sequence,
72
+ trace: teselaJson.sequence,
73
73
  base64str,
74
74
  file_name: newFile.name,
75
75
  file_type: 'Sequencing file',
@@ -11,4 +11,3 @@ export default function useInitializeApp() {
11
11
  clearVerificationFileContents();
12
12
  }, []);
13
13
  }
14
-
@@ -1,6 +1,6 @@
1
1
  import { useCallback } from 'react';
2
2
  import { useMutation } from '@tanstack/react-query';
3
- import { buildAlignmentTrack, buildReferenceTrack, buildAlignmentConfig } from '@opencloning/utils/alignmentUtils';
3
+ import { buildSequencingAlignmentConfig } from '@opencloning/utils/alignmentUtils';
4
4
  import useBackendHttpClient from './useBackendHttpClient';
5
5
 
6
6
  export default function useSequencingAlignment({ onError } = {}) {
@@ -10,14 +10,7 @@ export default function useSequencingAlignment({ onError } = {}) {
10
10
  // parsedFiles: [{ fileName, fileContent }] where fileContent is getTeselaJsonFromBase64 output
11
11
  const traces = parsedFiles.map((f) => f.fileContent.sequence);
12
12
  const resp = await httpClient.post('align_sanger', { sequence: sequenceModel, traces });
13
-
14
- const referenceTrack = buildReferenceTrack(sequenceData, resp.data[0]);
15
- const otherTracks = parsedFiles.map((f, i) => {
16
- const alignmentFile = { file_name: f.fileName, alignment: [resp.data[0], resp.data[i + 1]] }; // eslint-disable-line camelcase
17
- return buildAlignmentTrack(f.fileContent, alignmentFile);
18
- });
19
-
20
- return buildAlignmentConfig(sequenceModel.id, sequenceData, [referenceTrack, ...otherTracks]);
13
+ return buildSequencingAlignmentConfig(sequenceModel, sequenceData, parsedFiles, resp.data);
21
14
  }, [httpClient]);
22
15
 
23
16
  return useMutation({ mutationFn: fetchAlignments, onError });
@@ -16,6 +16,9 @@ const httpClient = getExternalHttpClient();
16
16
 
17
17
  const { updateSource } = cloningActions;
18
18
 
19
+ // OpenCloning MCP server, set at build time
20
+ const mcpUrl = import.meta.env.VITE_MCP_SERVER_URL;
21
+
19
22
  /**
20
23
  * Hook to load sequences from URL parameters
21
24
  * Handles various source types: database, example, template, genome_coordinates, locus_tag
@@ -75,6 +78,27 @@ export default function useUrlParamsLoader() {
75
78
  });
76
79
  console.error(error);
77
80
  }
81
+ } else if (urlParams.source === 'mcp' && urlParams.strategy) {
82
+ // Only the MCP server set at build time (VITE_MCP_SERVER_URL) is fetched from, never a url from the query
83
+ try {
84
+ if (!mcpUrl) {
85
+ throw new Error('No MCP server configured');
86
+ }
87
+ if (!/^[A-Za-z0-9_-]+$/.test(urlParams.strategy)) {
88
+ throw new Error('Invalid strategy id');
89
+ }
90
+ const url = `${mcpUrl.replace(/\/$/, '')}/strategies/${encodePathSegment(urlParams.strategy)}.json`;
91
+ const resp = await httpClient.get(url);
92
+ const file = new File([JSON.stringify(resp.data)], 'mcp_strategy.json');
93
+ const prepared = await loadHistoryFromFile(file, { onError: setHistoryFileError });
94
+ await prepared?.replace();
95
+ } catch (error) {
96
+ addAlert({
97
+ message: 'Error loading strategy from the MCP server (it may have expired or not exist)',
98
+ severity: 'error',
99
+ });
100
+ console.error(error);
101
+ }
78
102
  } else if (urlParams.source === 'template' && urlParams.template && urlParams.key) {
79
103
  try {
80
104
  const baseUrl = 'https://assets.opencloning.org/OpenCloning-submission';
package/src/index.js CHANGED
@@ -7,4 +7,3 @@ export * from './components/index.js';
7
7
  export { StandAloneOpenCloning } from './StandAloneOpenCloning.js';
8
8
  // Export version - replaced at publish time via prepack script
9
9
  export { version } from './version.js';
10
-
@@ -4,7 +4,7 @@ const ConfigContext = createContext(null);
4
4
 
5
5
  /**
6
6
  * ConfigProvider - Provides application configuration via React Context
7
- *
7
+ *
8
8
  * @param {Object} props
9
9
  * @param {Object} props.config - Config object
10
10
  * @param {React.ReactNode} props.children - Child components
@@ -27,7 +27,7 @@ export function ConfigProvider({ config, children }) {
27
27
 
28
28
  /**
29
29
  * useConfig - Hook to access configuration from ConfigProvider
30
- *
30
+ *
31
31
  * @returns {Object} Configuration object with properties:
32
32
  * - backendUrl: string
33
33
  * - showAppBar: boolean
@@ -36,12 +36,12 @@ export function ConfigProvider({ config, children }) {
36
36
  * - requiresBackendAuth: boolean
37
37
  * - noExternalRequests: boolean
38
38
  * - database: string | null
39
- *
39
+ *
40
40
  * @throws {Error} If used outside of ConfigProvider
41
41
  */
42
42
  export function useConfig() {
43
43
  const context = useContext(ConfigContext);
44
-
44
+
45
45
  if (context === null) {
46
46
  throw new Error('useConfig must be used within a ConfigProvider');
47
47
  }
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Version placeholder - replaced at publish time via prepack script
2
- export const version = "2.1.0";
2
+ export const version = "2.2.0";