-
Notifications
You must be signed in to change notification settings - Fork 144
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add import/export functionality #295
Add import/export functionality #295
Conversation
@torkelrogstad I won't do a full review because you marked it as a draft, but I have skimmed through the code and ran it. Overall, it works as advertised, which is awesome. Thanks for working on this feature and let me know when you feel ready for me to do a thorough review. |
0522785
to
1661f44
Compare
This PR is now in a reviewable state, @jamaljsr. I've added some tests where I mimicked existing tests. The hairy parts of this PR (the zipping/unzipping/ I also didn't tackle the first item on my checkbox from above, removing the user name of the user that exported the network. This would take some effort, and doesn't seem to be terribly important. Here's an exported network you can pull down and import: polar-foo.zip |
Hey @torkelrogstad, I will give the code a thorough review by tomorrow. Regarding testing, the simplest way to determine what tests to add is to look at the code coverage report. If you run Using coverage as a guide also helps you organize your code better. For example, I see that you have a fair amount of logic written in the NetworkView component and button click handlers. It would be challenging to write tests for these since the testing framework has to interact with the UI. It is a better practice to keep your components as small as possible and have util functions for the heavy lifting. The only logic in these files should be to determine what to render on the screen, with little to none of the plumbing necessary to perform an action. This also allows you to reuse that functionality in other components, which keeps your codebase DRY. Since this code would be difficult to test, you would be encouraged to move it to a separate file. Then you'd have a lot more flexibility to pass in arguments to the function to induce errors and handle edge cases. I know testing feels like a pain upfront, but I can tell you from experience that it leads to a much more developer friendly codebase in the future after years of changes. |
I have started reviewing this PR by trying to break it in as many ways as I can think of. Here are some issues I have found by testing this feature as a user.
I also have some cosmetic suggestions to improve the UI
This is all the feedback I have from a user's perspective. I will go ahead and dive into the code now and provide feedback on that separately. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's my feedback on the code for this feature. This is a really good start. I tried to be as helpful as possible in providing guidance on how the code can be improved. If there is any confusion or disagreement, please feel free to let me know.
I also saw some spelling errors in comments which I did not mention, I'll save those for a later review. If you use VS Code as your editor, I highly recommend the Code Spell Checker plugin. It's helped me a lot. Also, the TypeScript Import Sorter plugin does a really good job at keeping import statements consistent with the rules I defined in the settings.json file.
15b16dc
to
f1f8e38
Compare
await t.expect(getPageUrl()).match(/network_import/); | ||
}); | ||
|
||
test('when the user aborts the file dialog, nothing should happen', async t => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm having issues with this test. I can't get Testcafe to trigger the file dialog... Any idea what I'm doing wrong?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did a bit of playing around with this and I think I see the problem. The antd Dragger component uses the native HTML5 input[type=file]
element to handle the file selection. This means that the electron dialog.showOpenDialog
function is not being used, so the setElectronDialogHandler
handler will never be called in the test. It looks like Testcafe has a different setFilesToUpload API to trigger file selection for inputs. You can give that a try. Or if you want to take a different approach, you could create an upload component that uses dialog.showOpenDialog
instead of the antd Dragger with an input[type=file]
element. I do not have a suggestion on which one is better/easier than the other.
e24da98
to
5c481ae
Compare
|
||
// finished | ||
archive.on('finish', () => resolve()); | ||
archive.on('error', err => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm having some issues with testing the error
and warning
branches here. How would I solve this with a mock?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was a bit tricky because the archiver
package exposes its main function as the default export. After a little tinkering, I've gotten a couple tests passing which simulate the error
and warning
events.
- The
jest.mock('archiver
, ...);` code should be placed in a global mock file because it should be mocked in all tests. I just put it all in one file for easier pasting. - I mocked
fs
in these tests because your code used that module. I suggest switching tofs-extra
which already has a global mock in __mocks__. It would just need to be updated with any missing functions declared there, likecreateWriteStream
.
import fs from 'fs';
import * as archiver from 'archiver';
import { PassThrough } from 'stream';
import { zip } from './zip';
jest.mock('fs');
jest.mock('archiver', () => {
// TODO: this code should live in __mocks__/archiver.js
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { PassThrough } = require('stream');
let mockStream: any;
// return a fake stream when "archiver()" is called in the app
const ctor = function() {
mockStream = new PassThrough();
mockStream.file = jest.fn();
mockStream.directory = jest.fn();
mockStream.append = jest.fn();
mockStream.finalize = jest.fn();
return mockStream;
};
// attach a func to emit events on the stream from the tests
ctor.mockEmit = (event: string, data: any) => mockStream.emit(event, data);
return ctor;
});
const fsMock = fs as jest.Mocked<typeof fs>;
const archiverMock = archiver as jest.Mocked<any>;
it('should fail if there is an archiver error', async () => {
fsMock.createWriteStream.mockReturnValueOnce(new PassThrough() as any);
const promise = zip({
destination: 'path-to-zip',
objects: [],
paths: [],
});
// emit an error after a small delay
const mockError = new Error('test-error');
setTimeout(() => {
archiverMock.mockEmit('error', mockError);
}, 100);
await expect(promise).rejects.toEqual(mockError);
});
it('should fail if there is an archiver warning', async () => {
fsMock.createWriteStream.mockReturnValueOnce(new PassThrough() as any);
const promise = zip({
destination: 'path-to-zip',
objects: [],
paths: [],
});
// emit an error after a small delay
const mockError = new Error('test-warning');
setTimeout(() => {
archiverMock.mockEmit('warning', mockError);
}, 100);
await expect(promise).rejects.toEqual(mockError);
});
const ImportNetwork: React.FC<RouteComponentProps> = () => { | ||
const { navigateTo, notify } = useStoreActions(s => s.app); | ||
const { importNetwork } = useStoreActions(s => s.network); | ||
const { l } = usePrefixedTranslation('cmps.network.ImportNetwork'); | ||
const [importing, setImporting] = useState(false); | ||
|
||
const doImportNetwork = (file: RcFile) => { | ||
setImporting(true); | ||
|
||
// we kick off the import promise, but don't wait for it | ||
importNetwork(file.path) | ||
.then(network => { | ||
notify({ message: l('importSuccess', { name: network.name }) }); | ||
navigateTo(HOME); | ||
}) | ||
.catch(error => { | ||
notify({ message: l('importError', { file: file.name }), error }); | ||
}) | ||
.then(() => { | ||
setImporting(false); | ||
}); | ||
|
||
// return false to prevent the Upload.Dragger from sending the file somewhere | ||
return false; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function is not tested in unit tests. One solution here could be to create a hidden element on the page that has a onClick
set to this, and then assert on that in tests. Any thoughts on that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe you could trigger the change event on the file input in order to execute this code. I'm not sure if that will work, but it's worth a try.
5c481ae
to
bbf2735
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This review took a lot longer than I expected because I was playing around with the tests. I've left a bunch of feedback on how this can be further improved. Please let me know if you have any questions of need assistance with anything.
await t.expect(getPageUrl()).match(/network_import/); | ||
}); | ||
|
||
test('when the user aborts the file dialog, nothing should happen', async t => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did a bit of playing around with this and I think I see the problem. The antd Dragger component uses the native HTML5 input[type=file]
element to handle the file selection. This means that the electron dialog.showOpenDialog
function is not being used, so the setElectronDialogHandler
handler will never be called in the test. It looks like Testcafe has a different setFilesToUpload API to trigger file selection for inputs. You can give that a try. Or if you want to take a different approach, you could create an upload component that uses dialog.showOpenDialog
instead of the antd Dragger with an input[type=file]
element. I do not have a suggestion on which one is better/easier than the other.
src/__mocks__/electron.js
Outdated
module.exports = { | ||
remote: { | ||
app: { | ||
getPath: p => `ELECTRON_PATH[${p}]`, | ||
getLocale: () => 'en-US', | ||
}, | ||
dialog: { | ||
showSaveDialog: async () => ({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This mock would not be needed if you do not use electron.remote.dialog.showSaveDialog()
in the app.
const ImportNetwork: React.FC<RouteComponentProps> = () => { | ||
const { navigateTo, notify } = useStoreActions(s => s.app); | ||
const { importNetwork } = useStoreActions(s => s.network); | ||
const { l } = usePrefixedTranslation('cmps.network.ImportNetwork'); | ||
const [importing, setImporting] = useState(false); | ||
|
||
const doImportNetwork = (file: RcFile) => { | ||
setImporting(true); | ||
|
||
// we kick off the import promise, but don't wait for it | ||
importNetwork(file.path) | ||
.then(network => { | ||
notify({ message: l('importSuccess', { name: network.name }) }); | ||
navigateTo(HOME); | ||
}) | ||
.catch(error => { | ||
notify({ message: l('importError', { file: file.name }), error }); | ||
}) | ||
.then(() => { | ||
setImporting(false); | ||
}); | ||
|
||
// return false to prevent the Upload.Dragger from sending the file somewhere | ||
return false; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe you could trigger the change event on the file input in order to execute this code. I'm not sure if that will work, but it's worth a try.
|
||
// finished | ||
archive.on('finish', () => resolve()); | ||
archive.on('error', err => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was a bit tricky because the archiver
package exposes its main function as the default export. After a little tinkering, I've gotten a couple tests passing which simulate the error
and warning
events.
- The
jest.mock('archiver
, ...);` code should be placed in a global mock file because it should be mocked in all tests. I just put it all in one file for easier pasting. - I mocked
fs
in these tests because your code used that module. I suggest switching tofs-extra
which already has a global mock in __mocks__. It would just need to be updated with any missing functions declared there, likecreateWriteStream
.
import fs from 'fs';
import * as archiver from 'archiver';
import { PassThrough } from 'stream';
import { zip } from './zip';
jest.mock('fs');
jest.mock('archiver', () => {
// TODO: this code should live in __mocks__/archiver.js
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { PassThrough } = require('stream');
let mockStream: any;
// return a fake stream when "archiver()" is called in the app
const ctor = function() {
mockStream = new PassThrough();
mockStream.file = jest.fn();
mockStream.directory = jest.fn();
mockStream.append = jest.fn();
mockStream.finalize = jest.fn();
return mockStream;
};
// attach a func to emit events on the stream from the tests
ctor.mockEmit = (event: string, data: any) => mockStream.emit(event, data);
return ctor;
});
const fsMock = fs as jest.Mocked<typeof fs>;
const archiverMock = archiver as jest.Mocked<any>;
it('should fail if there is an archiver error', async () => {
fsMock.createWriteStream.mockReturnValueOnce(new PassThrough() as any);
const promise = zip({
destination: 'path-to-zip',
objects: [],
paths: [],
});
// emit an error after a small delay
const mockError = new Error('test-error');
setTimeout(() => {
archiverMock.mockEmit('error', mockError);
}, 100);
await expect(promise).rejects.toEqual(mockError);
});
it('should fail if there is an archiver warning', async () => {
fsMock.createWriteStream.mockReturnValueOnce(new PassThrough() as any);
const promise = zip({
destination: 'path-to-zip',
objects: [],
paths: [],
});
// emit an error after a small delay
const mockError = new Error('test-warning');
setTimeout(() => {
archiverMock.mockEmit('warning', mockError);
}, 100);
await expect(promise).rejects.toEqual(mockError);
});
Hey @torkelrogstad are you still working on this PR? I want to package up a new release this coming week and would love to include this feature. If you don't have time to finish this up now, I am happy to work on it myself. I just don't want to take over if you plan to continue with it yourself. |
@jamaljsr Sorry for being a bit off the grid for the last days. Hoping to finish it up today, working on addressing your latest round of review now. |
Ok great. Thanks for letting me know. |
35ba38c
to
bb7628d
Compare
I pushed what I had time to do today. I've addressed some of your comments, but did not get around to all of them. If you want to get this feature wrapped up so you can do a release, feel free to push changes here. |
Ok, this looks pretty close to being ready. I'll finish it up in a day or two. Thanks for contributing what you have so far. |
c8de332
to
e49ac58
Compare
Codecov Report
@@ Coverage Diff @@
## master #295 +/- ##
======================================
Coverage 100% 100%
======================================
Files 109 111 +2
Lines 2804 2954 +150
Branches 509 535 +26
======================================
+ Hits 2804 2954 +150
Continue to review full report at Codecov.
|
36b966b
to
d47760a
Compare
Add functionality to export and import networks and data from the application. On export we zip the docker-compose file, the data directories for the nodes and JSON representations of the chart and network definitions. This zip file can later be imported, where we do the reverse operation and copy the docker-compose file and the data directories, and insert the network and chart definitions into the networks.json file. fix 203
feat: add import/export network functionality Modularize import/export network and zip/unzip functionality. We also add some tests.
feat: add import/export network functionality Add thunk and (attempt at) test for importing network.
feat: add import/export network functionality
d47760a
to
1a6c872
Compare
@torkelrogstad I finished up this PR and will merge it. Thank so much for your work on this feature. 👍 |
In this PR we add import/export functionality.
Working:
Todo: