@opencloning/ui 1.3.2 → 1.4.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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # @opencloning/ui
2
2
 
3
+ ## 1.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#611](https://github.com/manulera/OpenCloning_frontend/pull/611) [`74a58c4`](https://github.com/manulera/OpenCloning_frontend/commit/74a58c463a32bf6443eaf062094dc4aac5c76b5a) Thanks [@manulera](https://github.com/manulera)! - Adds functionality to load files (sequences and syntaxes) from a local public folder, providing an alternative to uploading files manually. The implementation enables users to pre-configure collections of sequences and syntaxes that can be easily selected through a UI.
8
+
9
+ **Changes:**
10
+
11
+ - Added `useRequestForEffect` hook for managing async requests with retry capability
12
+ - Added `useServerStaticFiles` hook for fetching and managing local file collections
13
+ - Added `ServerStaticFileSelect` component for selecting files from the local collection with category filtering
14
+ - Added `SourceServerStaticFile` component and integrated it into the source selection flow
15
+ - Added local file loading capability to the assembler's plasmid uploader and syntax loader
16
+ - Configured build system to copy example collection folder to public directory during development
17
+ - Added comprehensive test coverage for all new components and functionality
18
+
19
+ ### Patch Changes
20
+
21
+ - Updated dependencies []:
22
+ - @opencloning/store@1.4.0
23
+ - @opencloning/utils@1.4.0
24
+
25
+ ## 1.3.3
26
+
27
+ ### Patch Changes
28
+
29
+ - [#607](https://github.com/manulera/OpenCloning_frontend/pull/607) [`1e4bfbf`](https://github.com/manulera/OpenCloning_frontend/commit/1e4bfbfb805e841d9a91cf650ebf632ac62b1248) Thanks [@manulera](https://github.com/manulera)! - Syntax validation and fix longestFeature when it's empty
30
+
31
+ - Updated dependencies []:
32
+ - @opencloning/store@1.3.3
33
+ - @opencloning/utils@1.3.3
34
+
3
35
  ## 1.3.2
4
36
 
5
37
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencloning/ui",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
@@ -25,15 +25,15 @@
25
25
  "@emotion/styled": "^11.14.0",
26
26
  "@mui/icons-material": "^5.15.17",
27
27
  "@mui/material": "^5.15.17",
28
- "@opencloning/store": "1.3.2",
29
- "@opencloning/utils": "1.3.2",
30
- "@teselagen/bio-parsers": "^0.4.32",
31
- "@teselagen/ove": "^0.8.30",
32
- "@teselagen/range-utils": "^0.3.13",
33
- "@teselagen/sequence-utils": "^0.3.35",
28
+ "@opencloning/store": "1.4.0",
29
+ "@opencloning/utils": "1.4.0",
30
+ "@teselagen/bio-parsers": "^0.4.34",
31
+ "@teselagen/ove": "^0.8.34",
32
+ "@teselagen/range-utils": "^0.3.20",
33
+ "@teselagen/sequence-utils": "^0.3.42",
34
34
  "@zip.js/zip.js": "^2.7.62",
35
35
  "axios": "^1.12.2",
36
- "lodash-es": "^4.17.21",
36
+ "lodash-es": "^4.17.23",
37
37
  "react-draggable": "^4.4.6"
38
38
  },
39
39
  "peerDependencies": {
@@ -1,14 +1,7 @@
1
1
  /* eslint-disable camelcase */
2
2
  import React from 'react';
3
3
  import { ConfigProvider } from '@opencloning/ui/providers/ConfigProvider';
4
- import { AssemblerComponent, UploadPlasmidsButton } from './Assembler';
5
- import mocloSyntax from '../../../../../cypress/test_files/syntax/moclo_syntax.json';
6
-
7
- mocloSyntax.overhangNames = {
8
- ...mocloSyntax.overhangNames,
9
- CCCT: 'CCCT_overhang',
10
- AACG: 'AACG_overhang',
11
- };
4
+ import { AssemblerComponent } from './Assembler';
12
5
 
13
6
  // Test config
14
7
  const testConfig = {
@@ -233,132 +226,3 @@ describe('<AssemblerComponent />', () => {
233
226
  });
234
227
  });
