Skip to content

Commit 366c2a5

Browse files
authored
Merge branch 'dev' into dependabot/npm_and_yarn/npm_and_yarn-04d31be16b
2 parents c8cd623 + 210ebea commit 366c2a5

26 files changed

Lines changed: 534 additions & 188 deletions

package-lock.json

Lines changed: 15 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/controller/workflow.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1-
from model.workflows import *
1+
from model.workflows import WorkFlowModel
22
from flask import request, make_response, Blueprint
33
import defusedxml.ElementTree as ET
44

55
workFlow = Blueprint('workflow', __name__)
66
workFlowModel = WorkFlowModel()
77

88

9+
def isMissingWorkflow(graphml):
10+
if graphml is None:
11+
return True
12+
# Backward-compatible guard for legacy model return type.
13+
return isinstance(graphml, tuple) and len(graphml) > 0 and graphml[0] is False
14+
15+
916
def getLasteshActionHash(root):
1017
xmlns = root.tag[root.tag.index('{')+1:root.tag.rindex('}')]
1118
return root.find(f'{{{xmlns}}}graph')\
@@ -32,7 +39,7 @@ def postWorkflow():
3239
@workFlow.route("/<serverID>")
3340
def getWorkflow(serverID):
3441
graphml = workFlowModel.get(serverID)
35-
if graphml is None:
42+
if isMissingWorkflow(graphml):
3643
return "Not Found", 404
3744
if('X-Latest-Hash' in request.headers):
3845
latestHash = request.headers['X-Latest-Hash']

server/model/workflows.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def insert(self, graphml, latestHash):
3232
def get(self, serverID):
3333
cl = self.collection.find_one({'serverID': serverID})
3434
if not cl:
35-
return False, 'Record Not Found'
35+
return None
3636
return cl['graphml']
3737

3838
def update(self, serverID, graphml, latestHash, allHash):

server/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
dnspython==2.6.1
2-
Flask==2.2.5
2+
Flask==3.1.3
33
python-dotenv==0.19.0
44
pymongo==4.6.3
55
gunicorn==23.0.0
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import importlib
2+
import pathlib
3+
import sys
4+
import types
5+
import unittest
6+
7+
from flask import Flask
8+
9+
VALID_GRAPHML = (
10+
'<graphml xmlns="http://graphml.graphdrawing.org/xmlns">'
11+
'<graph edgedefault="directed">'
12+
'<actionHistory><hash>hash-1</hash></actionHistory>'
13+
'</graph>'
14+
'</graphml>'
15+
)
16+
17+
18+
class FakeWorkFlowModel:
19+
def __init__(self, graph_response):
20+
self.graph_response = graph_response
21+
22+
def get(self, _server_id):
23+
return self.graph_response
24+
25+
26+
class WorkflowControllerTests(unittest.TestCase):
27+
@classmethod
28+
def setUpClass(cls):
29+
server_root = pathlib.Path(__file__).resolve().parents[1]
30+
if str(server_root) not in sys.path:
31+
sys.path.insert(0, str(server_root))
32+
33+
fake_model_pkg = types.ModuleType('model')
34+
fake_model_workflows = types.ModuleType('model.workflows')
35+
36+
class StubWorkFlowModel:
37+
def get(self, _server_id):
38+
return None
39+
40+
fake_model_workflows.WorkFlowModel = StubWorkFlowModel
41+
fake_model_pkg.workflows = fake_model_workflows
42+
43+
sys.modules['model'] = fake_model_pkg
44+
sys.modules['model.workflows'] = fake_model_workflows
45+
46+
if 'controller.workflow' in sys.modules:
47+
del sys.modules['controller.workflow']
48+
cls.workflow_module = importlib.import_module('controller.workflow')
49+
50+
def make_client(self, graph_response):
51+
self.workflow_module.workFlowModel = FakeWorkFlowModel(graph_response)
52+
app = Flask(__name__)
53+
app.register_blueprint(self.workflow_module.workFlow, url_prefix='/workflow')
54+
return app.test_client()
55+
56+
def test_missing_workflow_returns_404_for_none(self):
57+
client = self.make_client(None)
58+
response = client.get('/workflow/missing-id')
59+
self.assertEqual(response.status_code, 404)
60+
self.assertEqual(response.get_data(as_text=True), 'Not Found')
61+
62+
def test_missing_workflow_returns_404_for_legacy_tuple(self):
63+
client = self.make_client((False, 'Record Not Found'))
64+
response = client.get('/workflow/missing-id')
65+
self.assertEqual(response.status_code, 404)
66+
self.assertEqual(response.get_data(as_text=True), 'Not Found')
67+
68+
def test_hash_header_returns_400_for_different_history(self):
69+
client = self.make_client(VALID_GRAPHML)
70+
response = client.get('/workflow/existing-id', headers={'X-Latest-Hash': 'unknown-hash'})
71+
self.assertEqual(response.status_code, 400)
72+
self.assertEqual(response.get_data(as_text=True), 'Different History')
73+
74+
def test_hash_header_returns_200_for_matching_history(self):
75+
client = self.make_client(VALID_GRAPHML)
76+
response = client.get('/workflow/existing-id', headers={'X-Latest-Hash': 'hash-1'})
77+
self.assertEqual(response.status_code, 200)
78+
self.assertEqual(response.get_data(as_text=True), VALID_GRAPHML)
79+
80+
81+
if __name__ == '__main__':
82+
unittest.main()

