@opencloning/ui 1.2.0 → 1.3.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.
- package/CHANGELOG.md +27 -0
- package/package.json +4 -3
- package/src/components/assembler/Assembler.cy.jsx +364 -0
- package/src/components/assembler/Assembler.jsx +298 -205
- package/src/components/assembler/AssemblerPart.cy.jsx +52 -0
- package/src/components/assembler/AssemblerPart.jsx +51 -79
- package/src/components/assembler/ExistingSyntaxDialog.cy.jsx +251 -0
- package/src/components/assembler/ExistingSyntaxDialog.jsx +104 -0
- package/src/components/assembler/PlasmidSyntaxTable.jsx +83 -0
- package/src/components/assembler/assembler_utils.js +134 -0
- package/src/components/assembler/assembler_utils.test.js +193 -0
- package/src/components/assembler/assembly_component.module.css +1 -1
- package/src/components/assembler/graph_utils.js +153 -0
- package/src/components/assembler/graph_utils.test.js +239 -0
- package/src/components/assembler/index.js +9 -0
- package/src/components/assembler/useAssembler.js +59 -22
- package/src/components/assembler/useCombinatorialAssembly.js +76 -0
- package/src/components/assembler/usePlasmidsLogic.js +82 -0
- package/src/components/eLabFTW/utils.js +0 -9
- package/src/components/index.js +2 -0
- package/src/components/navigation/SelectTemplateDialog.jsx +0 -1
- package/src/components/primers/DownloadPrimersButton.jsx +0 -1
- package/src/components/primers/PrimerList.jsx +4 -3
- package/src/components/primers/import_primers/ImportPrimersButton.jsx +0 -1
- package/src/version.js +1 -1
- package/vitest.config.js +2 -4
- package/src/components/DraggableDialogPaper.jsx +0 -16
- package/src/components/assembler/AssemblePartWidget.jsx +0 -252
- package/src/components/assembler/StopIcon.jsx +0 -34
- package/src/components/assembler/assembler_data2.json +0 -50
- package/src/components/assembler/moclo.json +0 -110
|
@@ -1,97 +1,69 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import styles from './assembly_component.module.css'
|
|
3
3
|
|
|
4
|
-
import { getAminoAcidFromSequenceTriplet, getComplementSequenceString } from '@teselagen/sequence-utils'
|
|
5
4
|
import { getSvgByGlyph } from './sbol_visual_glyphs'
|
|
5
|
+
import { partDataToDisplayData } from './assembler_utils'
|
|
6
6
|
|
|
7
|
+
export function AssemblerPartCore({ color = 'lightgray', glyph = 'engineered-region' }) {
|
|
8
|
+
return (
|
|
9
|
+
<div data-testid="assembler-part-core" className={styles.boxContainer}>
|
|
10
|
+
<div className={styles.box}>
|
|
11
|
+
<div className={styles.imageContainer} style={{ backgroundColor: color }}>
|
|
12
|
+
<img src={getSvgByGlyph(glyph)} alt={`${glyph}.svg`} />
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
7
18
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
export function DisplayOverhang( { overhang, overhangRc, translation, isRight = false } ) {
|
|
20
|
+
return (
|
|
21
|
+
<div data-testid="display-overhang" className={`${styles.dna} ${styles.overhang} ${isRight ? styles.right : styles.left}`}>
|
|
22
|
+
<div className={styles.top}>{translation}</div>
|
|
23
|
+
<div className={styles.watson}>{overhang}</div>
|
|
24
|
+
<div className={styles.crick}>{overhangRc}</div>
|
|
25
|
+
<div className={styles.bottom}> </div>
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
export function DisplayInside( { inside, insideRc, translation, isRight = false } ) {
|
|
30
|
+
return (
|
|
31
|
+
<div data-testid="display-inside" className={`${styles.dna} ${styles.inside} ${isRight ? styles.right : styles.left}`}>
|
|
32
|
+
<div className={styles.top}>{translation}</div>
|
|
33
|
+
<div className={styles.watson}>{inside}</div>
|
|
34
|
+
<div className={styles.crick}>{insideRc}</div>
|
|
35
|
+
<div className={styles.bottom}> </div>
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
21
39
|
|
|
40
|
+
export function AssemblerPartContainer({ children }) {
|
|
41
|
+
return (
|
|
42
|
+
<div className={styles.container}>
|
|
43
|
+
{children}
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
22
47
|
|
|
23
|
-
function AssemblerPart( { data =
|
|
24
|
-
const {
|
|
25
|
-
|
|
26
|
-
right_codon_start: rightCodonStart,
|
|
27
|
-
left_overhang: leftOverhang,
|
|
28
|
-
right_overhang: rightOverhang,
|
|
29
|
-
left_inside: leftInside,
|
|
30
|
-
right_inside: rightInside,
|
|
31
|
-
glyph
|
|
32
|
-
} = data
|
|
33
|
-
const leftOverhangRc = getComplementSequenceString(leftOverhang)
|
|
34
|
-
const rightOverhangRc = getComplementSequenceString(rightOverhang)
|
|
35
|
-
const leftInsideRc = getComplementSequenceString(leftInside)
|
|
36
|
-
const rightInsideRc = getComplementSequenceString(rightInside)
|
|
37
|
-
let leftTranslationOverhang = ''
|
|
38
|
-
let leftTranslationInside = ''
|
|
39
|
-
if (leftCodonStart) {
|
|
40
|
-
const triplets = (leftOverhang + leftInside).slice(leftCodonStart - 1).match(/.{3}/g)
|
|
41
|
-
const padding = ' '.repeat(leftCodonStart - 1)
|
|
42
|
-
const translationLeft = padding + triplets.map(triplet => getAminoAcidFromSequenceTriplet(triplet).threeLettersName.replace('Stop', '***')).join('')
|
|
43
|
-
leftTranslationOverhang = translationLeft.slice(0, leftOverhang.length)
|
|
44
|
-
leftTranslationInside = translationLeft.slice(leftOverhang.length)
|
|
45
|
-
}
|
|
46
|
-
let rightTranslationOverhang = ''
|
|
47
|
-
let rightTranslationInside = ''
|
|
48
|
-
if (rightCodonStart) {
|
|
49
|
-
const triplets = (rightInside + rightOverhang).slice(rightCodonStart - 1).match(/.{3}/g)
|
|
50
|
-
const padding = ' '.repeat(rightCodonStart - 1)
|
|
51
|
-
const translationRight = padding + triplets.map(triplet => getAminoAcidFromSequenceTriplet(triplet).threeLettersName.replace('Stop', '***')).join('')
|
|
52
|
-
rightTranslationInside = translationRight.slice(0, rightInside.length)
|
|
53
|
-
rightTranslationOverhang = translationRight.slice(rightInside.length)
|
|
54
|
-
}
|
|
48
|
+
function AssemblerPart( { data, showRight = true } ) {
|
|
49
|
+
const { left_overhang: leftOverhang, right_overhang: rightOverhang, left_inside: leftInside, right_inside: rightInside, glyph } = data
|
|
50
|
+
const { leftTranslationOverhang, leftTranslationInside, rightTranslationOverhang, rightTranslationInside, leftOverhangRc, rightOverhangRc, leftInsideRc, rightInsideRc } = partDataToDisplayData(data)
|
|
55
51
|
|
|
56
52
|
return (
|
|
57
53
|
|
|
58
|
-
<
|
|
59
|
-
<
|
|
60
|
-
<div className={styles.top}>{leftTranslationOverhang}</div>
|
|
61
|
-
<div className={styles.watson}>{leftOverhang}</div>
|
|
62
|
-
<div className={styles.crick}>{leftOverhangRc}</div>
|
|
63
|
-
<div className={styles.bottom}> </div>
|
|
64
|
-
</div>
|
|
54
|
+
<AssemblerPartContainer>
|
|
55
|
+
<DisplayOverhang overhang={leftOverhang} overhangRc={leftOverhangRc} translation={leftTranslationOverhang} isRight={false} />
|
|
65
56
|
{leftInside && (
|
|
66
|
-
<
|
|
67
|
-
<div className={styles.top}>{leftTranslationInside}</div>
|
|
68
|
-
<div className={styles.watson}>{leftInside}</div>
|
|
69
|
-
<div className={styles.crick}>{leftInsideRc}</div>
|
|
70
|
-
<div className={styles.bottom}> </div>
|
|
71
|
-
</div>
|
|
57
|
+
<DisplayInside inside={leftInside} insideRc={leftInsideRc} translation={leftTranslationInside} isRight={false} />
|
|
72
58
|
)}
|
|
73
|
-
<
|
|
74
|
-
<div className={styles.box}>
|
|
75
|
-
<div className={styles.imageContainer} style={{ backgroundColor: data.color || 'lightgray' }}>
|
|
76
|
-
<img src={getSvgByGlyph(glyph)} alt={`${glyph}.svg`} />
|
|
77
|
-
</div>
|
|
78
|
-
</div>
|
|
79
|
-
</div>
|
|
59
|
+
<AssemblerPartCore color={data.color || 'lightgray'} glyph={glyph} />
|
|
80
60
|
{rightInside && (
|
|
81
|
-
<
|
|
82
|
-
<div className={styles.top}>{rightTranslationInside}</div>
|
|
83
|
-
<div className={styles.watson}>{rightInside}</div>
|
|
84
|
-
<div className={styles.crick}>{rightInsideRc}</div>
|
|
85
|
-
<div className={styles.bottom}> </div>
|
|
86
|
-
</div>
|
|
61
|
+
<DisplayInside inside={rightInside} insideRc={rightInsideRc} translation={rightTranslationInside} isRight={true} />
|
|
87
62
|
)}
|
|
88
|
-
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
<div className={styles.bottom}> </div>
|
|
93
|
-
</div>
|
|
94
|
-
</div>
|
|
63
|
+
{ showRight && (
|
|
64
|
+
<DisplayOverhang overhang={rightOverhang} overhangRc={rightOverhangRc} translation={rightTranslationOverhang} isRight={true} />
|
|
65
|
+
)}
|
|
66
|
+
</AssemblerPartContainer>
|
|
95
67
|
|
|
96
68
|
)
|
|
97
69
|
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ExistingSyntaxDialog from './ExistingSyntaxDialog';
|
|
3
|
+
|
|
4
|
+
const mockSyntaxes = [
|
|
5
|
+
{
|
|
6
|
+
path: 'test-syntax-1',
|
|
7
|
+
name: 'Test Syntax 1',
|
|
8
|
+
description: 'First test syntax',
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
path: 'test-syntax-2',
|
|
12
|
+
name: 'Test Syntax 2',
|
|
13
|
+
description: 'Second test syntax',
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const mockSyntaxData = {
|
|
18
|
+
name: 'Test Syntax',
|
|
19
|
+
parts: [],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const mockPlasmidsData = [
|
|
23
|
+
{
|
|
24
|
+
id: 1,
|
|
25
|
+
name: 'Test Plasmid',
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
describe('<ExistingSyntaxDialog />', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
// Intercept the index.json request
|
|
32
|
+
cy.intercept('GET', 'https://assets.opencloning.org/syntaxes/syntaxes/index.json', {
|
|
33
|
+
statusCode: 200,
|
|
34
|
+
body: mockSyntaxes,
|
|
35
|
+
}).as('getSyntaxes');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('loads and displays syntaxes successfully', () => {
|
|
39
|
+
const onCloseSpy = cy.spy().as('onCloseSpy');
|
|
40
|
+
const onSyntaxSelectSpy = cy.spy().as('onSyntaxSelectSpy');
|
|
41
|
+
|
|
42
|
+
cy.mount(
|
|
43
|
+
<ExistingSyntaxDialog
|
|
44
|
+
onClose={onCloseSpy}
|
|
45
|
+
onSyntaxSelect={onSyntaxSelectSpy}
|
|
46
|
+
/>,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
cy.wait('@getSyntaxes');
|
|
50
|
+
cy.contains('Load an existing syntax').should('exist');
|
|
51
|
+
cy.contains('Test Syntax 1').should('exist');
|
|
52
|
+
cy.contains('First test syntax').should('exist');
|
|
53
|
+
cy.contains('Test Syntax 2').should('exist');
|
|
54
|
+
cy.contains('Second test syntax').should('exist');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('successfully loads syntax and plasmids data when clicking a syntax', () => {
|
|
58
|
+
const onCloseSpy = cy.spy().as('onCloseSpy');
|
|
59
|
+
const onSyntaxSelectSpy = cy.spy().as('onSyntaxSelectSpy');
|
|
60
|
+
|
|
61
|
+
// Intercept syntax.json and plasmids.json requests
|
|
62
|
+
cy.intercept('GET', 'https://assets.opencloning.org/syntaxes/syntaxes/test-syntax-1/syntax.json', {
|
|
63
|
+
statusCode: 200,
|
|
64
|
+
body: mockSyntaxData,
|
|
65
|
+
}).as('getSyntaxData');
|
|
66
|
+
|
|
67
|
+
cy.intercept('GET', 'https://assets.opencloning.org/syntaxes/syntaxes/test-syntax-1/plasmids.json', {
|
|
68
|
+
statusCode: 200,
|
|
69
|
+
body: mockPlasmidsData,
|
|
70
|
+
}).as('getPlasmidsData');
|
|
71
|
+
|
|
72
|
+
cy.mount(
|
|
73
|
+
<ExistingSyntaxDialog
|
|
74
|
+
onClose={onCloseSpy}
|
|
75
|
+
onSyntaxSelect={onSyntaxSelectSpy}
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
cy.wait('@getSyntaxes');
|
|
80
|
+
cy.contains('Test Syntax 1').click();
|
|
81
|
+
|
|
82
|
+
cy.wait('@getSyntaxData');
|
|
83
|
+
cy.wait('@getPlasmidsData');
|
|
84
|
+
|
|
85
|
+
cy.get('@onSyntaxSelectSpy').should('have.been.calledWith', mockSyntaxData, mockPlasmidsData);
|
|
86
|
+
cy.get('@onCloseSpy').should('have.been.called');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('displays error message when syntax.json request fails', () => {
|
|
90
|
+
const onCloseSpy = cy.spy().as('onCloseSpy');
|
|
91
|
+
const onSyntaxSelectSpy = cy.spy().as('onSyntaxSelectSpy');
|
|
92
|
+
|
|
93
|
+
// Intercept syntax.json with error
|
|
94
|
+
cy.intercept('GET', 'https://assets.opencloning.org/syntaxes/syntaxes/test-syntax-1/syntax.json', {
|
|
95
|
+
statusCode: 500,
|
|
96
|
+
body: { error: 'Internal server error' },
|
|
97
|
+
}).as('getSyntaxDataError');
|
|
98
|
+
|
|
99
|
+
cy.mount(
|
|
100
|
+
<ExistingSyntaxDialog
|
|
101
|
+
onClose={onCloseSpy}
|
|
102
|
+
onSyntaxSelect={onSyntaxSelectSpy}
|
|
103
|
+
/>,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
cy.wait('@getSyntaxes');
|
|
107
|
+
cy.contains('Test Syntax 1').click();
|
|
108
|
+
|
|
109
|
+
cy.wait('@getSyntaxDataError');
|
|
110
|
+
|
|
111
|
+
cy.contains('Failed to load syntax data. Please try again.').should('exist');
|
|
112
|
+
cy.get('@onSyntaxSelectSpy').should('not.have.been.called');
|
|
113
|
+
cy.get('@onCloseSpy').should('not.have.been.called');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('displays error message when plasmids.json request fails', () => {
|
|
117
|
+
const onCloseSpy = cy.spy().as('onCloseSpy');
|
|
118
|
+
const onSyntaxSelectSpy = cy.spy().as('onSyntaxSelectSpy');
|
|
119
|
+
|
|
120
|
+
// Intercept syntax.json successfully
|
|
121
|
+
cy.intercept('GET', 'https://assets.opencloning.org/syntaxes/syntaxes/test-syntax-1/syntax.json', {
|
|
122
|
+
statusCode: 200,
|
|
123
|
+
body: mockSyntaxData,
|
|
124
|
+
}).as('getSyntaxData');
|
|
125
|
+
|
|
126
|
+
// Intercept plasmids.json with error
|
|
127
|
+
cy.intercept('GET', 'https://assets.opencloning.org/syntaxes/syntaxes/test-syntax-1/plasmids.json', {
|
|
128
|
+
statusCode: 500,
|
|
129
|
+
body: { error: 'Internal server error' },
|
|
130
|
+
}).as('getPlasmidsDataError');
|
|
131
|
+
|
|
132
|
+
cy.mount(
|
|
133
|
+
<ExistingSyntaxDialog
|
|
134
|
+
onClose={onCloseSpy}
|
|
135
|
+
onSyntaxSelect={onSyntaxSelectSpy}
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
cy.wait('@getSyntaxes');
|
|
140
|
+
cy.contains('Test Syntax 1').click();
|
|
141
|
+
|
|
142
|
+
cy.wait('@getSyntaxData');
|
|
143
|
+
cy.wait('@getPlasmidsDataError');
|
|
144
|
+
|
|
145
|
+
cy.contains('Failed to load plasmids data. Please try again.').should('exist');
|
|
146
|
+
cy.get('@onSyntaxSelectSpy').should('not.have.been.called');
|
|
147
|
+
cy.get('@onCloseSpy').should('not.have.been.called');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('clears error message when selecting a different syntax after error', () => {
|
|
151
|
+
const onCloseSpy = cy.spy().as('onCloseSpy');
|
|
152
|
+
const onSyntaxSelectSpy = cy.spy().as('onSyntaxSelectSpy');
|
|
153
|
+
|
|
154
|
+
// First syntax fails
|
|
155
|
+
cy.intercept('GET', 'https://assets.opencloning.org/syntaxes/syntaxes/test-syntax-1/syntax.json', {
|
|
156
|
+
statusCode: 500,
|
|
157
|
+
body: { error: 'Internal server error' },
|
|
158
|
+
}).as('getSyntaxDataError');
|
|
159
|
+
|
|
160
|
+
// Second syntax succeeds
|
|
161
|
+
cy.intercept('GET', 'https://assets.opencloning.org/syntaxes/syntaxes/test-syntax-2/syntax.json', {
|
|
162
|
+
statusCode: 200,
|
|
163
|
+
body: mockSyntaxData,
|
|
164
|
+
}).as('getSyntaxData2');
|
|
165
|
+
|
|
166
|
+
cy.intercept('GET', 'https://assets.opencloning.org/syntaxes/syntaxes/test-syntax-2/plasmids.json', {
|
|
167
|
+
statusCode: 200,
|
|
168
|
+
body: mockPlasmidsData,
|
|
169
|
+
}).as('getPlasmidsData2');
|
|
170
|
+
|
|
171
|
+
cy.mount(
|
|
172
|
+
<ExistingSyntaxDialog
|
|
173
|
+
onClose={onCloseSpy}
|
|
174
|
+
onSyntaxSelect={onSyntaxSelectSpy}
|
|
175
|
+
/>,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
cy.wait('@getSyntaxes');
|
|
179
|
+
|
|
180
|
+
// Click first syntax (fails)
|
|
181
|
+
cy.contains('Test Syntax 1').click();
|
|
182
|
+
cy.wait('@getSyntaxDataError');
|
|
183
|
+
cy.contains('Failed to load syntax data. Please try again.').should('exist');
|
|
184
|
+
|
|
185
|
+
// Click second syntax (succeeds) - error should be cleared
|
|
186
|
+
cy.contains('Test Syntax 2').click();
|
|
187
|
+
cy.wait('@getSyntaxData2');
|
|
188
|
+
cy.wait('@getPlasmidsData2');
|
|
189
|
+
|
|
190
|
+
cy.contains('Failed to load syntax data. Please try again.').should('not.exist');
|
|
191
|
+
cy.get('@onSyntaxSelectSpy').should('have.been.calledWith', mockSyntaxData, mockPlasmidsData);
|
|
192
|
+
cy.get('@onCloseSpy').should('have.been.called');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('successfully uploads syntax from JSON file', () => {
|
|
196
|
+
const onCloseSpy = cy.spy().as('onCloseSpy');
|
|
197
|
+
const onSyntaxSelectSpy = cy.spy().as('onSyntaxSelectSpy');
|
|
198
|
+
|
|
199
|
+
const uploadedSyntaxData = {
|
|
200
|
+
name: 'Uploaded Syntax',
|
|
201
|
+
parts: [
|
|
202
|
+
{ id: 1, name: 'Part 1' },
|
|
203
|
+
{ id: 2, name: 'Part 2' },
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Create a temporary JSON file
|
|
208
|
+
cy.writeFile('cypress/temp/syntax.json', uploadedSyntaxData);
|
|
209
|
+
|
|
210
|
+
cy.mount(
|
|
211
|
+
<ExistingSyntaxDialog
|
|
212
|
+
onClose={onCloseSpy}
|
|
213
|
+
onSyntaxSelect={onSyntaxSelectSpy}
|
|
214
|
+
/>,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
cy.wait('@getSyntaxes');
|
|
218
|
+
cy.contains('Upload syntax from JSON file').should('exist');
|
|
219
|
+
|
|
220
|
+
// Upload the JSON file
|
|
221
|
+
cy.get('input[type="file"]').selectFile('cypress/temp/syntax.json', { force: true });
|
|
222
|
+
|
|
223
|
+
cy.get('@onSyntaxSelectSpy').should('have.been.calledWith', uploadedSyntaxData, []);
|
|
224
|
+
cy.get('@onCloseSpy').should('have.been.called');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('displays error message when uploaded JSON file is invalid', () => {
|
|
228
|
+
const onCloseSpy = cy.spy().as('onCloseSpy');
|
|
229
|
+
const onSyntaxSelectSpy = cy.spy().as('onSyntaxSelectSpy');
|
|
230
|
+
|
|
231
|
+
// Create a file with invalid JSON
|
|
232
|
+
cy.writeFile('cypress/temp/invalid.json', '{ invalid json }', { encoding: 'utf8' });
|
|
233
|
+
|
|
234
|
+
cy.mount(
|
|
235
|
+
<ExistingSyntaxDialog
|
|
236
|
+
onClose={onCloseSpy}
|
|
237
|
+
onSyntaxSelect={onSyntaxSelectSpy}
|
|
238
|
+
/>,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
cy.wait('@getSyntaxes');
|
|
242
|
+
cy.contains('Upload syntax from JSON file').should('exist');
|
|
243
|
+
|
|
244
|
+
// Upload invalid JSON
|
|
245
|
+
cy.get('input[type="file"]').selectFile('cypress/temp/invalid.json', { force: true });
|
|
246
|
+
|
|
247
|
+
cy.contains(/Failed to parse JSON file/).should('exist');
|
|
248
|
+
cy.get('@onSyntaxSelectSpy').should('not.have.been.called');
|
|
249
|
+
cy.get('@onCloseSpy').should('not.have.been.called');
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Dialog, DialogTitle, DialogContent, List, ListItem, ListItemText, ListItemButton, Alert, Button, Box } from '@mui/material'
|
|
3
|
+
import getHttpClient from '@opencloning/utils/getHttpClient';
|
|
4
|
+
import RequestStatusWrapper from '../form/RequestStatusWrapper';
|
|
5
|
+
|
|
6
|
+
const httpClient = getHttpClient();
|
|
7
|
+
const baseURL = 'https://assets.opencloning.org/syntaxes/syntaxes/';
|
|
8
|
+
httpClient.defaults.baseURL = baseURL;
|
|
9
|
+
|
|
10
|
+
function ExistingSyntaxDialog({ onClose, onSyntaxSelect }) {
|
|
11
|
+
const [syntaxes, setSyntaxes] = React.useState([]);
|
|
12
|
+
const [connectAttempt, setConnectAttempt] = React.useState(0);
|
|
13
|
+
const [requestStatus, setRequestStatus] = React.useState({ status: 'loading' });
|
|
14
|
+
const [loadError, setLoadError] = React.useState(null);
|
|
15
|
+
const fileInputRef = React.useRef(null);
|
|
16
|
+
|
|
17
|
+
React.useEffect(() => {
|
|
18
|
+
setRequestStatus({ status: 'loading' });
|
|
19
|
+
const fetchData = async () => {
|
|
20
|
+
try {
|
|
21
|
+
const { data } = await httpClient.get('index.json');
|
|
22
|
+
setRequestStatus({ status: 'success' });
|
|
23
|
+
setSyntaxes(data);
|
|
24
|
+
} catch {
|
|
25
|
+
setRequestStatus({ status: 'error', message: 'Could not load syntaxes' });
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
fetchData();
|
|
29
|
+
}, [connectAttempt]);
|
|
30
|
+
|
|
31
|
+
const onSyntaxClick = React.useCallback(async (syntax) => {
|
|
32
|
+
setLoadError(null);
|
|
33
|
+
let loadingErrorPart = 'syntax'
|
|
34
|
+
try {
|
|
35
|
+
const { data: syntaxData } = await httpClient.get(`${syntax.path}/syntax.json`);
|
|
36
|
+
loadingErrorPart = 'plasmids'
|
|
37
|
+
const { data: plasmidsData } = await httpClient.get(`${syntax.path}/plasmids.json`);
|
|
38
|
+
onSyntaxSelect(syntaxData, plasmidsData);
|
|
39
|
+
onClose();
|
|
40
|
+
} catch {
|
|
41
|
+
setLoadError(`Failed to load ${loadingErrorPart} data. Please try again.`);
|
|
42
|
+
}
|
|
43
|
+
}, [onSyntaxSelect, onClose]);
|
|
44
|
+
|
|
45
|
+
const handleFileUpload = React.useCallback(async (event) => {
|
|
46
|
+
setLoadError(null);
|
|
47
|
+
const file = event.target.files[0];
|
|
48
|
+
if (!file) return;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const text = await file.text();
|
|
52
|
+
const syntaxData = JSON.parse(text);
|
|
53
|
+
|
|
54
|
+
// Uploaded JSON files contain only syntax data, no plasmids
|
|
55
|
+
onSyntaxSelect(syntaxData, []);
|
|
56
|
+
onClose();
|
|
57
|
+
} catch (error) {
|
|
58
|
+
setLoadError(`Failed to parse JSON file: ${error.message}`);
|
|
59
|
+
} finally {
|
|
60
|
+
// Reset file input so the same file can be selected again
|
|
61
|
+
if (fileInputRef.current) {
|
|
62
|
+
fileInputRef.current.value = '';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}, [onSyntaxSelect, onClose]);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Dialog open onClose={onClose}>
|
|
69
|
+
<DialogTitle>Load an existing syntax</DialogTitle>
|
|
70
|
+
<DialogContent>
|
|
71
|
+
{loadError && <Alert severity="error" sx={{ mb: 2 }}>{loadError}</Alert>}
|
|
72
|
+
<RequestStatusWrapper requestStatus={requestStatus} retry={() => setConnectAttempt((prev) => prev + 1)}>
|
|
73
|
+
<List>
|
|
74
|
+
{syntaxes.map((syntax) => (
|
|
75
|
+
<ListItem key={syntax.path}>
|
|
76
|
+
<ListItemButton onClick={() => {onSyntaxClick(syntax)}}>
|
|
77
|
+
<ListItemText primary={syntax.name} secondary={syntax.description} />
|
|
78
|
+
</ListItemButton>
|
|
79
|
+
</ListItem>
|
|
80
|
+
))}
|
|
81
|
+
</List>
|
|
82
|
+
</RequestStatusWrapper>
|
|
83
|
+
<Box sx={{ mb: 2 }}>
|
|
84
|
+
<input
|
|
85
|
+
type="file"
|
|
86
|
+
ref={fileInputRef}
|
|
87
|
+
accept=".json"
|
|
88
|
+
onChange={handleFileUpload}
|
|
89
|
+
style={{ display: 'none' }}
|
|
90
|
+
/>
|
|
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>
|
|
98
|
+
</Box>
|
|
99
|
+
</DialogContent>
|
|
100
|
+
</Dialog>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export default ExistingSyntaxDialog
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { Table, TableHead, TableRow, TableCell, TableBody, Typography, Chip, Box } from '@mui/material'
|
|
4
|
+
|
|
5
|
+
function PartChip({ name, overhang}) {
|
|
6
|
+
const label = name ? `${overhang} (${name})` : overhang;
|
|
7
|
+
return (
|
|
8
|
+
<Chip label={label} size="small" sx={{ fontSize: '0.7rem', height: 20, fontFamily: 'monospace' }}
|
|
9
|
+
/>
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function PlasmidRow({ plasmid }) {
|
|
14
|
+
const { name, appData } = plasmid;
|
|
15
|
+
const { fileName, correspondingParts, correspondingPartsNames, partInfo, longestFeature } = appData;
|
|
16
|
+
let sx = undefined;
|
|
17
|
+
let infoStr = '-';
|
|
18
|
+
let longestFeatureStr = '-';
|
|
19
|
+
let noParts = partInfo.length === 0;
|
|
20
|
+
if (partInfo.length === 1) {
|
|
21
|
+
sx = {
|
|
22
|
+
backgroundColor: partInfo[0]?.color,
|
|
23
|
+
}
|
|
24
|
+
infoStr = partInfo[0] ? partInfo[0].name : 'Spans multiple parts';
|
|
25
|
+
longestFeatureStr = longestFeature ? longestFeature[0].name : '-';
|
|
26
|
+
}
|
|
27
|
+
const multipleParts = partInfo.length > 1;
|
|
28
|
+
if (multipleParts) {
|
|
29
|
+
infoStr = 'Contains multiple parts';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<TableRow sx={sx}>
|
|
34
|
+
<TableCell sx={{ maxWidth: 200 }}>
|
|
35
|
+
<Typography variant="body2" noWrap title={name}>
|
|
36
|
+
{name}
|
|
37
|
+
</Typography>
|
|
38
|
+
</TableCell>
|
|
39
|
+
<TableCell sx={{ maxWidth: 200 }}>
|
|
40
|
+
<Typography variant="body2" noWrap title={fileName}>
|
|
41
|
+
{fileName}
|
|
42
|
+
</Typography>
|
|
43
|
+
</TableCell>
|
|
44
|
+
<TableCell sx={multipleParts ? {backgroundColor: 'red'} : null}>
|
|
45
|
+
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
|
46
|
+
{noParts ? '-' : correspondingParts.map((part, idx) => (
|
|
47
|
+
<PartChip key={idx} name={correspondingPartsNames[idx]} overhang={part}
|
|
48
|
+
/>
|
|
49
|
+
))}
|
|
50
|
+
</Box>
|
|
51
|
+
</TableCell>
|
|
52
|
+
<TableCell>
|
|
53
|
+
<Typography variant="body2">{infoStr}</Typography>
|
|
54
|
+
</TableCell>
|
|
55
|
+
<TableCell>
|
|
56
|
+
<Typography variant="body2">{longestFeatureStr}</Typography>
|
|
57
|
+
</TableCell>
|
|
58
|
+
</TableRow>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function PlasmidSyntaxTable( { plasmids } ) {
|
|
63
|
+
return (
|
|
64
|
+
<Table stickyHeader size="small">
|
|
65
|
+
<TableHead sx={{ '& th': { fontWeight: 'bold' } }}>
|
|
66
|
+
<TableRow>
|
|
67
|
+
<TableCell>Name</TableCell>
|
|
68
|
+
<TableCell>File Name</TableCell>
|
|
69
|
+
<TableCell>Overhangs</TableCell>
|
|
70
|
+
<TableCell>Part Info</TableCell>
|
|
71
|
+
<TableCell>Longest Feature</TableCell>
|
|
72
|
+
</TableRow>
|
|
73
|
+
</TableHead>
|
|
74
|
+
<TableBody>
|
|
75
|
+
{plasmids.map((plasmid, index) => (
|
|
76
|
+
<PlasmidRow key={index} plasmid={plasmid} />
|
|
77
|
+
))}
|
|
78
|
+
</TableBody>
|
|
79
|
+
</Table>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default PlasmidSyntaxTable
|