235
228
 
236
- describe('<UploadPlasmidsButton />', () => {
237
- beforeEach(() => {
238
- cy.window().then((win) => {
239
- win.localStorage.clear();
240
- });
241
- });
242
-
243
- it('calls addPlasmids with correctly formatted valid plasmid', () => {
244
- const addPlasmidsSpy = cy.spy().as('addPlasmidsSpy');
245
-
246
- cy.mount(
247
- <ConfigProvider config={testConfig}>
248
- <UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
249
- </ConfigProvider>,
250
- );
251
-
252
- cy.get('button').contains('Add Plasmids').siblings('input').selectFile([
253
- 'cypress/test_files/syntax/pYTK002.gb',
254
- 'cypress/test_files/syntax/moclo_ytk_multi_part.gb',
255
- 'cypress/test_files/syntax/pYTK095.gb',
256
- 'cypress/test_files/sequencing/locus.gb'],
257
- { force: true });
258
-
259
- // Wait for the dialog to appear (indicating plasmids were processed)
260
- cy.get('.MuiDialog-root', { timeout: 10000 }).should('be.visible');
261
-
262
- cy.get('@addPlasmidsSpy').should('not.have.been.called');
263
-
264
- cy.get('[data-testid="invalid-plasmids-box"]').contains('Invalid Plasmids').should('exist');
265
- cy.get('[data-testid="valid-plasmids-box"]').contains('Valid Plasmids').should('exist');
266
-
267
- cy.get('[data-testid="invalid-plasmids-box"]').contains('pYTK057')
268
- cy.get('[data-testid="invalid-plasmids-box"]').contains('moclo_ytk_multi_part.gb')
269
- cy.get('[data-testid="invalid-plasmids-box"] .MuiChip-label').contains('ATCC-TGGC')
270
- cy.get('[data-testid="invalid-plasmids-box"] .MuiChip-label').contains('CCCT-AACG (CCCT_overhang-AACG_overhang)')
271
- cy.get('[data-testid="invalid-plasmids-box"]').contains('Contains multiple parts')
272
- cy.get('[data-testid="invalid-plasmids-box"]').contains('locus.gb')
273
-
274
- cy.get('[data-testid="valid-plasmids-box"] tr').eq(1).find('td').eq(0).should('contain', 'pYTK002')
275
- cy.get('[data-testid="valid-plasmids-box"] tr').eq(1).find('td').eq(1).should('contain', 'pYTK002.gb')
276
- cy.get('[data-testid="valid-plasmids-box"] tr').eq(1).find('td').eq(2).should('contain', 'CCCT-AACG (CCCT_overhang-AACG_overhang)')
277
- cy.get('[data-testid="valid-plasmids-box"] tr').eq(1).find('td').eq(3).should('contain', '1')
278
- cy.get('[data-testid="valid-plasmids-box"] tr').eq(1).find('td').eq(4).should('contain', 'ConS')
279
- cy.get('[data-testid="valid-plasmids-box"] tr').eq(1).then(($el) => {
280
- const bgColor = window.getComputedStyle($el[0]).backgroundColor;
281
- cy.wrap(bgColor).should('equal', 'rgb(132, 197, 222)');
282
- });
283
-
284
- cy.get('[data-testid="valid-plasmids-box"] tr').eq(2).find('td').eq(0).should('contain', 'pYTK095')
285
- cy.get('[data-testid="valid-plasmids-box"] tr').eq(2).find('td').eq(1).should('contain', 'pYTK095.gb')
286
- cy.get('[data-testid="valid-plasmids-box"] tr').eq(2).find('td').eq(2).should('contain', 'TACA-CCCT (TACA-CCCT_overhang)')
287
- cy.get('[data-testid="valid-plasmids-box"] tr').eq(2).find('td').eq(3).should('contain', 'Spans multiple parts')
288
- cy.get('[data-testid="valid-plasmids-box"] tr').eq(2).find('td').eq(4).should('contain', 'AmpR')
289
-
290
- // Click the import button
291
- cy.contains('button', 'Import valid plasmids').click();
292
-
293
- // Verify addPlasmids was called
294
- cy.get('@addPlasmidsSpy').should('have.been.called');
295
-
296
- // Verify it was called with an array and check structure
297
- cy.get('@addPlasmidsSpy').then((spy) => {
298
- const firstCall = spy.getCall(0);
299
- console.log('firstCall', firstCall.args);
300
- cy.wrap(firstCall.args[0]).should('be.an', 'array');
301
- cy.wrap(firstCall.args[0]).should('have.length', 2);
302
-
303
- const firstPlasmid = firstCall.args[0][0];
304
-
305
- cy.wrap(firstPlasmid.file_name).should('equal', 'pYTK002.gb');
306
- cy.wrap(firstPlasmid.plasmid_name).should('equal', 'pYTK002.gb (ConS)');
307
- cy.wrap(firstPlasmid.left_overhang).should('equal', 'CCCT');
308
- cy.wrap(firstPlasmid.right_overhang).should('equal', 'AACG');
309
- cy.wrap(firstPlasmid.key).should('equal', 'CCCT-AACG');
310
-
311
- const {appData} = firstPlasmid.sequenceData;
312
-
313
- cy.wrap(appData.fileName).should('equal', 'pYTK002.gb');
314
- cy.wrap(appData.correspondingParts).should('deep.equal', ['CCCT-AACG']);
315
- cy.wrap(appData.correspondingPartsNames).should('deep.equal', ["CCCT_overhang-AACG_overhang"]);
316
-
317
- });
318
- });
319
-
320
-
321
- it('does not allow to submit when all plasmids are invalid', () => {
322
- cy.mount(
323
- <ConfigProvider config={testConfig}>
324
- <UploadPlasmidsButton addPlasmids={() => {}} syntax={mocloSyntax} />
325
- </ConfigProvider>,
326
- );
327
- cy.get('button').contains('Add Plasmids').siblings('input').selectFile([
328
- 'cypress/test_files/sequencing/locus.gb'],
329
- { force: true });
330
-
331
- // Wait for the dialog to appear (indicating plasmids were processed)
332
- cy.get('.MuiDialog-root', { timeout: 10000 }).should('be.visible');
333
-
334
- cy.get('[data-testid="invalid-plasmids-box"]').contains('Invalid Plasmids').should('exist');
335
- cy.get('[data-testid="valid-plasmids-box"]').should('not.exist');
336
-
337
- cy.get('button').contains('Import valid plasmids').should('be.disabled');
338
-
339
- });
340
-
341
- it('cancelling does not call addPlasmids', () => {
342
- const addPlasmidsSpy = cy.spy().as('addPlasmidsSpy');
343
- cy.mount(
344
- <ConfigProvider config={testConfig}>
345
- <UploadPlasmidsButton addPlasmids={addPlasmidsSpy} syntax={mocloSyntax} />
346
- </ConfigProvider>,
347
- );
348
-
349
- cy.get('button').contains('Add Plasmids').siblings('input').selectFile([
350
- 'cypress/test_files/syntax/pYTK002.gb',
351
- 'cypress/test_files/syntax/moclo_ytk_multi_part.gb',
352
- 'cypress/test_files/syntax/pYTK095.gb',
353
- 'cypress/test_files/sequencing/locus.gb'],
354
- { force: true });
355
-
356
- cy.get('.MuiDialog-root', { timeout: 10000 }).should('be.visible');
357
-
358
- cy.get('button').contains('Cancel').click();
359
-
360
- cy.get('@addPlasmidsSpy').should('not.have.been.called');
361
-
362
- });
363
-
364
- });
@@ -1,7 +1,6 @@
1
1
  import React from 'react'