src/GraphArea.jsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,18 @@ function Graph({
5656
}, [active, instance, graphID, dispatcher]);
5757

5858
useEffect(() => {
59+
const handleResize = () => setContainerDim(ref.current);
60+
let graph = null;
5961
if (ref.current) {
6062
setContainerDim(ref.current);
61-
window.addEventListener('resize', () => setContainerDim(ref.current));
62-
setInstance(initialiseNewGraph());
63+
window.addEventListener('resize', handleResize);
64+
graph = initialiseNewGraph();
65+
setInstance(graph);
6366
}
67+
return () => {
68+
window.removeEventListener('resize', handleResize);
69+
if (graph) graph.dispose();
70+
};
6471
}, [ref]);
6572

6673
// Update theme when darkMode changes

src/GraphWorkspace.jsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22
import ZoomComp from './component/ZoomSetter';
3-
4-
// import { actionType as T } from './reducer';
3+
import ConfirmModal from './component/modals/ConfirmModal';
4+
import { actionType as T } from './reducer';
55
import './graphWorkspace.css';
66
// import localStorageManager from './graph-builder/local-storage-manager';
77
import TabBar from './component/TabBar';
@@ -80,6 +80,16 @@ const GraphComp = (props) => {
8080
))}
8181
<ZoomComp dispatcher={dispatcher} superState={superState} />
8282
</div>
83+
<ConfirmModal
84+
isOpen={superState.confirmModal.open}
85+
title="Confirm"
86+
message={superState.confirmModal.message}
87+
onConfirm={() => {
88+
if (superState.confirmModal.onConfirm) superState.confirmModal.onConfirm();
89+
dispatcher({ type: T.SET_CONFIRM_MODAL, payload: { open: false, message: '', onConfirm: null } });
90+
}}
91+
onCancel={() => dispatcher({ type: T.SET_CONFIRM_MODAL, payload: { open: false, message: '', onConfirm: null } })}
92+
/>
8393
</div>
8494
);
8595
};

src/component/ErrorBoundary.jsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from 'react';
2+
3+
const crashStyle = {
4+
minHeight: '100vh',
5+
display: 'flex',
6+
alignItems: 'center',
7+
justifyContent: 'center',
8+
padding: '24px',
9+
backgroundColor: 'var(--bg-primary)',
10+
color: 'var(--text-primary)',
11+
textAlign: 'center',
12+
};
13+
14+
class ErrorBoundary extends React.Component {
15+
constructor(props) {
16+
super(props);
17+
this.state = { hasError: false };
18+
}
19+
20+
static getDerivedStateFromError() {
21+
return { hasError: true };
22+
}
23+
24+
render() {
25+
const { hasError } = this.state;
26+
const { children } = this.props;
27+
28+
if (hasError) {
29+
return (
30+
<div style={crashStyle}>
31+
<div>
32+
<h2 style={{ marginBottom: '8px' }}>Something went wrong</h2>
33+
<p style={{ marginBottom: '16px', opacity: 0.9 }}>
34+
The app hit an unexpected error. Reload to continue.
35+
</p>
36+
<button
37+
type="button"
38+
className="btn btn-primary"
39+
onClick={() => window.location.reload()}
40+
>
41+
Reload
42+
</button>
43+
</div>
44+
</div>
45+
);
46+
}
47+
48+
return children;
49+
}
50+
}
51+
52+
export default ErrorBoundary;