2
2
  import {
3
- Alert, Autocomplete, Box, Button, CircularProgress, Dialog, DialogTitle, DialogContent,
4
- DialogActions, FormControl, IconButton, InputAdornment, InputLabel, MenuItem, Select, Stack, Table,
3
+ Alert, Autocomplete, Box, Button, CircularProgress, FormControl, IconButton, InputAdornment, InputLabel, MenuItem, Select, Stack, Table,
5
4
  TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, ButtonGroup
6
5
  } from '@mui/material'
7
6
  import { Clear as ClearIcon, Visibility as VisibilityIcon } from '@mui/icons-material';
@@ -9,39 +8,18 @@ import { useAssembler } from './useAssembler';
9
8
  import { useDispatch } from 'react-redux';
10
9
  import { cloningActions } from '@opencloning/store/cloning';
11
10
  import AssemblerPart from './AssemblerPart';
12
- import { jsonToGenbank } from '@teselagen/bio-parsers';
11
+
13
12
  import useCombinatorialAssembly from './useCombinatorialAssembly';
14
- import { usePlasmidsLogic } from './usePlasmidsLogic';
15
- import PlasmidSyntaxTable from './PlasmidSyntaxTable';
16
13
  import ExistingSyntaxDialog from './ExistingSyntaxDialog';
17
14
  import error2String from '@opencloning/utils/error2String';
18
15
  import { categoryFilter } from './assembler_utils';
16
+ import useBackendRoute from '../../hooks/useBackendRoute';
17
+ import useHttpClient from '../../hooks/useHttpClient';
18
+ import useAlerts from '../../hooks/useAlerts';
19
+ import UploadPlasmidsButton from './UploadPlasmidsButton';
19
20
 
20
- const { setState: setCloningState, setCurrentTab: setCurrentTabAction } = cloningActions;
21
-
22
- function formatPlasmid(sequenceData) {
23
21
 
24
- const { appData } = sequenceData;
25
- const { fileName, correspondingParts, longestFeature } = appData;
26
- const [left_overhang, right_overhang] = correspondingParts[0].split('-');
27
-
28
- let plasmidName = fileName;
29
- if (longestFeature[0]?.name) {
30
- plasmidName += ` (${longestFeature[0].name})`;
31
- }
32
-
33
- return {
34
- type: 'loadedFile',
35
- plasmid_name: plasmidName,
36
- file_name: fileName,
37
- left_overhang,
38
- right_overhang,
39
- key: `${left_overhang}-${right_overhang}`,
40
- sequenceData,
41
- genbankString: jsonToGenbank(sequenceData),
42
- };
43
-
44
- }
22
+ const { setState: setCloningState, setCurrentTab: setCurrentTabAction } = cloningActions;
45
23
 
46
24
 