src/component/File-drag-drop.jsx

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,42 +6,58 @@ import { readFile } from '../toolbarActions/toolbarFunctions';
66

77
const app = ({ superState, dispatcher }) => {
88
const fileRef = React.useRef();
9+
const superStateRef = React.useRef(superState);
10+
const dispatcherRef = React.useRef(dispatcher);
11+
12+
useEffect(() => {
13+
superStateRef.current = superState;
14+
dispatcherRef.current = dispatcher;
15+
});
916

1017
useEffect(() => {
1118
dispatcher({ type: T.SET_FILE_REF, payload: fileRef });
1219
const p = document.getElementsByTagName('body')[0];
1320
const c = document.getElementsByClassName('drag-drop-area')[0];
1421
let cc = 0;
15-
p.addEventListener('dragenter', (e) => {
22+
23+
const onDragEnter = (e) => {
1624
e.preventDefault();
1725
cc += 1;
1826
if (cc === 1) c.classList.remove('hidden');
19-
});
20-
p.addEventListener('dragleave', (e) => {
27+
};
28+
const onDragLeave = (e) => {
2129
e.preventDefault();
2230
cc -= 1;
2331
if (cc === 0) c.classList.add('hidden');
24-
});
25-
26-
p.addEventListener('dragover', (e) => {
32+
};
33+
const onDragOver = (e) => { e.preventDefault(); };
34+
const onDragReset = (e) => {
2735
e.preventDefault();
28-
});
29-
['dragend', 'dragexit', 'drop'].forEach((dragEvent) => {
30-
p.addEventListener(dragEvent, (e) => {
31-
e.preventDefault();
32-
cc = 0;
33-
c.classList.add('hidden');
34-
});
35-
});
36-
37-
p.addEventListener('drop', (e) => {
36+
cc = 0;
37+
c.classList.add('hidden');
38+
};
39+
const onDrop = (e) => {
3840
e.preventDefault();
3941
fileRef.current.value = null;
4042
if (e.dataTransfer.files.length === 1
4143
&& e.dataTransfer.files[0].name.split('.').slice(-1)[0] === 'graphml') {
42-
readFile(superState, dispatcher, e.dataTransfer.files[0]);
44+
readFile(superStateRef.current, dispatcherRef.current, e.dataTransfer.files[0]);
4345
}
44-
});
46+
};
47+
48+
p.addEventListener('dragenter', onDragEnter);
49+
p.addEventListener('dragleave', onDragLeave);
50+
p.addEventListener('dragover', onDragOver);
51+
['dragend', 'dragexit', 'drop'].forEach((dragEvent) => p.addEventListener(dragEvent, onDragReset));
52+
p.addEventListener('drop', onDrop);
53+
54+
return () => {
55+
p.removeEventListener('dragenter', onDragEnter);
56+
p.removeEventListener('dragleave', onDragLeave);
57+
p.removeEventListener('dragover', onDragOver);
58+
['dragend', 'dragexit', 'drop'].forEach((dragEvent) => p.removeEventListener(dragEvent, onDragReset));
59+
p.removeEventListener('drop', onDrop);
60+
};
4561
}, []);
4662
return (
4763
<div className="drag-drop-area hidden">

src/component/FullScreenButton.jsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ export default function FullScreenButton() {
1515
};
1616

1717
return (
18-
<button type="button" onClick={toggleFullscreen}>
18+
<button
19+
type="button"
20+
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
21+
onClick={toggleFullscreen}
22+
>
1923
{isFullscreen ? <Minimize size={20} /> : <Maximize size={20} />}
2024
</button>
2125
);

0 commit comments

Comments
 (0)