47
25
  function formatItemName(item) {
@@ -261,71 +239,29 @@ function categoriesFromSyntaxAndPlasmids(syntax, plasmids) {
261
239
 
262
240
  function LoadSyntaxButton({ setSyntax, addPlasmids }) {
263
241
  const [existingSyntaxDialogOpen, setExistingSyntaxDialogOpen] = React.useState(false)
264
- const onSyntaxSelect = React.useCallback((syntax, plasmids) => {
265
- setSyntax(syntax)
266
- addPlasmids(plasmids)
267
- }, [setSyntax, addPlasmids])
242
+ const httpClient = useHttpClient();
243
+ const backendRoute = useBackendRoute();
244
+ const { addAlert } = useAlerts();
245
+ const onSyntaxSelect = React.useCallback(async (syntax, plasmids) => {
246
+ const url = backendRoute('validate_syntax');
247
+ try {
248
+ await httpClient.post(url, syntax);
249
+ setSyntax(syntax)
250
+ addPlasmids(plasmids)
251
+ } catch (error) {
252
+ addAlert({
253
+ message: error2String(error),
254
+ severity: 'error',
255
+ });
256
+ }
257
+ }, [setSyntax, addPlasmids, httpClient, backendRoute, addAlert])
268
258
  return <>
269
259
  <Button color="success" onClick={() => setExistingSyntaxDialogOpen(true)}>Load Syntax</Button>
270
260
  {existingSyntaxDialogOpen && <ExistingSyntaxDialog onClose={() => setExistingSyntaxDialogOpen(false)} onSyntaxSelect={onSyntaxSelect}/>}
271
261
  </>
272
262
  }
273
263
 
274
- export function UploadPlasmidsButton({ addPlasmids, syntax }) {
275
- const { uploadPlasmids, linkedPlasmids, setLinkedPlasmids } = usePlasmidsLogic(syntax)
276
- const validPlasmids = React.useMemo(() => linkedPlasmids.filter((plasmid) => plasmid.appData.correspondingParts.length === 1), [linkedPlasmids])
277
- const invalidPlasmids = React.useMemo(() => linkedPlasmids.filter((plasmid) => plasmid.appData.correspondingParts.length !== 1), [linkedPlasmids])
278
- const fileInputRef = React.useRef(null)
279
-
280
- const handleFileChange = (event) => {
281
- uploadPlasmids(Array.from(event.target.files))
282
- fileInputRef.current.value = ''
283
- }
284
-
285
- const handleImportValidPlasmids = React.useCallback(() => {
286
- addPlasmids(validPlasmids.map(formatPlasmid))
287
- setLinkedPlasmids([])
288
- }, [addPlasmids, validPlasmids, setLinkedPlasmids])
289
264
 
290
- return (<>
291
- <Button color="primary" onClick={() => fileInputRef.current.click()}>
292
- Add Plasmids
293
- </Button>
294
- <input multiple type="file" ref={fileInputRef} style={{ display: 'none' }} onChange={handleFileChange} accept=".gbk,.gb,.fasta,.fa,.dna" />
295
- <Dialog
296
- maxWidth="lg"
297
- fullWidth
298
- open={invalidPlasmids.length > 0 || validPlasmids.length > 0}
299
- onClose={() => setLinkedPlasmids([])}
300
- PaperProps={{
301
- style: {
302
- maxHeight: '80vh',
303
- },
304
- }}
305
- >
306
- <DialogActions sx={{ justifyContent: 'center', position: 'sticky', top: 0, zIndex: 99, background: '#fff' }}>
307
- <Button disabled={validPlasmids.length === 0} variant="contained" color="success" onClick={handleImportValidPlasmids}>Import valid plasmids</Button>
308
- <Button variant="contained" color="error" onClick={() => setLinkedPlasmids([])}>Cancel</Button>
309
- </DialogActions>
310
- {invalidPlasmids.length > 0 && (
311
- <Box data-testid="invalid-plasmids-box">
312
- <DialogTitle>Invalid Plasmids</DialogTitle>
313
- <DialogContent>
314
- <PlasmidSyntaxTable plasmids={invalidPlasmids} />
315
- </DialogContent>
316
- </Box>
317
- )}
318
- {validPlasmids.length > 0 && (
319
- <Box data-testid="valid-plasmids-box">
320
- <DialogTitle>Valid Plasmids</DialogTitle>
321
- <DialogContent>
322
- <PlasmidSyntaxTable plasmids={validPlasmids} />
323
- </DialogContent>
324
- </Box>
325
- )}
326
- </Dialog>
327
- </>)
328
- }
329
265
 
330
266
  function Assembler() {
331
267
  const [syntax, setSyntax] = React.useState(null);
@@ -1,6 +1,16 @@
1
1
  import React from 'react';
2
+ import { ConfigProvider } from '@opencloning/ui/providers/ConfigProvider';
2
3
  import ExistingSyntaxDialog from './ExistingSyntaxDialog';
3
4
 
5
+ // Test config
6
+ const testConfig = {
7
+ backendUrl: 'http://localhost:8000',
8
+ showAppBar: false,
9
+ noExternalRequests: false,
10
+ enableAssembler: true,
11
+ enablePlannotate: false,
12
+ };
13
+
4
14
  const mockSyntaxes = [
5
15
  {
6
16
  path: 'test-syntax-1',
@@ -40,10 +50,12 @@ describe('<ExistingSyntaxDialog />', () => {
40
50
  const onSyntaxSelectSpy = cy.spy().as('onSyntaxSelectSpy');
41
51
 
42
52
  cy.mount(
43
- <ExistingSyntaxDialog
44
- onClose={onCloseSpy}
45
- onSyntaxSelect={onSyntaxSelectSpy}
46
- />,
53
+ <ConfigProvider config={testConfig}>
54
+ <ExistingSyntaxDialog
55
+ onClose={onCloseSpy}
56
+ onSyntaxSelect={onSyntaxSelectSpy}
57
+ />
58
+ </ConfigProvider>,
47
59
  );
48
60
 
49
61
  cy.wait('@getSyntaxes');
@@ -70,10 +82,12 @@ describe('<ExistingSyntaxDialog />', () => {
70
82
  }).as('getPlasmidsData');
71
83
 
72
84
  cy.mount(
73
- <ExistingSyntaxDialog
74
- onClose={onCloseSpy}
75
- onSyntaxSelect={onSyntaxSelectSpy}
76
- />,
85
+ <ConfigProvider config={testConfig}>
86
+ <ExistingSyntaxDialog
87
+ onClose={onCloseSpy}
88
+ onSyntaxSelect={onSyntaxSelectSpy}
89
+ />
90
+ </ConfigProvider>,
77
91
  );
78
92
 
79
93
  cy.wait('@getSyntaxes');
@@ -97,10 +111,12 @@ describe('<ExistingSyntaxDialog />', () => {
97
111
  }).as('getSyntaxDataError');
98
112
 
99
113
  cy.mount(
100
- <ExistingSyntaxDialog
101
- onClose={onCloseSpy}
102
- onSyntaxSelect={onSyntaxSelectSpy}
103
- />,
114
+ <ConfigProvider config={testConfig}>
115
+ <ExistingSyntaxDialog
116
+ onClose={onCloseSpy}
117
+ onSyntaxSelect={onSyntaxSelectSpy}
118
+ />
119
+ </ConfigProvider>,
104
120
  );
105
121
 
106
122
  cy.wait('@getSyntaxes');
@@ -130,10 +146,12 @@ describe('<ExistingSyntaxDialog />', () => {
130
146
  }).as('getPlasmidsDataError');
131
147
 
132
148
  cy.mount(
133
- <ExistingSyntaxDialog
134
- onClose={onCloseSpy}
135
- onSyntaxSelect={onSyntaxSelectSpy}
136
- />,
149
+ <ConfigProvider config={testConfig}>
150
+ <ExistingSyntaxDialog
151
+ onClose={onCloseSpy}
152
+ onSyntaxSelect={onSyntaxSelectSpy}
153
+ />
154
+ </ConfigProvider>,
137
155
  );
138
156
 
139
157
  cy.wait('@getSyntaxes');
@@ -169,10 +187,12 @@ describe('<ExistingSyntaxDialog />', () => {
169
187
  }).as('getPlasmidsData2');
170
188
 
171
189
  cy.mount(
172
- <ExistingSyntaxDialog
173
- onClose={onCloseSpy}
174
- onSyntaxSelect={onSyntaxSelectSpy}
175
- />,
190
+ <ConfigProvider config={testConfig}>
191
+ <ExistingSyntaxDialog
192
+ onClose={onCloseSpy}
193
+ onSyntaxSelect={onSyntaxSelectSpy}
194
+ />
195
+ </ConfigProvider>,
176
196
  );
177
197
 
178
198
  cy.wait('@getSyntaxes');
@@ -204,21 +224,27 @@ describe('<ExistingSyntaxDialog />', () => {
204
224
  ],
205
225
  };
206
226
 
207
- // Create a temporary JSON file
208
- cy.writeFile('cypress/temp/syntax.json', uploadedSyntaxData);
227
+ const tempFile = {
228
+ contents: Cypress.Buffer.from(JSON.stringify(uploadedSyntaxData)),
229
+ fileName: 'syntax.json',
230
+ mimeType: 'text/plain',
231
+ lastModified: Date.now(),
232
+ }
209
233
 
210
234
  cy.mount(
211
- <ExistingSyntaxDialog
212
- onClose={onCloseSpy}
213
- onSyntaxSelect={onSyntaxSelectSpy}
214
- />,
235
+ <ConfigProvider config={testConfig}>
236
+ <ExistingSyntaxDialog
237
+ onClose={onCloseSpy}
238
+ onSyntaxSelect={onSyntaxSelectSpy}
239
+ />
240
+ </ConfigProvider>,
215
241
  );
216
242
 
217
243
  cy.wait('@getSyntaxes');
218
244
  cy.contains('Upload syntax from JSON file').should('exist');
219
245
 
220
246
  // Upload the JSON file
221
- cy.get('input[type="file"]').selectFile('cypress/temp/syntax.json', { force: true });
247
+ cy.get('input[type="file"]').selectFile(tempFile, { force: true });
222
248
 
223
249
  cy.get('@onSyntaxSelectSpy').should('have.been.calledWith', uploadedSyntaxData, []);
224
250
  cy.get('@onCloseSpy').should('have.been.called');
@@ -228,21 +254,27 @@ describe('<ExistingSyntaxDialog />', () => {
228
254
  const onCloseSpy = cy.spy().as('onCloseSpy');
229
255
  const onSyntaxSelectSpy = cy.spy().as('onSyntaxSelectSpy');
230
256
 
231
- // Create a file with invalid JSON
232
- cy.writeFile('cypress/temp/invalid.json', '{ invalid json }', { encoding: 'utf8' });
257
+ const invalidFile = {
258
+ contents: Cypress.Buffer.from('{ invalid json }'),
259
+ fileName: 'invalid.json',
260
+ mimeType: 'text/plain',
261
+ lastModified: Date.now(),
262
+ }
233
263
 
234
264
  cy.mount(
235
- <ExistingSyntaxDialog
236
- onClose={onCloseSpy}
237
- onSyntaxSelect={onSyntaxSelectSpy}
238
- />,
265
+ <ConfigProvider config={testConfig}>
266
+ <ExistingSyntaxDialog
267
+ onClose={onCloseSpy}
268
+ onSyntaxSelect={onSyntaxSelectSpy}
269
+ />
270
+ </ConfigProvider>,
239
271
  );
240
272
 
241
273
  cy.wait('@getSyntaxes');
242
274
  cy.contains('Upload syntax from JSON file').should('exist');
243
275
 
244
276
  // Upload invalid JSON
245
- cy.get('input[type="file"]').selectFile('cypress/temp/invalid.json', { force: true });
277
+ cy.get('input[type="file"]').selectFile(invalidFile, { force: true });
246
278
 
247
279
  cy.contains(/Failed to parse JSON file/).should('exist');
248
280
  cy.get('@onSyntaxSelectSpy').should('not.have.been.called');
@@ -1,18 +1,41 @@
1
1
  import React from 'react'
2
- import { Dialog, DialogTitle, DialogContent, List, ListItem, ListItemText, ListItemButton, Alert, Button, Box } from '@mui/material'
2
+ import { Dialog, DialogTitle, DialogContent, List, ListItem, ListItemText, ListItemButton, Alert, Button, Box, ButtonGroup } from '@mui/material'
3
3
  import getHttpClient from '@opencloning/utils/getHttpClient';
4
4
  import RequestStatusWrapper from '../form/RequestStatusWrapper';
5
+ import { useConfig } from '../../providers';
6
+ import ServerStaticFileSelect from '../form/ServerStaticFileSelect';
7
+ import { readSubmittedTextFile } from '@opencloning/utils/readNwrite';
5
8
 
6
9
  const httpClient = getHttpClient();
7
10
  const baseURL = 'https://assets.opencloning.org/syntaxes/syntaxes/';
8
11
  httpClient.defaults.baseURL = baseURL;
9
12
 
13
+ function LocalSyntaxDialog({ onClose, onSyntaxSelect }) {
14
+
15
+ const onFileSelected = React.useCallback(async (file) => {
16
+ const text = await readSubmittedTextFile(file);
17
+ const syntaxData = JSON.parse(text);
18
+ onSyntaxSelect(syntaxData, []);
19
+ onClose();
20
+ }, [onSyntaxSelect, onClose]);
21
+ return (
22
+ <Dialog data-testid="local-syntax-dialog" open onClose={onClose}>
23
+ <DialogTitle>Load syntax from local server</DialogTitle>
24
+ <DialogContent sx={{ minWidth: '400px' }}>
25
+ <ServerStaticFileSelect onFileSelected={onFileSelected} type="syntax" />
26
+ </DialogContent>
27
+ </Dialog>
28
+ )
29
+ }
30
+
10
31
  function ExistingSyntaxDialog({ onClose, onSyntaxSelect }) {
11
32
  const [syntaxes, setSyntaxes] = React.useState([]);
12
33
  const [connectAttempt, setConnectAttempt] = React.useState(0);
13
34
  const [requestStatus, setRequestStatus] = React.useState({ status: 'loading' });
14
35
  const [loadError, setLoadError] = React.useState(null);
15
36
  const fileInputRef = React.useRef(null);
37
+ const { staticContentPath } = useConfig();
38
+ const [localDialogOpen, setLocalDialogOpen] = React.useState(false);
16
39
 
17
40
  React.useEffect(() => {
18
41
  setRequestStatus({ status: 'loading' });
@@ -88,13 +111,21 @@ function ExistingSyntaxDialog({ onClose, onSyntaxSelect }) {
88
111
  onChange={handleFileUpload}
89
112
  style={{ display: 'none' }}
90
113
  />
91
- <Button
92
- variant="outlined"
93
- sx={{ display: 'block', mx: 'auto' }}
94
- onClick={() => fileInputRef.current?.click()}
95
- >
96
- Upload syntax from JSON file
97
- </Button>
114
+ <ButtonGroup sx={{ display: 'flex', justifyContent: 'center' }}>
115
+ <Button
116
+ onClick={() => fileInputRef.current?.click()}
117
+ >
118
+ Upload syntax from JSON file
119
+ </Button>
120
+ {staticContentPath && (
121
+ <Button
122
+ onClick={() => setLocalDialogOpen(true)}
123
+ >
124
+ Load syntax from local server
125
+ </Button>
126
+ )}
127
+ </ButtonGroup>
128
+ {localDialogOpen && <LocalSyntaxDialog onClose={() => {setLocalDialogOpen(false); onClose()}} onSyntaxSelect={onSyntaxSelect} />}
98
129
  </Box>
99
130
  </DialogContent>
100
131
  </Dialog>
@@ -22,7 +22,7 @@ function PlasmidRow({ plasmid }) {
22
22
  backgroundColor: partInfo[0]?.color,
23
23
  }
24
24
  infoStr = partInfo[0] ? partInfo[0].name : 'Spans multiple parts';
25
- longestFeatureStr = longestFeature ? longestFeature[0].name : '-';
25
+ longestFeatureStr = longestFeature[0] ? longestFeature[0].name : '-';
26
26
  }
27
27
  const multipleParts = partInfo.length > 1;
28
28
  if (multipleParts) {