diff --git a/.eslintrc b/.eslintrc index 604b87260..ba83c3e3a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,13 +1,19 @@ { - "extends": "airbnb-base", + "extends": [ + "airbnb-base", + "plugin:react/recommended" + ], "plugins": [ "import" ], "globals": { - "angular": true, - "app": true, + "describe": true, + "it": true, + "beforeEach": true, + "afterEach": true, "ipc": true, - "PRODUCTION": true + "PRODUCTION": true, + "TEST": true, }, "env": { "browser": true, @@ -27,12 +33,18 @@ ] }], - "no-loop-func": "off", + "react/prop-types": "off", "no-plusplus": "off", - "no-restricted-properties": "off", - "no-return-assign": "off", "no-underscore-dangle": "off", - "import/no-extraneous-dependencies": "off", + "import/no-extraneous-dependencies": ["error", { + devDependencies: [ + "./src/**/*.test.js", + "./features/*/*.js", + "./src/**/stories.js", + "./src/tests.js" + ] + } + ], "no-param-reassign": "off" } } diff --git a/.gitignore b/.gitignore index 72c3f0cd5..502475a8d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ app/*.map app/app.js .vscode .idea +blockchain_explorer.db.gz diff --git a/.storybook/config.js b/.storybook/config.js new file mode 100644 index 000000000..7ea5a5304 --- /dev/null +++ b/.storybook/config.js @@ -0,0 +1,15 @@ +import { configure } from '@storybook/react'; +import '../src/components/app/app.css'; + +function loadStories() { + require('../src/components/account/stories'); + require('../src/components/dialog/stories'); + require('../src/components/formattedNumber/stories'); + require('../src/components/toaster/stories'); + require('../src/components/signMessage/stories'); + require('../src/components/send/stories'); + require('../src/components/spinner/stories'); + require('../src/components/verifyMessage/stories'); +} + +configure(loadStories, module); diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js new file mode 100644 index 000000000..462796f4c --- /dev/null +++ b/.storybook/webpack.config.js @@ -0,0 +1,5 @@ +const config = require('../webpack.config.js'); + +module.exports = config({ + test: true, +}); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..4514239a7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,839 @@ +# Change Log + +## [v1.1.0](https://github.com/LiskHQ/lisk-nano/tree/v1.1.0) (2017-09-14) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v1.1.0-rc.3...v1.1.0) + +**Fixed bugs:** + +- Pending votes are in voting dialog [\#741](https://github.com/LiskHQ/lisk-nano/issues/741) +- MY VOTES modal dialog, the X does not seem to delete/remove anything [\#722](https://github.com/LiskHQ/lisk-nano/issues/722) +- Voting does not reflect in grid after voting and progress indicator completes, must toggle tabs [\#720](https://github.com/LiskHQ/lisk-nano/issues/720) +- More user-friendly voting input validation error messages [\#718](https://github.com/LiskHQ/lisk-nano/issues/718) +- Transaction confirmation success message is incorrect [\#717](https://github.com/LiskHQ/lisk-nano/issues/717) +- Confirmations not updated [\#716](https://github.com/LiskHQ/lisk-nano/issues/716) +- Loading indicator is not dismissed [\#668](https://github.com/LiskHQ/lisk-nano/issues/668) +- Forging rank circle progress bar [\#600](https://github.com/LiskHQ/lisk-nano/issues/600) +- Voting transaction does not appear right away after voting [\#304](https://github.com/LiskHQ/lisk-nano/issues/304) + +**Closed issues:** + +- LISK sent from Wallet to Bittrex address not showing up [\#729](https://github.com/LiskHQ/lisk-nano/issues/729) +- If already voted for a delegate\(s\), it does not say which one\(s\) [\#721](https://github.com/LiskHQ/lisk-nano/issues/721) + +**Merged pull requests:** + +- Fix voting status update race condition - Closes \#720 [\#743](https://github.com/LiskHQ/lisk-nano/pull/743) ([slaweet](https://github.com/slaweet)) +- Don't show pending votes in voting dialog - Closes \#741 [\#742](https://github.com/LiskHQ/lisk-nano/pull/742) ([slaweet](https://github.com/slaweet)) +- Make sure confirmations are updated - Closes \#716 [\#735](https://github.com/LiskHQ/lisk-nano/pull/735) ([slaweet](https://github.com/slaweet)) +- Fix removing votes in "MY VOTES" - Closes \#722 [\#728](https://github.com/LiskHQ/lisk-nano/pull/728) ([slaweet](https://github.com/slaweet)) +- Disable "Confirm" vote button if too many delegates selected - Closes \#718 [\#727](https://github.com/LiskHQ/lisk-nano/pull/727) ([slaweet](https://github.com/slaweet)) +- Fix amount in transaction success alert - Closes \#717 [\#726](https://github.com/LiskHQ/lisk-nano/pull/726) ([slaweet](https://github.com/slaweet)) +- Give better passphrase error messages than "invalid" - Closes \#491 [\#724](https://github.com/LiskHQ/lisk-nano/pull/724) ([alepop](https://github.com/alepop)) + +## [v1.1.0-rc.3](https://github.com/LiskHQ/lisk-nano/tree/v1.1.0-rc.3) (2017-09-08) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v1.1.0-rc.2...v1.1.0-rc.3) + +**Fixed bugs:** + +- Clicking on the active tab re-initiates the component [\#711](https://github.com/LiskHQ/lisk-nano/issues/711) +- Wrong amount shown in pending transactions [\#710](https://github.com/LiskHQ/lisk-nano/issues/710) +- Doesn't seem to work with testnet [\#707](https://github.com/LiskHQ/lisk-nano/issues/707) + +**Closed issues:** + +- LSK disappeared [\#709](https://github.com/LiskHQ/lisk-nano/issues/709) +- Don't send secret to active peers [\#708](https://github.com/LiskHQ/lisk-nano/issues/708) +- Hi there. I recently bought some LSK tokens, my first transaction was made successfully and I managed to receive them with no issues. I decided to purchase some more the following day, and when I sent them to my Lisk Nano wallet, I noticed the additional tokens I just bought would show up and disappear after a while, and now those tokens I just bought are no longer there. Only the ones I purchased from the first transaction are visible. I have the transaction ID for both transactions from Bittrex. Can anyone assist me? [\#704](https://github.com/LiskHQ/lisk-nano/issues/704) +- Didn't receive my Lisk from Bittrex. [\#699](https://github.com/LiskHQ/lisk-nano/issues/699) + +**Merged pull requests:** + +- Implement localization - Closes \#558 [\#715](https://github.com/LiskHQ/lisk-nano/pull/715) ([yasharAyari](https://github.com/yasharAyari)) +- Revert "Implement localization - closes \#558" [\#714](https://github.com/LiskHQ/lisk-nano/pull/714) ([reyraa](https://github.com/reyraa)) +- Revert 705 558 implement localization [\#713](https://github.com/LiskHQ/lisk-nano/pull/713) ([reyraa](https://github.com/reyraa)) +- Multiple minor fixings - Closes \#711, \#710, \#707 [\#712](https://github.com/LiskHQ/lisk-nano/pull/712) ([reyraa](https://github.com/reyraa)) +- Implement localization - closes \#558 [\#705](https://github.com/LiskHQ/lisk-nano/pull/705) ([yasharAyari](https://github.com/yasharAyari)) + +## [v1.1.0-rc.2](https://github.com/LiskHQ/lisk-nano/tree/v1.1.0-rc.2) (2017-09-05) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v1.1.0-rc.1...v1.1.0-rc.2) + +**Fixed bugs:** + +- Reset focus in wallet while logging again with different account [\#698](https://github.com/LiskHQ/lisk-nano/issues/698) +- Custom node address - Lisk Nano 1.1.0 rc1 [\#697](https://github.com/LiskHQ/lisk-nano/issues/697) +- Passphrase in voting dialog showed in clear text. 1.1.0 rc 1 [\#695](https://github.com/LiskHQ/lisk-nano/issues/695) +- Random delay in showing buttons after login [\#669](https://github.com/LiskHQ/lisk-nano/issues/669) +- Custom node configuration is being forgotten [\#666](https://github.com/LiskHQ/lisk-nano/issues/666) + +**Closed issues:** + +- missing lisk coins address [\#692](https://github.com/LiskHQ/lisk-nano/issues/692) +- Setup stylelint rules and run it in Jenkins [\#642](https://github.com/LiskHQ/lisk-nano/issues/642) + +**Merged pull requests:** + +- Fix url validator to accept ip and domain - Closes \#697 [\#701](https://github.com/LiskHQ/lisk-nano/pull/701) ([reyraa](https://github.com/reyraa)) +- Reset focus in wallet while logging again with different account - Closes \#698 [\#700](https://github.com/LiskHQ/lisk-nano/pull/700) ([alepop](https://github.com/alepop)) +- Make secondPassphraseInput type='password' - Closes \#695 [\#696](https://github.com/LiskHQ/lisk-nano/pull/696) ([slaweet](https://github.com/slaweet)) +- Setup stylelint rules and run it in jenkins - Closes \#642 [\#691](https://github.com/LiskHQ/lisk-nano/pull/691) ([yasharAyari](https://github.com/yasharAyari)) + +## [v1.1.0-rc.1](https://github.com/LiskHQ/lisk-nano/tree/v1.1.0-rc.1) (2017-09-01) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v1.0.2...v1.1.0-rc.1) + +**Implemented enhancements:** + +- Increase polling interval when not focused. [\#693](https://github.com/LiskHQ/lisk-nano/issues/693) +- Move the Login logic to middlewares [\#596](https://github.com/LiskHQ/lisk-nano/issues/596) +- Move account fetch and update logic into middlewares [\#594](https://github.com/LiskHQ/lisk-nano/issues/594) +- Create a priced primary button [\#583](https://github.com/LiskHQ/lisk-nano/issues/583) +- Issues with logging in [\#550](https://github.com/LiskHQ/lisk-nano/issues/550) +- Enhancements in routing [\#499](https://github.com/LiskHQ/lisk-nano/issues/499) +- Change account registration into a modal dialog [\#346](https://github.com/LiskHQ/lisk-nano/issues/346) + +**Fixed bugs:** + +- Fix after-migration issues in transactions tab [\#683](https://github.com/LiskHQ/lisk-nano/issues/683) +- Registering delegate doesn't update UI [\#682](https://github.com/LiskHQ/lisk-nano/issues/682) +- Fix eslint error in liskAmount component [\#674](https://github.com/LiskHQ/lisk-nano/issues/674) +- ConfirmVotes component should check validity of second passphrase before firing the action [\#628](https://github.com/LiskHQ/lisk-nano/issues/628) +- Cards don’t have the shadow [\#627](https://github.com/LiskHQ/lisk-nano/issues/627) +- Fix login page after-migration differences [\#624](https://github.com/LiskHQ/lisk-nano/issues/624) +- Infinite scroll loading should start earlier than when the bottom is hit [\#623](https://github.com/LiskHQ/lisk-nano/issues/623) +- Fix voting tab after-migration differences [\#622](https://github.com/LiskHQ/lisk-nano/issues/622) +- Global loading bar issues [\#619](https://github.com/LiskHQ/lisk-nano/issues/619) +- Issues in forging tab [\#618](https://github.com/LiskHQ/lisk-nano/issues/618) +- Stabilize e2e Scenario: should remember the selected network [\#615](https://github.com/LiskHQ/lisk-nano/issues/615) +- Migration regressions in voting tab [\#597](https://github.com/LiskHQ/lisk-nano/issues/597) +- Issue with logging page [\#590](https://github.com/LiskHQ/lisk-nano/issues/590) +- Logout should remove all account-related data from redux [\#584](https://github.com/LiskHQ/lisk-nano/issues/584) +- After-migration fixes in passphrase component [\#566](https://github.com/LiskHQ/lisk-nano/issues/566) +- Unit tests fail with JavaScript heap out of memory [\#562](https://github.com/LiskHQ/lisk-nano/issues/562) +- Issues with logging in [\#550](https://github.com/LiskHQ/lisk-nano/issues/550) +- Fix offline behaviour [\#545](https://github.com/LiskHQ/lisk-nano/issues/545) +- Fix the Electron app [\#539](https://github.com/LiskHQ/lisk-nano/issues/539) +- Stabilise unit tests for generateSeed [\#530](https://github.com/LiskHQ/lisk-nano/issues/530) +- Passphrase field doesn't work [\#524](https://github.com/LiskHQ/lisk-nano/issues/524) +- Transactions tab should provide "No transactions" message [\#522](https://github.com/LiskHQ/lisk-nano/issues/522) +- Page is rendered before styles are loaded [\#511](https://github.com/LiskHQ/lisk-nano/issues/511) +- Fix Header component [\#506](https://github.com/LiskHQ/lisk-nano/issues/506) +- Enhancements in routing [\#499](https://github.com/LiskHQ/lisk-nano/issues/499) +- Lisk Nano notified me of a negative balance [\#477](https://github.com/LiskHQ/lisk-nano/issues/477) +- "Invalid transaction timestamp" [\#365](https://github.com/LiskHQ/lisk-nano/issues/365) +- Upon starting the application, caps lock symbol pops up without caps lock being on [\#211](https://github.com/LiskHQ/lisk-nano/issues/211) + +**Closed issues:** + +- Use local storage instead of cookies [\#681](https://github.com/LiskHQ/lisk-nano/issues/681) +- "Report issue..." should go to Zendesk, not Github [\#664](https://github.com/LiskHQ/lisk-nano/issues/664) +- Browser console is polluted with redux actions [\#649](https://github.com/LiskHQ/lisk-nano/issues/649) +- Use theme props to customize react-toolbox tab [\#641](https://github.com/LiskHQ/lisk-nano/issues/641) +- Clean workspace folder in Jenkins on success [\#634](https://github.com/LiskHQ/lisk-nano/issues/634) +- Setup auto-login option and fix login redirect for better developer experience [\#633](https://github.com/LiskHQ/lisk-nano/issues/633) +- Register delegate with 2nd passphrase e2e test sometimes fail [\#632](https://github.com/LiskHQ/lisk-nano/issues/632) +- Page html title is “lisk nano” \(should be “Lisk Nano”\) [\#626](https://github.com/LiskHQ/lisk-nano/issues/626) +- Move Api calls to actions using Redux Thunk [\#611](https://github.com/LiskHQ/lisk-nano/issues/611) +- Create a middleware to handle success alerts after a transaction added [\#610](https://github.com/LiskHQ/lisk-nano/issues/610) +- Update readme build badge [\#592](https://github.com/LiskHQ/lisk-nano/issues/592) +- Centralise test setup [\#589](https://github.com/LiskHQ/lisk-nano/issues/589) +- Add autocomplete module to 'confirm votes modal' [\#587](https://github.com/LiskHQ/lisk-nano/issues/587) +- Unit tests should run on file change only once [\#581](https://github.com/LiskHQ/lisk-nano/issues/581) +- Clean up unit test output [\#578](https://github.com/LiskHQ/lisk-nano/issues/578) +- Migrate desktop notifications for changes to React [\#568](https://github.com/LiskHQ/lisk-nano/issues/568) +- Unify naming of components that use 'connect' [\#567](https://github.com/LiskHQ/lisk-nano/issues/567) +- Re-enable e2e tests for features already working in React [\#565](https://github.com/LiskHQ/lisk-nano/issues/565) +- Migrate "Save network to cookie" [\#564](https://github.com/LiskHQ/lisk-nano/issues/564) +- Jenkins doesn't fail if there are eslint errors in tests [\#548](https://github.com/LiskHQ/lisk-nano/issues/548) +- Migrate Spinner component to React [\#546](https://github.com/LiskHQ/lisk-nano/issues/546) +- Create ActionBar React component [\#541](https://github.com/LiskHQ/lisk-nano/issues/541) +- Create a React component for second passphrase field [\#540](https://github.com/LiskHQ/lisk-nano/issues/540) +- Review and improve React unit test coverage [\#531](https://github.com/LiskHQ/lisk-nano/issues/531) +- Fix responsiveness of the layout [\#529](https://github.com/LiskHQ/lisk-nano/issues/529) +- Add unit tests for sign/verify message [\#527](https://github.com/LiskHQ/lisk-nano/issues/527) +- Implement click to send functionality in React [\#516](https://github.com/LiskHQ/lisk-nano/issues/516) +- Implement custom alert dialogs in React [\#515](https://github.com/LiskHQ/lisk-nano/issues/515) +- Implement pending transactions in React [\#514](https://github.com/LiskHQ/lisk-nano/issues/514) +- Resolve Lisk address [\#508](https://github.com/LiskHQ/lisk-nano/issues/508) +- Re-enable e2e tests for features already working in React [\#507](https://github.com/LiskHQ/lisk-nano/issues/507) +- Setup React toolbox tabs for Transactions/Voting/Forging [\#505](https://github.com/LiskHQ/lisk-nano/issues/505) +- Unify the test titles [\#503](https://github.com/LiskHQ/lisk-nano/issues/503) +- Create \ React component [\#501](https://github.com/LiskHQ/lisk-nano/issues/501) +- Setup React Storybook [\#496](https://github.com/LiskHQ/lisk-nano/issues/496) +- Implement global loading bar React component [\#494](https://github.com/LiskHQ/lisk-nano/issues/494) +- Migrate voting for delegates on Voting tab to React [\#492](https://github.com/LiskHQ/lisk-nano/issues/492) +- Migrate account creation modal [\#487](https://github.com/LiskHQ/lisk-nano/issues/487) +- Setup React toaster library [\#486](https://github.com/LiskHQ/lisk-nano/issues/486) +- Improve eslint report [\#467](https://github.com/LiskHQ/lisk-nano/issues/467) +- Migrate send dialog to React [\#355](https://github.com/LiskHQ/lisk-nano/issues/355) +- Migrate Register as delegate to React [\#354](https://github.com/LiskHQ/lisk-nano/issues/354) +- Migrate Register second passphrase to React [\#353](https://github.com/LiskHQ/lisk-nano/issues/353) +- Migrate Forging tab to React [\#351](https://github.com/LiskHQ/lisk-nano/issues/351) +- Migrate browsing delegates on Voting tab to React [\#350](https://github.com/LiskHQ/lisk-nano/issues/350) +- Migrate home page to React [\#348](https://github.com/LiskHQ/lisk-nano/issues/348) +- Migrate login page to React [\#347](https://github.com/LiskHQ/lisk-nano/issues/347) +- Get back coverage reports [\#276](https://github.com/LiskHQ/lisk-nano/issues/276) +- Delegate list gets slow when loading ~1000 delegates [\#260](https://github.com/LiskHQ/lisk-nano/issues/260) +- Upgrade webpack 1 to 2, optimize webpack loading time [\#61](https://github.com/LiskHQ/lisk-nano/issues/61) + +**Merged pull requests:** + +- Increase polling interval when not focused. - Closes \#693 [\#694](https://github.com/LiskHQ/lisk-nano/pull/694) ([slaweet](https://github.com/slaweet)) +- Use local storage instead of cookies - Closes \#681 [\#689](https://github.com/LiskHQ/lisk-nano/pull/689) ([slaweet](https://github.com/slaweet)) +- Bugfix: Registering delegate doesn't update UI - Closes \#682 [\#688](https://github.com/LiskHQ/lisk-nano/pull/688) ([slaweet](https://github.com/slaweet)) +- Fix after-migration issues in transactions tab - Closes \#683 [\#684](https://github.com/LiskHQ/lisk-nano/pull/684) ([slaweet](https://github.com/slaweet)) +- Fix stories for Send, SignMessage and Toaster [\#680](https://github.com/LiskHQ/lisk-nano/pull/680) ([lohek](https://github.com/lohek)) +- ConfirmVotes component should check validity of second passphrase - Closes \#628 [\#679](https://github.com/LiskHQ/lisk-nano/pull/679) ([yasharAyari](https://github.com/yasharAyari)) +- Bump lisk-js version [\#677](https://github.com/LiskHQ/lisk-nano/pull/677) ([slaweet](https://github.com/slaweet)) +- After-migration fixes in passphrase component - Closes \#566 [\#676](https://github.com/LiskHQ/lisk-nano/pull/676) ([slaweet](https://github.com/slaweet)) +- Fix eslint error in liskAmount component -Closes \#674 [\#675](https://github.com/LiskHQ/lisk-nano/pull/675) ([slaweet](https://github.com/slaweet)) +- Fix login page after-migration differences - Closes \#624 [\#673](https://github.com/LiskHQ/lisk-nano/pull/673) ([alepop](https://github.com/alepop)) +- Change "Report issue..." to go to Zendesk, not Github - Closes \#664 [\#671](https://github.com/LiskHQ/lisk-nano/pull/671) ([slaweet](https://github.com/slaweet)) +- Update README [\#662](https://github.com/LiskHQ/lisk-nano/pull/662) ([albinekcom](https://github.com/albinekcom)) +- Fix pluralization for confirmation tooltip [\#657](https://github.com/LiskHQ/lisk-nano/pull/657) ([ccampbell](https://github.com/ccampbell)) +- Set up source maps in karma [\#655](https://github.com/LiskHQ/lisk-nano/pull/655) ([slaweet](https://github.com/slaweet)) +- Unify naming of components that use 'connect' - Closes \#567 [\#654](https://github.com/LiskHQ/lisk-nano/pull/654) ([slaweet](https://github.com/slaweet)) +- Add karma coverage json output [\#652](https://github.com/LiskHQ/lisk-nano/pull/652) ([slaweet](https://github.com/slaweet)) +- Fix voting tab after migration differences - Close \#622 [\#651](https://github.com/LiskHQ/lisk-nano/pull/651) ([yasharAyari](https://github.com/yasharAyari)) +- Clean Redux actions from browser console - Closes \#649 [\#650](https://github.com/LiskHQ/lisk-nano/pull/650) ([slaweet](https://github.com/slaweet)) +- Give cards back their shadow - Closes \#627 [\#648](https://github.com/LiskHQ/lisk-nano/pull/648) ([slaweet](https://github.com/slaweet)) +- Use theme prop to style Tabs and Dialog - Closes \#641 [\#647](https://github.com/LiskHQ/lisk-nano/pull/647) ([slaweet](https://github.com/slaweet)) +- Capitalize page title - Closes \#626 [\#646](https://github.com/LiskHQ/lisk-nano/pull/646) ([slaweet](https://github.com/slaweet)) +- Improve eslint report - Closes \#467 [\#645](https://github.com/LiskHQ/lisk-nano/pull/645) ([slaweet](https://github.com/slaweet)) +- Delegate list gets slow when loading ~1000 delegates - Closes \#260 [\#644](https://github.com/LiskHQ/lisk-nano/pull/644) ([slaweet](https://github.com/slaweet)) +- Fix infinity scroll - Closes \#623 [\#643](https://github.com/LiskHQ/lisk-nano/pull/643) ([alepop](https://github.com/alepop)) +- Setup auto-login option and fix login redirect for better developer experience - Closes \#633 [\#640](https://github.com/LiskHQ/lisk-nano/pull/640) ([slaweet](https://github.com/slaweet)) +- Make e2e tests more stable - Closes \#632 [\#639](https://github.com/LiskHQ/lisk-nano/pull/639) ([slaweet](https://github.com/slaweet)) +- Fix global loading bar issues - Closes \#619 [\#637](https://github.com/LiskHQ/lisk-nano/pull/637) ([slaweet](https://github.com/slaweet)) +- Isolate core and presentational logics - Closes \#611, \#610, \#584 [\#636](https://github.com/LiskHQ/lisk-nano/pull/636) ([reyraa](https://github.com/reyraa)) +- Clean workspace folder in Jenkins on success - Closes \#634 [\#635](https://github.com/LiskHQ/lisk-nano/pull/635) ([slaweet](https://github.com/slaweet)) +- Fix issues in forging - Closes \#618 [\#630](https://github.com/LiskHQ/lisk-nano/pull/630) ([slaweet](https://github.com/slaweet)) +- Stabilize e2e Scenario: should remember the selected network - Closes \#615 [\#629](https://github.com/LiskHQ/lisk-nano/pull/629) ([slaweet](https://github.com/slaweet)) +- Add autocomplete module to 'confirm votes modal' - Closes \#587 [\#625](https://github.com/LiskHQ/lisk-nano/pull/625) ([yasharAyari](https://github.com/yasharAyari)) +- Fix responsiveness of the layout - Closes \#529 [\#617](https://github.com/LiskHQ/lisk-nano/pull/617) ([slaweet](https://github.com/slaweet)) +- Ignore Scenario: should remember the selected network [\#616](https://github.com/LiskHQ/lisk-nano/pull/616) ([slaweet](https://github.com/slaweet)) +- Fix the Electron app - Closes \#539 [\#614](https://github.com/LiskHQ/lisk-nano/pull/614) ([slaweet](https://github.com/slaweet)) +- Cetralise test setup - Closes \#589 [\#613](https://github.com/LiskHQ/lisk-nano/pull/613) ([slaweet](https://github.com/slaweet)) +- Fix offline behaviour - Closes \#545 [\#612](https://github.com/LiskHQ/lisk-nano/pull/612) ([slaweet](https://github.com/slaweet)) +- Migrate desktop notifications to react - Closes \#568 [\#609](https://github.com/LiskHQ/lisk-nano/pull/609) ([alepop](https://github.com/alepop)) +- Update readme build badge - Closes \#592 [\#608](https://github.com/LiskHQ/lisk-nano/pull/608) ([slaweet](https://github.com/slaweet)) +- Make Jenkins fail if there are eslint errors in tests - Closes \#548 [\#607](https://github.com/LiskHQ/lisk-nano/pull/607) ([slaweet](https://github.com/slaweet)) +- Migration regressions in voting tab - Closes \#597 [\#606](https://github.com/LiskHQ/lisk-nano/pull/606) ([yasharAyari](https://github.com/yasharAyari)) +- Migrate "Save network to cookie" - Closes \#564 [\#605](https://github.com/LiskHQ/lisk-nano/pull/605) ([slaweet](https://github.com/slaweet)) +- Stabilise seed generator test - Closes \#530 [\#604](https://github.com/LiskHQ/lisk-nano/pull/604) ([reyraa](https://github.com/reyraa)) +- Move the Login logic to middlewares - Closes \#596 [\#603](https://github.com/LiskHQ/lisk-nano/pull/603) ([reyraa](https://github.com/reyraa)) +- Transactions tab should provide "No transactions" message- Closes \#522 [\#602](https://github.com/LiskHQ/lisk-nano/pull/602) ([yasharAyari](https://github.com/yasharAyari)) +- Review and improve React unit test coverage - Closes \#531 [\#601](https://github.com/LiskHQ/lisk-nano/pull/601) ([slaweet](https://github.com/slaweet)) +- Move logic to middlewares - Closes \#594 [\#598](https://github.com/LiskHQ/lisk-nano/pull/598) ([alepop](https://github.com/alepop)) +- Migrate voting for delegates on voting component - Closes \#492 [\#593](https://github.com/LiskHQ/lisk-nano/pull/593) ([yasharAyari](https://github.com/yasharAyari)) +- Fix login issues- Closes \#590 [\#591](https://github.com/LiskHQ/lisk-nano/pull/591) ([reyraa](https://github.com/reyraa)) +- Create priced button component - Closes \#583 [\#585](https://github.com/LiskHQ/lisk-nano/pull/585) ([reyraa](https://github.com/reyraa)) +- Unit tests should run on file change only once - Closes \#581 [\#582](https://github.com/LiskHQ/lisk-nano/pull/582) ([alepop](https://github.com/alepop)) +- Fix unit tests JavaScript heap out of memory error - Closes \#562 [\#580](https://github.com/LiskHQ/lisk-nano/pull/580) ([slaweet](https://github.com/slaweet)) +- Cleanup unit test output - Closes \#578 [\#579](https://github.com/LiskHQ/lisk-nano/pull/579) ([alepop](https://github.com/alepop)) +- Re-enable e2e tests for features already working in React - Closes \#565 [\#577](https://github.com/LiskHQ/lisk-nano/pull/577) ([slaweet](https://github.com/slaweet)) +- Fix header component style - Closes \#506 [\#575](https://github.com/LiskHQ/lisk-nano/pull/575) ([alepop](https://github.com/alepop)) +- Create ActionBar React component - Closes \#541 [\#563](https://github.com/LiskHQ/lisk-nano/pull/563) ([slaweet](https://github.com/slaweet)) +- Create a React component for second passphrase field - Closes \#540 [\#560](https://github.com/LiskHQ/lisk-nano/pull/560) ([slaweet](https://github.com/slaweet)) +- Set second passphrase - Closes \#353 [\#552](https://github.com/LiskHQ/lisk-nano/pull/552) ([reyraa](https://github.com/reyraa)) +- Login issues - Closes \#550 [\#551](https://github.com/LiskHQ/lisk-nano/pull/551) ([reyraa](https://github.com/reyraa)) +- Implement pending transactions in React - Closes \#514 [\#549](https://github.com/LiskHQ/lisk-nano/pull/549) ([slaweet](https://github.com/slaweet)) +- Add Spinner component - Closes \#546 [\#547](https://github.com/LiskHQ/lisk-nano/pull/547) ([slaweet](https://github.com/slaweet)) +- Extract css styles to one file - Closes \#511 [\#544](https://github.com/LiskHQ/lisk-nano/pull/544) ([alepop](https://github.com/alepop)) +- Register as delegate - Closes \#354 [\#543](https://github.com/LiskHQ/lisk-nano/pull/543) ([reyraa](https://github.com/reyraa)) +- Fix header component - Closes \#506 [\#542](https://github.com/LiskHQ/lisk-nano/pull/542) ([alepop](https://github.com/alepop)) +- Migrate global loading bar to React - Closes \#494 [\#538](https://github.com/LiskHQ/lisk-nano/pull/538) ([slaweet](https://github.com/slaweet)) +- Fix coverage reports - Closes \#276 [\#537](https://github.com/LiskHQ/lisk-nano/pull/537) ([slaweet](https://github.com/slaweet)) +- Implement click to send functionality in React - Closes \#516 [\#534](https://github.com/LiskHQ/lisk-nano/pull/534) ([slaweet](https://github.com/slaweet)) +- Setup React toolbox tabs - Closes \#505 [\#532](https://github.com/LiskHQ/lisk-nano/pull/532) ([slaweet](https://github.com/slaweet)) +- Add unit tests for sign/verify message - Closes \#527 [\#528](https://github.com/LiskHQ/lisk-nano/pull/528) ([slaweet](https://github.com/slaweet)) +- Implement custom alert dialogs in React - Closes \#515 [\#526](https://github.com/LiskHQ/lisk-nano/pull/526) ([slaweet](https://github.com/slaweet)) +- Fix passphrase field - Closes \#524 [\#525](https://github.com/LiskHQ/lisk-nano/pull/525) ([slaweet](https://github.com/slaweet)) +- Re-enable e2e tests for features already working in React - Closes \#507 [\#523](https://github.com/LiskHQ/lisk-nano/pull/523) ([slaweet](https://github.com/slaweet)) +- Setup React toaster - Closes \#486 [\#519](https://github.com/LiskHQ/lisk-nano/pull/519) ([slaweet](https://github.com/slaweet)) +- Implement account creation modal - Closes \#487 [\#518](https://github.com/LiskHQ/lisk-nano/pull/518) ([reyraa](https://github.com/reyraa)) +- Add Yashar Ayari to Authors in README.md [\#517](https://github.com/LiskHQ/lisk-nano/pull/517) ([slaweet](https://github.com/slaweet)) +- Migrate send dialog to React - Closes \#355 [\#513](https://github.com/LiskHQ/lisk-nano/pull/513) ([slaweet](https://github.com/slaweet)) +- Migrate browsing delegates on Voting tab to React - Closes \#350 [\#512](https://github.com/LiskHQ/lisk-nano/pull/512) ([yasharAyari](https://github.com/yasharAyari)) +- Enhancements in routing - Closes \#499 [\#509](https://github.com/LiskHQ/lisk-nano/pull/509) ([alepop](https://github.com/alepop)) +- Unify test titles - Closes \#503 [\#504](https://github.com/LiskHQ/lisk-nano/pull/504) ([reyraa](https://github.com/reyraa)) +- Create \ React component - Closes \#501 [\#502](https://github.com/LiskHQ/lisk-nano/pull/502) ([slaweet](https://github.com/slaweet)) +- Migrate Login page - Closes \#347 [\#498](https://github.com/LiskHQ/lisk-nano/pull/498) ([reyraa](https://github.com/reyraa)) +- Add react storybook - Closes \#496 [\#497](https://github.com/LiskHQ/lisk-nano/pull/497) ([willclarktech](https://github.com/willclarktech)) +- Migrate forging component to React - Closes \#351 [\#495](https://github.com/LiskHQ/lisk-nano/pull/495) ([slaweet](https://github.com/slaweet)) + +## [v1.0.2](https://github.com/LiskHQ/lisk-nano/tree/v1.0.2) (2017-07-20) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/1.0.2...v1.0.2) + +## [1.0.2](https://github.com/LiskHQ/lisk-nano/tree/1.0.2) (2017-07-20) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v1.0.1...1.0.2) + +**Implemented enhancements:** + +- Update account regularly on beat events [\#488](https://github.com/LiskHQ/lisk-nano/issues/488) +- Increase unit test coverage for routing [\#479](https://github.com/LiskHQ/lisk-nano/issues/479) +- Add react routing [\#474](https://github.com/LiskHQ/lisk-nano/issues/474) +- Support more deployment methods [\#455](https://github.com/LiskHQ/lisk-nano/issues/455) +- Possibility to define custom node to broadcast transactions [\#8](https://github.com/LiskHQ/lisk-nano/issues/8) + +**Fixed bugs:** + +- Not able to use my public node anymore without added port 8000 [\#424](https://github.com/LiskHQ/lisk-nano/issues/424) +- Rank is not displayed while searching [\#303](https://github.com/LiskHQ/lisk-nano/issues/303) + +**Closed issues:** + +- Re-enable e2e tests in Jenkins [\#482](https://github.com/LiskHQ/lisk-nano/issues/482) +- Create general modal dialogs in React [\#478](https://github.com/LiskHQ/lisk-nano/issues/478) +- Make active peer available in main component [\#469](https://github.com/LiskHQ/lisk-nano/issues/469) +- Enhance unit test output [\#466](https://github.com/LiskHQ/lisk-nano/issues/466) +- Add hot module reload functionality [\#465](https://github.com/LiskHQ/lisk-nano/issues/465) +- Add hot CSS reload functionality [\#464](https://github.com/LiskHQ/lisk-nano/issues/464) +- Lisk Nano - cannot install on Windows 32-bit machine [\#454](https://github.com/LiskHQ/lisk-nano/issues/454) +- Migrate Sign/Verify message to React [\#352](https://github.com/LiskHQ/lisk-nano/issues/352) +- Migrate transactions tab to React [\#349](https://github.com/LiskHQ/lisk-nano/issues/349) +- Update "build" badge in README [\#275](https://github.com/LiskHQ/lisk-nano/issues/275) +- Create a modal that can compile any child component [\#163](https://github.com/LiskHQ/lisk-nano/issues/163) + +**Merged pull requests:** + +- Invalid timestamp fixes - Closes \#365 [\#500](https://github.com/LiskHQ/lisk-nano/pull/500) ([reyraa](https://github.com/reyraa)) +- Update account regularly on beat events - Closes \#488 [\#493](https://github.com/LiskHQ/lisk-nano/pull/493) ([reyraa](https://github.com/reyraa)) +- Migrate transactions tab to react - Closes \#349 [\#490](https://github.com/LiskHQ/lisk-nano/pull/490) ([yasharAyari](https://github.com/yasharAyari)) +- Increase unit test coverage for routing - Closes \#479 [\#489](https://github.com/LiskHQ/lisk-nano/pull/489) ([alepop](https://github.com/alepop)) +- Update "build" badge in README - Closes \#275 [\#484](https://github.com/LiskHQ/lisk-nano/pull/484) ([slaweet](https://github.com/slaweet)) +- Fix e2e tests in Jenkinsfile - Closes \#482 [\#483](https://github.com/LiskHQ/lisk-nano/pull/483) ([slaweet](https://github.com/slaweet)) +- Fix notification about negative balance - Closes \#477 [\#481](https://github.com/LiskHQ/lisk-nano/pull/481) ([alepop](https://github.com/alepop)) +- Create general modal dialogs in React - Closes \#478 [\#480](https://github.com/LiskHQ/lisk-nano/pull/480) ([slaweet](https://github.com/slaweet)) +- Implement sign/verify message as React components - Closes \#352 [\#476](https://github.com/LiskHQ/lisk-nano/pull/476) ([slaweet](https://github.com/slaweet)) +- Add react routing - Closes \#474 [\#475](https://github.com/LiskHQ/lisk-nano/pull/475) ([reyraa](https://github.com/reyraa)) +- Enhance unit test output - Closes \#466 [\#473](https://github.com/LiskHQ/lisk-nano/pull/473) ([alepop](https://github.com/alepop)) +- Add hot module reload functionality - Closes \#465 [\#472](https://github.com/LiskHQ/lisk-nano/pull/472) ([alepop](https://github.com/alepop)) +- Add new deployments to package.json - Closes \#455 [\#471](https://github.com/LiskHQ/lisk-nano/pull/471) ([slaweet](https://github.com/slaweet)) +- Make active peer available in main component - Closes \#469 [\#470](https://github.com/LiskHQ/lisk-nano/pull/470) ([reyraa](https://github.com/reyraa)) + +## [v1.0.1](https://github.com/LiskHQ/lisk-nano/tree/v1.0.1) (2017-07-10) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v1.0.0...v1.0.1) + +**Closed issues:** + +- Update Build Badge for Jenkins [\#468](https://github.com/LiskHQ/lisk-nano/issues/468) +- Remove boilerplate example components [\#462](https://github.com/LiskHQ/lisk-nano/issues/462) +- Login with custom node not possible [\#449](https://github.com/LiskHQ/lisk-nano/issues/449) +- Add a grid system to react boilerplate [\#448](https://github.com/LiskHQ/lisk-nano/issues/448) +- Migrate header component to React [\#444](https://github.com/LiskHQ/lisk-nano/issues/444) +- Migrate top component to React [\#443](https://github.com/LiskHQ/lisk-nano/issues/443) +- Remove less files and use css version of them [\#440](https://github.com/LiskHQ/lisk-nano/issues/440) +- Set up Lisk Nano theme in React Toolbox [\#438](https://github.com/LiskHQ/lisk-nano/issues/438) +- Add a material design based framework to react boilerplate [\#435](https://github.com/LiskHQ/lisk-nano/issues/435) +- Create Api utilities to communicate with Lisk-js endpoints [\#434](https://github.com/LiskHQ/lisk-nano/issues/434) +- Can't Open Wallet With a Passphrase [\#425](https://github.com/LiskHQ/lisk-nano/issues/425) + +**Merged pull requests:** + +- Remove boilerplate example components - Closes \#462 [\#463](https://github.com/LiskHQ/lisk-nano/pull/463) ([reyraa](https://github.com/reyraa)) +- Migrate top component to react - Closes \#443 [\#461](https://github.com/LiskHQ/lisk-nano/pull/461) ([yasharAyari](https://github.com/yasharAyari)) +- Migrate header component to react - Close \#444 [\#459](https://github.com/LiskHQ/lisk-nano/pull/459) ([yasharAyari](https://github.com/yasharAyari)) +- Add new deployments to package.json - Closes \#455 [\#457](https://github.com/LiskHQ/lisk-nano/pull/457) ([Isabello](https://github.com/Isabello)) +- Remove IPC listeners after logout [\#452](https://github.com/LiskHQ/lisk-nano/pull/452) ([alepop](https://github.com/alepop)) +- Add Flexbox grid to react boilerplate - Close \#448 [\#450](https://github.com/LiskHQ/lisk-nano/pull/450) ([yasharAyari](https://github.com/yasharAyari)) +- Remove less files and use css files instead of them - Closes \#440 [\#442](https://github.com/LiskHQ/lisk-nano/pull/442) ([yasharAyari](https://github.com/yasharAyari)) +- Fix login to https custom node - Closes \#424 [\#441](https://github.com/LiskHQ/lisk-nano/pull/441) ([slaweet](https://github.com/slaweet)) +- Set up Lisk Nano theme in React Toolbox - close \#438 [\#439](https://github.com/LiskHQ/lisk-nano/pull/439) ([yasharAyari](https://github.com/yasharAyari)) +- Create api utilities - Closes \#434 [\#437](https://github.com/LiskHQ/lisk-nano/pull/437) ([reyraa](https://github.com/reyraa)) +- Add material design frame work to react boilerplate - closes \#435 [\#436](https://github.com/LiskHQ/lisk-nano/pull/436) ([yasharAyari](https://github.com/yasharAyari)) + +## [v1.0.0](https://github.com/LiskHQ/lisk-nano/tree/v1.0.0) (2017-06-22) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v1.0.0-rc.5...v1.0.0) + +**Fixed bugs:** + +- Fix test coverage results [\#430](https://github.com/LiskHQ/lisk-nano/issues/430) +- Loading bar doesn't hide after sucessfull login [\#422](https://github.com/LiskHQ/lisk-nano/issues/422) + +**Closed issues:** + +- Implement account logic in React [\#429](https://github.com/LiskHQ/lisk-nano/issues/429) +- Set up boilerplate for React migration [\#342](https://github.com/LiskHQ/lisk-nano/issues/342) +- Improve e2e test coverage [\#274](https://github.com/LiskHQ/lisk-nano/issues/274) + +**Merged pull requests:** + +- Account logic - Closes \#429 [\#433](https://github.com/LiskHQ/lisk-nano/pull/433) ([reyraa](https://github.com/reyraa)) +- Fix test coverage results - Closes \#430 [\#432](https://github.com/LiskHQ/lisk-nano/pull/432) ([yasharAyari](https://github.com/yasharAyari)) +- Improve e2e coverage - Closes \#274 [\#427](https://github.com/LiskHQ/lisk-nano/pull/427) ([slaweet](https://github.com/slaweet)) +- Create react boilerplate - Closes \#342 [\#426](https://github.com/LiskHQ/lisk-nano/pull/426) ([yasharAyari](https://github.com/yasharAyari)) +- Fix loading bar after unsuccessfull login - Closes \#422 [\#423](https://github.com/LiskHQ/lisk-nano/pull/423) ([slaweet](https://github.com/slaweet)) + +## [v1.0.0-rc.5](https://github.com/LiskHQ/lisk-nano/tree/v1.0.0-rc.5) (2017-06-16) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v1.0.0-rc.4...v1.0.0-rc.5) + +**Fixed bugs:** + +- New Account and Login buttons are permanently disabled [\#420](https://github.com/LiskHQ/lisk-nano/issues/420) +- Using wrong URL as custom node results in endless loop [\#417](https://github.com/LiskHQ/lisk-nano/issues/417) + +**Merged pull requests:** + +- Fix an issue in form validation in login page - Closes \#420 [\#421](https://github.com/LiskHQ/lisk-nano/pull/421) ([reyraa](https://github.com/reyraa)) +- Use Javascript URL method to normalize URL before setting active peer - Closes \#417 [\#419](https://github.com/LiskHQ/lisk-nano/pull/419) ([reyraa](https://github.com/reyraa)) + +## [v1.0.0-rc.4](https://github.com/LiskHQ/lisk-nano/tree/v1.0.0-rc.4) (2017-06-15) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v1.0.0-rc.3...v1.0.0-rc.4) + +**Implemented enhancements:** + +- Add bouncer animation to pending send transactions [\#407](https://github.com/LiskHQ/lisk-nano/issues/407) +- Clicking anywhere after generating passphrase break "ctrl+a" selection scope [\#403](https://github.com/LiskHQ/lisk-nano/issues/403) +- Remove the "Load More" link at the bottom of the first few transactions [\#402](https://github.com/LiskHQ/lisk-nano/issues/402) +- Remove "Today" on the "Forging" Tab [\#401](https://github.com/LiskHQ/lisk-nano/issues/401) +- Gray line after adding a delegate to the voting list [\#330](https://github.com/LiskHQ/lisk-nano/issues/330) +- Add Explorer links to the help menu [\#282](https://github.com/LiskHQ/lisk-nano/issues/282) + +**Closed issues:** + +- Unify "not enough funds" errors [\#405](https://github.com/LiskHQ/lisk-nano/issues/405) +- Typos in the login text [\#399](https://github.com/LiskHQ/lisk-nano/issues/399) + +**Merged pull requests:** + +- Add space between "remove votes from" and divider - Closes \#330 [\#416](https://github.com/LiskHQ/lisk-nano/pull/416) ([slaweet](https://github.com/slaweet)) +- Replace "Forged today" with "In past 365 days" - Closes \#401 [\#413](https://github.com/LiskHQ/lisk-nano/pull/413) ([slaweet](https://github.com/slaweet)) +- Prevent blur event on textarea.passphrase element [\#412](https://github.com/LiskHQ/lisk-nano/pull/412) ([alepop](https://github.com/alepop)) +- Unify "not enough funds" errors - Closes \#405 [\#411](https://github.com/LiskHQ/lisk-nano/pull/411) ([slaweet](https://github.com/slaweet)) +- Add pending status spinner to transactions list - Closes \#407 [\#410](https://github.com/LiskHQ/lisk-nano/pull/410) ([reyraa](https://github.com/reyraa)) +- Fix typos - Closes \#399 [\#409](https://github.com/LiskHQ/lisk-nano/pull/409) ([reyraa](https://github.com/reyraa)) +- Load 20 transaction instead 10 - Closes \#402 [\#408](https://github.com/LiskHQ/lisk-nano/pull/408) ([alepop](https://github.com/alepop)) +- Add explorer link to Help menu [\#395](https://github.com/LiskHQ/lisk-nano/pull/395) ([alepop](https://github.com/alepop)) + +## [v1.0.0-rc.3](https://github.com/LiskHQ/lisk-nano/tree/v1.0.0-rc.3) (2017-06-13) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v1.0.0-rc.2...v1.0.0-rc.3) + +**Implemented enhancements:** + +- Polish voting page [\#344](https://github.com/LiskHQ/lisk-nano/issues/344) +- After the confirmation of a 1st/2nd passphrase, add another step [\#331](https://github.com/LiskHQ/lisk-nano/issues/331) +- Conform all numbers with comma [\#326](https://github.com/LiskHQ/lisk-nano/issues/326) +- Visual bug with the tabs - 1px white line on the left side [\#321](https://github.com/LiskHQ/lisk-nano/issues/321) +- Improve sign and verify message output styling [\#150](https://github.com/LiskHQ/lisk-nano/issues/150) + +**Fixed bugs:** + +- Forging tab doesn't show correct template for \(non-\)delegate accounts [\#391](https://github.com/LiskHQ/lisk-nano/issues/391) +- Address and balance overflows on smaller screen [\#389](https://github.com/LiskHQ/lisk-nano/issues/389) +- Delegate registration popup doesn't show second passphrase input [\#388](https://github.com/LiskHQ/lisk-nano/issues/388) +- Javascript Error: app.setAboutPanelOptions is not a function [\#386](https://github.com/LiskHQ/lisk-nano/issues/386) +- Delegates highlighting after voting is broken [\#380](https://github.com/LiskHQ/lisk-nano/issues/380) +- Cancel button does not work in delegate registration modal [\#373](https://github.com/LiskHQ/lisk-nano/issues/373) +- Forging tab still have "Load more" button/lavel [\#367](https://github.com/LiskHQ/lisk-nano/issues/367) +- 25 LSK fee showing for a new account [\#363](https://github.com/LiskHQ/lisk-nano/issues/363) +- Window jumps to top when loading more transactions. [\#361](https://github.com/LiskHQ/lisk-nano/issues/361) +- Polish voting page [\#344](https://github.com/LiskHQ/lisk-nano/issues/344) + +**Closed issues:** + +- Empty recipient field when clicking on sender address [\#381](https://github.com/LiskHQ/lisk-nano/issues/381) +- Connect to mainnet nodes via https [\#378](https://github.com/LiskHQ/lisk-nano/issues/378) +- Jenkins Coveralls Reporter lost from Karma.conf.js [\#374](https://github.com/LiskHQ/lisk-nano/issues/374) +- Uncaught Exception on Startup [\#364](https://github.com/LiskHQ/lisk-nano/issues/364) +- Unify use of modal full screen mode [\#358](https://github.com/LiskHQ/lisk-nano/issues/358) +- Change delegate registration modal header [\#345](https://github.com/LiskHQ/lisk-nano/issues/345) +- Remove "input names" feature from Voting tab [\#343](https://github.com/LiskHQ/lisk-nano/issues/343) + +**Merged pull requests:** + +- Bumping version - 1.0.0-rc.3 [\#397](https://github.com/LiskHQ/lisk-nano/pull/397) ([Isabello](https://github.com/Isabello)) +- Fix the header for smaller displays - Closes \#389 [\#396](https://github.com/LiskHQ/lisk-nano/pull/396) ([reyraa](https://github.com/reyraa)) +- Fix delegates highlighting after voting - Closes \#380 [\#394](https://github.com/LiskHQ/lisk-nano/pull/394) ([slaweet](https://github.com/slaweet)) +- Fix template issues in forging tab - Closes \#391 [\#392](https://github.com/LiskHQ/lisk-nano/pull/392) ([reyraa](https://github.com/reyraa)) +- Delegate registration popup fixings - Closes \#388 [\#390](https://github.com/LiskHQ/lisk-nano/pull/390) ([reyraa](https://github.com/reyraa)) +- Fix Javascript Error: app.setAboutPanelOptions is not a function [\#387](https://github.com/LiskHQ/lisk-nano/pull/387) ([alepop](https://github.com/alepop)) +- Change delegate registration modal header text - Closes \#345 [\#385](https://github.com/LiskHQ/lisk-nano/pull/385) ([yasharAyari](https://github.com/yasharAyari)) +- Polish voting page - Closes \#344 [\#384](https://github.com/LiskHQ/lisk-nano/pull/384) ([yasharAyari](https://github.com/yasharAyari)) +- Remove 'input names' feature from Voting tab - Closes \#343 [\#383](https://github.com/LiskHQ/lisk-nano/pull/383) ([yasharAyari](https://github.com/yasharAyari)) +- Fixes the usage of modal.dialog - Closes \#381 [\#382](https://github.com/LiskHQ/lisk-nano/pull/382) ([reyraa](https://github.com/reyraa)) +- Connect to mainnet nodes via https - Closes \#378 [\#379](https://github.com/LiskHQ/lisk-nano/pull/379) ([slaweet](https://github.com/slaweet)) +- Hide "Load More" button in forging tab - Closes \#367 [\#377](https://github.com/LiskHQ/lisk-nano/pull/377) ([slaweet](https://github.com/slaweet)) +- Restore karma-jenkins-reporter in karma.conf.js - Closes \#374 [\#376](https://github.com/LiskHQ/lisk-nano/pull/376) ([Isabello](https://github.com/Isabello)) +- Hide modal in cancel method - Closes \#373 [\#375](https://github.com/LiskHQ/lisk-nano/pull/375) ([reyraa](https://github.com/reyraa)) +- Don't show a fee on new account registration - Closes \#363 [\#372](https://github.com/LiskHQ/lisk-nano/pull/372) ([slaweet](https://github.com/slaweet)) +- Remove extra gray in vote dialog - Closes \#330 [\#371](https://github.com/LiskHQ/lisk-nano/pull/371) ([slaweet](https://github.com/slaweet)) +- Unify modals fullscreen and width - Closes \#358 [\#370](https://github.com/LiskHQ/lisk-nano/pull/370) ([slaweet](https://github.com/slaweet)) +- Fix load more transactions jumps to top bug - Closes \#361 [\#369](https://github.com/LiskHQ/lisk-nano/pull/369) ([slaweet](https://github.com/slaweet)) +- Intro for passphrase generation modals - Closes 331 [\#359](https://github.com/LiskHQ/lisk-nano/pull/359) ([reyraa](https://github.com/reyraa)) +- Conform all numbers with comma - Closes \#326 [\#339](https://github.com/LiskHQ/lisk-nano/pull/339) ([yasharAyari](https://github.com/yasharAyari)) +- Fix visual bug with the tabs in main component- Closes \#321 [\#337](https://github.com/LiskHQ/lisk-nano/pull/337) ([yasharAyari](https://github.com/yasharAyari)) + +## [v1.0.0-rc.2](https://github.com/LiskHQ/lisk-nano/tree/v1.0.0-rc.2) (2017-06-08) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v1.0.0-rc.1...v1.0.0-rc.2) + +**Implemented enhancements:** + +- Not all modals show the fees of a transaction [\#332](https://github.com/LiskHQ/lisk-nano/issues/332) +- Same loading bar which is all the time at the same location [\#328](https://github.com/LiskHQ/lisk-nano/issues/328) +- A blue border around "Show all columns" menu [\#323](https://github.com/LiskHQ/lisk-nano/issues/323) +- Move the √ icon from the peers box to the inner network box [\#320](https://github.com/LiskHQ/lisk-nano/issues/320) +- Check version compatibility on login [\#309](https://github.com/LiskHQ/lisk-nano/issues/309) +- All forms should look and feel the same [\#301](https://github.com/LiskHQ/lisk-nano/issues/301) +- Display nethash when logged in [\#285](https://github.com/LiskHQ/lisk-nano/issues/285) +- Show desktop notifications for changes [\#253](https://github.com/LiskHQ/lisk-nano/issues/253) + +**Fixed bugs:** + +- Send all funds is not clickable [\#356](https://github.com/LiskHQ/lisk-nano/issues/356) +- Auto select the Recipient Address in the send modal [\#333](https://github.com/LiskHQ/lisk-nano/issues/333) +- Add infinity scroll to "Forged blocks" [\#327](https://github.com/LiskHQ/lisk-nano/issues/327) +- Add shadow to the TRANSACTIONS menu entry [\#299](https://github.com/LiskHQ/lisk-nano/issues/299) +- New account doesn't load delegates [\#288](https://github.com/LiskHQ/lisk-nano/issues/288) +- Send all funds message [\#286](https://github.com/LiskHQ/lisk-nano/issues/286) +- Custom node still allows login if its not online [\#283](https://github.com/LiskHQ/lisk-nano/issues/283) +- Wrong tooltip wording [\#281](https://github.com/LiskHQ/lisk-nano/issues/281) +- New Account switches network after closing modal [\#280](https://github.com/LiskHQ/lisk-nano/issues/280) + +**Closed issues:** + +- White space above the transactions table [\#329](https://github.com/LiskHQ/lisk-nano/issues/329) +- Add % symbol behind the percentage number for uptime/approval on the voting page [\#322](https://github.com/LiskHQ/lisk-nano/issues/322) +- Official nodes distribution [\#318](https://github.com/LiskHQ/lisk-nano/issues/318) +- Postgress fail on Ubuntu 17.04 [\#317](https://github.com/LiskHQ/lisk-nano/issues/317) +- Unify transaction confirmation [\#315](https://github.com/LiskHQ/lisk-nano/issues/315) +- Research framework migration paths [\#311](https://github.com/LiskHQ/lisk-nano/issues/311) +- Align modal headers with the forms and buttons [\#302](https://github.com/LiskHQ/lisk-nano/issues/302) +- VOTING page is not loading on a new account [\#300](https://github.com/LiskHQ/lisk-nano/issues/300) +- Add a border-radius to the main menu [\#298](https://github.com/LiskHQ/lisk-nano/issues/298) +- Draw the thin gray line through the end at the 3-dot menu [\#297](https://github.com/LiskHQ/lisk-nano/issues/297) +- Problem with the 3 dots in send modal, responsive design [\#296](https://github.com/LiskHQ/lisk-nano/issues/296) +- Buttons in modals are not aligned correctly [\#295](https://github.com/LiskHQ/lisk-nano/issues/295) +- Conformity between modals [\#294](https://github.com/LiskHQ/lisk-nano/issues/294) +- Copyright in the about is incorrect [\#287](https://github.com/LiskHQ/lisk-nano/issues/287) +- Refactor new passphrase modal into a component [\#189](https://github.com/LiskHQ/lisk-nano/issues/189) + +**Merged pull requests:** + +- Send the right options to Send modal - Closes \#356 [\#357](https://github.com/LiskHQ/lisk-nano/pull/357) ([reyraa](https://github.com/reyraa)) +- Use one universal loading bar - Closes \#328 [\#340](https://github.com/LiskHQ/lisk-nano/pull/340) ([slaweet](https://github.com/slaweet)) +- Remove the blue border around "Show all columns" - Closes \#323 [\#338](https://github.com/LiskHQ/lisk-nano/pull/338) ([slaweet](https://github.com/slaweet)) +- Keep the load-more button hidden if the page has vertical scrollbar - Closes \#327 [\#336](https://github.com/LiskHQ/lisk-nano/pull/336) ([reyraa](https://github.com/reyraa)) +- Add infinity scroll for forged blocks [\#335](https://github.com/LiskHQ/lisk-nano/pull/335) ([alepop](https://github.com/alepop)) +- Minor ui fixes [\#334](https://github.com/LiskHQ/lisk-nano/pull/334) ([alepop](https://github.com/alepop)) +- Add % symbol for uptime/approval on the voting page - Closes \#322 [\#325](https://github.com/LiskHQ/lisk-nano/pull/325) ([slaweet](https://github.com/slaweet)) +- Move online/offline icon inside node element - Closes \#320 [\#324](https://github.com/LiskHQ/lisk-nano/pull/324) ([slaweet](https://github.com/slaweet)) +- Remove login page title [\#319](https://github.com/LiskHQ/lisk-nano/pull/319) ([slaweet](https://github.com/slaweet)) +- Unify transaction confirmation - Closes \#315 [\#316](https://github.com/LiskHQ/lisk-nano/pull/316) ([slaweet](https://github.com/slaweet)) +- Check version compatibility on login - Closes \#309 [\#314](https://github.com/LiskHQ/lisk-nano/pull/314) ([slaweet](https://github.com/slaweet)) +- Improve sign/verify message ui - Closes \#150 [\#313](https://github.com/LiskHQ/lisk-nano/pull/313) ([reyraa](https://github.com/reyraa)) +- Unify modals buttons and alignment [\#312](https://github.com/LiskHQ/lisk-nano/pull/312) ([slaweet](https://github.com/slaweet)) +- UI fixings: Unify forms, shadow to main tabs [\#310](https://github.com/LiskHQ/lisk-nano/pull/310) ([reyraa](https://github.com/reyraa)) +- Add modal method to dialog service as general modal service [\#308](https://github.com/LiskHQ/lisk-nano/pull/308) ([yasharAyari](https://github.com/yasharAyari)) +- Try to connect on login page before going further - Closes \#283 [\#307](https://github.com/LiskHQ/lisk-nano/pull/307) ([slaweet](https://github.com/slaweet)) +- UI fixes [\#306](https://github.com/LiskHQ/lisk-nano/pull/306) ([alepop](https://github.com/alepop)) +- Fix Copyright date range - Closes \#287 [\#293](https://github.com/LiskHQ/lisk-nano/pull/293) ([alepop](https://github.com/alepop)) +- Refine top header [\#292](https://github.com/LiskHQ/lisk-nano/pull/292) ([reyraa](https://github.com/reyraa)) +- Keep selected network on new account cancellation - Closes \#280 [\#291](https://github.com/LiskHQ/lisk-nano/pull/291) ([slaweet](https://github.com/slaweet)) +- Fix delegate list on new account - Closes \#288 [\#290](https://github.com/LiskHQ/lisk-nano/pull/290) ([slaweet](https://github.com/slaweet)) +- Refactor save passphrase modal into a component - Closes \#189 [\#278](https://github.com/LiskHQ/lisk-nano/pull/278) ([slaweet](https://github.com/slaweet)) +- Show desktop notifications for changes [\#263](https://github.com/LiskHQ/lisk-nano/pull/263) ([alepop](https://github.com/alepop)) + +## [v1.0.0-rc.1](https://github.com/LiskHQ/lisk-nano/tree/v1.0.0-rc.1) (2017-05-31) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v0.2.1...v1.0.0-rc.1) + +**Implemented enhancements:** + +- Send funds by clicking on account balance and transaction amount [\#270](https://github.com/LiskHQ/lisk-nano/issues/270) +- Signing a message simplicity [\#261](https://github.com/LiskHQ/lisk-nano/issues/261) +- Sign/verify message should contain some explanation [\#181](https://github.com/LiskHQ/lisk-nano/issues/181) +- Use window.requestAnimationFrame\(\) instead of timeouts [\#174](https://github.com/LiskHQ/lisk-nano/issues/174) + +**Fixed bugs:** + +- Circular progress bar should be overlaid when connecting to peer [\#272](https://github.com/LiskHQ/lisk-nano/issues/272) +- 2nd passphrase generation bug [\#267](https://github.com/LiskHQ/lisk-nano/issues/267) +- Infinite scrolling doesn't work in big screens [\#256](https://github.com/LiskHQ/lisk-nano/issues/256) +- Sending a transaction to own address displays the sent transaction but not the received [\#236](https://github.com/LiskHQ/lisk-nano/issues/236) +- Fee and Amount headers are missaligned [\#225](https://github.com/LiskHQ/lisk-nano/issues/225) +- Implement Try/Catch in Jenkins File [\#223](https://github.com/LiskHQ/lisk-nano/issues/223) +- Default window size is massive on large display [\#221](https://github.com/LiskHQ/lisk-nano/issues/221) +- Delegate name shown is too timid [\#219](https://github.com/LiskHQ/lisk-nano/issues/219) +- Secondary menu for Forged Blocks is not vertically aligned [\#217](https://github.com/LiskHQ/lisk-nano/issues/217) +- Input labels not visible after text entered [\#214](https://github.com/LiskHQ/lisk-nano/issues/214) +- Running electron app opens blank window [\#213](https://github.com/LiskHQ/lisk-nano/issues/213) +- Fix names of services in delegateApi.js and forgingApi.js [\#208](https://github.com/LiskHQ/lisk-nano/issues/208) + +**Closed issues:** + +- Download link broken for Mac DMG [\#251](https://github.com/LiskHQ/lisk-nano/issues/251) +- ccccccgiukgtugtvctgeektecvrcdjvnjltbbigjrvdl [\#250](https://github.com/LiskHQ/lisk-nano/issues/250) +- Resolve FIXME comments in e2e tests [\#203](https://github.com/LiskHQ/lisk-nano/issues/203) +- Refactor e2e tests to multitple files and use cucumber [\#202](https://github.com/LiskHQ/lisk-nano/issues/202) +- Setup watch mode for eslint [\#186](https://github.com/LiskHQ/lisk-nano/issues/186) +- Make files organisation clearer [\#154](https://github.com/LiskHQ/lisk-nano/issues/154) +- Document source code using YUIDoc [\#30](https://github.com/LiskHQ/lisk-nano/issues/30) + +**Merged pull requests:** + +- Make offline progress bar overlay - Closes \#272 [\#273](https://github.com/LiskHQ/lisk-nano/pull/273) ([slaweet](https://github.com/slaweet)) +- Send from address or amount - Closes \#270 [\#271](https://github.com/LiskHQ/lisk-nano/pull/271) ([reyraa](https://github.com/reyraa)) +- Unbind mousemove listener on passphrase directive $destroy - Closes \#267 [\#269](https://github.com/LiskHQ/lisk-nano/pull/269) ([slaweet](https://github.com/slaweet)) +- Disable new account e2e test [\#266](https://github.com/LiskHQ/lisk-nano/pull/266) ([slaweet](https://github.com/slaweet)) +- 186 setup watch mode for eslint [\#264](https://github.com/LiskHQ/lisk-nano/pull/264) ([alepop](https://github.com/alepop)) +- Add "sign and copy to clipboard" button to sign message - Closes \#261 [\#262](https://github.com/LiskHQ/lisk-nano/pull/262) ([slaweet](https://github.com/slaweet)) +- Add some explanation to sign/verify message - Closes \#181 [\#259](https://github.com/LiskHQ/lisk-nano/pull/259) ([slaweet](https://github.com/slaweet)) +- Add "Load more" button back to transactions list - Closes \#256 [\#258](https://github.com/LiskHQ/lisk-nano/pull/258) ([slaweet](https://github.com/slaweet)) +- Fix transaction list order [\#257](https://github.com/LiskHQ/lisk-nano/pull/257) ([slaweet](https://github.com/slaweet)) +- Refactor e2e tests to multitple files and use cucumber - Closes \#202 [\#252](https://github.com/LiskHQ/lisk-nano/pull/252) ([slaweet](https://github.com/slaweet)) +- Make tabs more visible - Closes \#220 [\#248](https://github.com/LiskHQ/lisk-nano/pull/248) ([reyraa](https://github.com/reyraa)) +- Rename Transfer to Send - Closes \#215 [\#247](https://github.com/LiskHQ/lisk-nano/pull/247) ([slaweet](https://github.com/slaweet)) +- Display reflexive transaction in gray with circle arrow - Closes \#236 [\#246](https://github.com/LiskHQ/lisk-nano/pull/246) ([slaweet](https://github.com/slaweet)) +- Fix names of services in delegateApi.js and forgingApi.js - Closes \#208 [\#245](https://github.com/LiskHQ/lisk-nano/pull/245) ([slaweet](https://github.com/slaweet)) +- Correct lock resources for nano [\#243](https://github.com/LiskHQ/lisk-nano/pull/243) ([Isabello](https://github.com/Isabello)) +- Make files organisation clearer - Closes \#154 [\#242](https://github.com/LiskHQ/lisk-nano/pull/242) ([alepop](https://github.com/alepop)) +- Revert "Make files organisation clearer - Resolves \#154" [\#241](https://github.com/LiskHQ/lisk-nano/pull/241) ([slaweet](https://github.com/slaweet)) +- Add documentation, Fixes \#30 [\#240](https://github.com/LiskHQ/lisk-nano/pull/240) ([reyraa](https://github.com/reyraa)) +- Fix forging unit tests [\#239](https://github.com/LiskHQ/lisk-nano/pull/239) ([slaweet](https://github.com/slaweet)) +- Make delegate name less timid - Closes \#219 [\#238](https://github.com/LiskHQ/lisk-nano/pull/238) ([slaweet](https://github.com/slaweet)) +- Fix forging tab visibility - Closes \#218 [\#237](https://github.com/LiskHQ/lisk-nano/pull/237) ([slaweet](https://github.com/slaweet)) +- Fix input label overflow - Closes \#214 [\#230](https://github.com/LiskHQ/lisk-nano/pull/230) ([slaweet](https://github.com/slaweet)) +- Fix alignment of md-checkbox in md-menu-item - Closes \#217 [\#229](https://github.com/LiskHQ/lisk-nano/pull/229) ([slaweet](https://github.com/slaweet)) +- Change secondary menu options and order - Closes \#216 [\#228](https://github.com/LiskHQ/lisk-nano/pull/228) ([slaweet](https://github.com/slaweet)) +- Fix transaction fee and amount header alignment - Closes \#225 [\#227](https://github.com/LiskHQ/lisk-nano/pull/227) ([slaweet](https://github.com/slaweet)) +- Restrict initial window size on big screens - Closes \#221 [\#226](https://github.com/LiskHQ/lisk-nano/pull/226) ([slaweet](https://github.com/slaweet)) +- Implement try/catch into Jenkinsfile - Closes \#223 [\#224](https://github.com/LiskHQ/lisk-nano/pull/224) ([Isabello](https://github.com/Isabello)) +- Remove base href="/" - Closes \#213 [\#222](https://github.com/LiskHQ/lisk-nano/pull/222) ([slaweet](https://github.com/slaweet)) +- Make files organisation clearer - Resolves \#154 [\#212](https://github.com/LiskHQ/lisk-nano/pull/212) ([alepop](https://github.com/alepop)) +- Eliminate redundant Api calls. Closes \#174 [\#210](https://github.com/LiskHQ/lisk-nano/pull/210) ([reyraa](https://github.com/reyraa)) +- Resolve FIXME comments in e2e tests - Closes \#203 [\#209](https://github.com/LiskHQ/lisk-nano/pull/209) ([slaweet](https://github.com/slaweet)) + +## [v0.2.1](https://github.com/LiskHQ/lisk-nano/tree/v0.2.1) (2017-05-11) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v0.2.0...v0.2.1) + +**Implemented enhancements:** + +- Change the appearance of the Send modal similar to Vote modal [\#175](https://github.com/LiskHQ/lisk-nano/issues/175) +- Simplify peer selection before login [\#153](https://github.com/LiskHQ/lisk-nano/issues/153) +- Improve error message on invalid recipient address [\#142](https://github.com/LiskHQ/lisk-nano/issues/142) +- Improve appearance of low ranking delegate [\#137](https://github.com/LiskHQ/lisk-nano/issues/137) +- Add more menu items to Electron wrapper [\#134](https://github.com/LiskHQ/lisk-nano/issues/134) +- Enable context menu [\#121](https://github.com/LiskHQ/lisk-nano/issues/121) +- Add routing functionality [\#117](https://github.com/LiskHQ/lisk-nano/issues/117) +- Add about menu dialog to electron wrapper [\#107](https://github.com/LiskHQ/lisk-nano/issues/107) +- Move send form to a dialog opened by a button at the top [\#88](https://github.com/LiskHQ/lisk-nano/issues/88) +- "Load more" - replacement [\#66](https://github.com/LiskHQ/lisk-nano/issues/66) +- Add possibility to sign/verify message [\#29](https://github.com/LiskHQ/lisk-nano/issues/29) +- Add delegate votes management [\#24](https://github.com/LiskHQ/lisk-nano/issues/24) +- Add support for delegate registration [\#22](https://github.com/LiskHQ/lisk-nano/issues/22) +- Add support for second passphrase registration [\#21](https://github.com/LiskHQ/lisk-nano/issues/21) + +**Fixed bugs:** + +- Force SCM checkout in Jenkins [\#205](https://github.com/LiskHQ/lisk-nano/issues/205) +- Account with second passphrase cannot register as delegate [\#198](https://github.com/LiskHQ/lisk-nano/issues/198) +- Maximum amount validation doesn't work [\#194](https://github.com/LiskHQ/lisk-nano/issues/194) +- Second passphrase registration menu item persists [\#190](https://github.com/LiskHQ/lisk-nano/issues/190) +- http://localhost:8080/ is broken [\#180](https://github.com/LiskHQ/lisk-nano/issues/180) +- Fix repeater issue in transaction list [\#157](https://github.com/LiskHQ/lisk-nano/issues/157) +- Signed message output missing publickey for cold accounts [\#152](https://github.com/LiskHQ/lisk-nano/issues/152) +- Closing new account procedure results in inaccessible login [\#148](https://github.com/LiskHQ/lisk-nano/issues/148) +- Appearance issues in Firefox [\#147](https://github.com/LiskHQ/lisk-nano/issues/147) +- Delegate account is not clearly indicated [\#141](https://github.com/LiskHQ/lisk-nano/issues/141) +- Vote button should be disabled until delegates selected [\#140](https://github.com/LiskHQ/lisk-nano/issues/140) +- Clicking "My Votes" when zero displays empty rectangle [\#139](https://github.com/LiskHQ/lisk-nano/issues/139) +- Vote confirmation modal overflows, hiding header and footer [\#138](https://github.com/LiskHQ/lisk-nano/issues/138) +- Peer selection highlighted as errorneous when passphrase invalid [\#136](https://github.com/LiskHQ/lisk-nano/issues/136) +- Scientific annotation displayed on transaction history [\#128](https://github.com/LiskHQ/lisk-nano/issues/128) +- Inconsistency in transaction amount [\#127](https://github.com/LiskHQ/lisk-nano/issues/127) +- Peer selection does not work [\#124](https://github.com/LiskHQ/lisk-nano/issues/124) +- Set maximum amount function invalid [\#122](https://github.com/LiskHQ/lisk-nano/issues/122) +- OSX Build is now --mac for electron builder [\#120](https://github.com/LiskHQ/lisk-nano/issues/120) +- Remove Git Hooks from src/package.json [\#111](https://github.com/LiskHQ/lisk-nano/issues/111) +- Logout button / app quitting [\#64](https://github.com/LiskHQ/lisk-nano/issues/64) + +**Closed issues:** + +- Implement Jenkins to run CI [\#192](https://github.com/LiskHQ/lisk-nano/issues/192) +- Update lisk-js to version 0.4.1 [\#182](https://github.com/LiskHQ/lisk-nano/issues/182) +- Unify angular service naming [\#179](https://github.com/LiskHQ/lisk-nano/issues/179) +- Create e2e tests for all 1.0.0 features [\#178](https://github.com/LiskHQ/lisk-nano/issues/178) +- Review test coverage [\#171](https://github.com/LiskHQ/lisk-nano/issues/171) +- Refactor some parts of forging controller into a service [\#170](https://github.com/LiskHQ/lisk-nano/issues/170) +- Refactor peers service account-related methods [\#169](https://github.com/LiskHQ/lisk-nano/issues/169) +- Update or remove unmaintained History.md [\#155](https://github.com/LiskHQ/lisk-nano/issues/155) +- Signed build for macOS and Windows [\#91](https://github.com/LiskHQ/lisk-nano/issues/91) +- Create an issue template [\#85](https://github.com/LiskHQ/lisk-nano/issues/85) +- Integrate end to end tests into travis build [\#54](https://github.com/LiskHQ/lisk-nano/issues/54) + +**Merged pull requests:** + +- Preparing 0.2.1 release [\#207](https://github.com/LiskHQ/lisk-nano/pull/207) ([karmacoma](https://github.com/karmacoma)) +- Correct Jenkinsfile checkout issue - Closes \#205 [\#206](https://github.com/LiskHQ/lisk-nano/pull/206) ([Isabello](https://github.com/Isabello)) +- Fix delegate registration for account with second passphrase - Closes \#198 [\#204](https://github.com/LiskHQ/lisk-nano/pull/204) ([slaweet](https://github.com/slaweet)) +- Create e2e tests for all 1.0.0 features - Closes \#178 [\#201](https://github.com/LiskHQ/lisk-nano/pull/201) ([slaweet](https://github.com/slaweet)) +- Fix maximum amount validation - Closes \#194 [\#200](https://github.com/LiskHQ/lisk-nano/pull/200) ([slaweet](https://github.com/slaweet)) +- Unify naming conventions - Closes \#179 [\#199](https://github.com/LiskHQ/lisk-nano/pull/199) ([reyraa](https://github.com/reyraa)) +- Fix maximum amount validation - Closes \#194 [\#197](https://github.com/LiskHQ/lisk-nano/pull/197) ([slaweet](https://github.com/slaweet)) +- Initial implementation of Jenkins CI - Closes \#192 [\#195](https://github.com/LiskHQ/lisk-nano/pull/195) ([Isabello](https://github.com/Isabello)) +- Hide 2nd passphrase menu item after registration - Closes \#190 [\#191](https://github.com/LiskHQ/lisk-nano/pull/191) ([slaweet](https://github.com/slaweet)) +- Fix routing issues - Closes \#180 [\#188](https://github.com/LiskHQ/lisk-nano/pull/188) ([reyraa](https://github.com/reyraa)) +- Updating lisk-js - Closes \#182 [\#187](https://github.com/LiskHQ/lisk-nano/pull/187) ([karmacoma](https://github.com/karmacoma)) +- Review test coverage - Closes \#171 [\#185](https://github.com/LiskHQ/lisk-nano/pull/185) ([slaweet](https://github.com/slaweet)) +- Updating lisk-js - Closes \#182 [\#184](https://github.com/LiskHQ/lisk-nano/pull/184) ([karmacoma](https://github.com/karmacoma)) +- Change the style of Send modal to make it similar to other modals - Closes \#175 [\#177](https://github.com/LiskHQ/lisk-nano/pull/177) ([reyraa](https://github.com/reyraa)) +- Move $peers service account-related methods to Account - Closes \#169 [\#176](https://github.com/LiskHQ/lisk-nano/pull/176) ([slaweet](https://github.com/slaweet)) +- Refactor some parts of forging controller into a service - Closes \#170 [\#173](https://github.com/LiskHQ/lisk-nano/pull/173) ([slaweet](https://github.com/slaweet)) +- Fixing appearance issues in Firefox - Fixes \#147 [\#172](https://github.com/LiskHQ/lisk-nano/pull/172) ([reyraa](https://github.com/reyraa)) +- Support delegate registration - Closes \#22 [\#168](https://github.com/LiskHQ/lisk-nano/pull/168) ([reyraa](https://github.com/reyraa)) +- Refactor use of alert dialogs and toasts [\#167](https://github.com/LiskHQ/lisk-nano/pull/167) ([slaweet](https://github.com/slaweet)) +- Fix disable button reverts [\#166](https://github.com/LiskHQ/lisk-nano/pull/166) ([slaweet](https://github.com/slaweet)) +- Fix new account generation cancel action - Closes \#148 [\#165](https://github.com/LiskHQ/lisk-nano/pull/165) ([slaweet](https://github.com/slaweet)) +- Simplify peer selection dropdown on login page - Closes \#153 [\#164](https://github.com/LiskHQ/lisk-nano/pull/164) ([slaweet](https://github.com/slaweet)) +- Prevent send form errors after successful transaction [\#162](https://github.com/LiskHQ/lisk-nano/pull/162) ([slaweet](https://github.com/slaweet)) +- Second passphrase should be null not undefined [\#161](https://github.com/LiskHQ/lisk-nano/pull/161) ([slaweet](https://github.com/slaweet)) +- Make sure that send dialog shows decimal point, not comma [\#160](https://github.com/LiskHQ/lisk-nano/pull/160) ([slaweet](https://github.com/slaweet)) +- Fix repeater issue in transaction list - Fixes \#157 [\#159](https://github.com/LiskHQ/lisk-nano/pull/159) ([slaweet](https://github.com/slaweet)) +- Add routing functionality - Fixes \#117 [\#158](https://github.com/LiskHQ/lisk-nano/pull/158) ([reyraa](https://github.com/reyraa)) +- Removing unmaintained History.md - Closes \#155 [\#156](https://github.com/LiskHQ/lisk-nano/pull/156) ([karmacoma](https://github.com/karmacoma)) +- Distinguish input and output in sign/verify message - Closes \#150 [\#151](https://github.com/LiskHQ/lisk-nano/pull/151) ([slaweet](https://github.com/slaweet)) +- Use angular-svg-round-progressbar in forging center - Fixes \#137 [\#149](https://github.com/LiskHQ/lisk-nano/pull/149) ([slaweet](https://github.com/slaweet)) +- Vote list too high - Fixes \#138 [\#146](https://github.com/LiskHQ/lisk-nano/pull/146) ([slaweet](https://github.com/slaweet)) +- Support second passphrase - Closes \#21 [\#145](https://github.com/LiskHQ/lisk-nano/pull/145) ([reyraa](https://github.com/reyraa)) +- Disable "My Votes \(0\)" button - Fixes \#139 [\#144](https://github.com/LiskHQ/lisk-nano/pull/144) ([slaweet](https://github.com/slaweet)) +- Disable vote button until delegates selected - Closes \#140 [\#143](https://github.com/LiskHQ/lisk-nano/pull/143) ([slaweet](https://github.com/slaweet)) +- Add more menu items to Electron wrapper - Closes \#134 [\#135](https://github.com/LiskHQ/lisk-nano/pull/135) ([slaweet](https://github.com/slaweet)) +- Add possibility to sign/verify message - Closes \#29 [\#133](https://github.com/LiskHQ/lisk-nano/pull/133) ([slaweet](https://github.com/slaweet)) +- Fix maximum amount function - Closes \#122 [\#131](https://github.com/LiskHQ/lisk-nano/pull/131) ([slaweet](https://github.com/slaweet)) +- Fix transaction amount inconsistency - Closes \#127 [\#130](https://github.com/LiskHQ/lisk-nano/pull/130) ([slaweet](https://github.com/slaweet)) +- Use inifinite scroll for transactions list - Closes \#66 [\#129](https://github.com/LiskHQ/lisk-nano/pull/129) ([slaweet](https://github.com/slaweet)) +- Remove Git Hooks from src/package.json - Closes \#111 [\#126](https://github.com/LiskHQ/lisk-nano/pull/126) ([slaweet](https://github.com/slaweet)) +- "About" menu item and dialog - Closes \#107 [\#125](https://github.com/LiskHQ/lisk-nano/pull/125) ([slaweet](https://github.com/slaweet)) +- Enable context menu - Closes \#121 [\#123](https://github.com/LiskHQ/lisk-nano/pull/123) ([slaweet](https://github.com/slaweet)) +- Send through modal - Closes \#88 [\#116](https://github.com/LiskHQ/lisk-nano/pull/116) ([reyraa](https://github.com/reyraa)) +- Add delegate votes management - Closes \#24 [\#115](https://github.com/LiskHQ/lisk-nano/pull/115) ([slaweet](https://github.com/slaweet)) + +## [v0.2.0](https://github.com/LiskHQ/lisk-nano/tree/v0.2.0) (2017-04-18) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v0.1.2...v0.2.0) + +**Implemented enhancements:** + +- Select network in login dialog [\#89](https://github.com/LiskHQ/lisk-nano/issues/89) +- Send all [\#62](https://github.com/LiskHQ/lisk-nano/issues/62) +- Add forging center [\#23](https://github.com/LiskHQ/lisk-nano/issues/23) + +**Fixed bugs:** + +- Prevent regular login with non-bip39-passphrase and ENTER-key [\#112](https://github.com/LiskHQ/lisk-nano/issues/112) +- Login.lisk.io and testnet.lisk.io peers options do not work [\#106](https://github.com/LiskHQ/lisk-nano/issues/106) +- Localhost:7000 is missing from peers options [\#105](https://github.com/LiskHQ/lisk-nano/issues/105) +- Peer doesn't show the peer at first [\#99](https://github.com/LiskHQ/lisk-nano/issues/99) +- App build is broken [\#94](https://github.com/LiskHQ/lisk-nano/issues/94) +- Prevent from calling "new account" function if already called [\#92](https://github.com/LiskHQ/lisk-nano/issues/92) +- Floating-point arithmetic rounding error in balance [\#80](https://github.com/LiskHQ/lisk-nano/issues/80) +- Transactions sent do not appear immediately within listing [\#63](https://github.com/LiskHQ/lisk-nano/issues/63) +- Webpack - Module build failed: SyntaxError [\#55](https://github.com/LiskHQ/lisk-nano/issues/55) +- Received "Error - An error occurred while sending the transaction." message, but Transaction still went through [\#19](https://github.com/LiskHQ/lisk-nano/issues/19) +- Weird behaviour while UI is selected [\#17](https://github.com/LiskHQ/lisk-nano/issues/17) +- New passphrase window sometimes pops-up more than once [\#16](https://github.com/LiskHQ/lisk-nano/issues/16) +- Ctrl/Cmd + A select more than passphrase [\#15](https://github.com/LiskHQ/lisk-nano/issues/15) +- Error: No peer connection. [\#11](https://github.com/LiskHQ/lisk-nano/issues/11) + +**Closed issues:** + +- Update electron dependencies for binary builds [\#113](https://github.com/LiskHQ/lisk-nano/issues/113) +- Travis build marked as "passed" even though a test case failed [\#97](https://github.com/LiskHQ/lisk-nano/issues/97) +- Figure out how to securely serve Lisk Nano as a web app [\#87](https://github.com/LiskHQ/lisk-nano/issues/87) +- Change license from MIT to GPLv3 [\#79](https://github.com/LiskHQ/lisk-nano/issues/79) +- Speed up webpack build time in unit tests [\#60](https://github.com/LiskHQ/lisk-nano/issues/60) +- Test webpack compilation during travis build [\#57](https://github.com/LiskHQ/lisk-nano/issues/57) +- Set up unit test framework that works with Angular and Webpack [\#52](https://github.com/LiskHQ/lisk-nano/issues/52) +- Add test coverage metric that works with Angular [\#51](https://github.com/LiskHQ/lisk-nano/issues/51) +- Review tests run during travis build [\#46](https://github.com/LiskHQ/lisk-nano/issues/46) +- Fix errors produced by eslint [\#45](https://github.com/LiskHQ/lisk-nano/issues/45) +- Rework code for lisk-js 0.3 [\#42](https://github.com/LiskHQ/lisk-nano/issues/42) +- Setup end-to-end tests [\#41](https://github.com/LiskHQ/lisk-nano/issues/41) +- Migrate Jade to Pug [\#38](https://github.com/LiskHQ/lisk-nano/issues/38) +- Add test coverage metric [\#28](https://github.com/LiskHQ/lisk-nano/issues/28) +- Setup travis based continous integration [\#27](https://github.com/LiskHQ/lisk-nano/issues/27) +- Setup static code analysis [\#26](https://github.com/LiskHQ/lisk-nano/issues/26) +- Setup unit testing framework [\#25](https://github.com/LiskHQ/lisk-nano/issues/25) + +**Merged pull requests:** + +- Updating lisk-js [\#119](https://github.com/LiskHQ/lisk-nano/pull/119) ([karmacoma](https://github.com/karmacoma)) +- Prevent regular login with non-bip39-passphrase and ENTER-key - Closes \#112 [\#118](https://github.com/LiskHQ/lisk-nano/pull/118) ([slaweet](https://github.com/slaweet)) +- Update electron build dependencies - Closes \#113 [\#114](https://github.com/LiskHQ/lisk-nano/pull/114) ([Isabello](https://github.com/Isabello)) +- Add forging center - Closes \#23 [\#110](https://github.com/LiskHQ/lisk-nano/pull/110) ([slaweet](https://github.com/slaweet)) +- Show ports only in localhost peers - Closes \#106 [\#109](https://github.com/LiskHQ/lisk-nano/pull/109) ([slaweet](https://github.com/slaweet)) +- Add localhost:7000 to peers options - Closes \#105 [\#108](https://github.com/LiskHQ/lisk-nano/pull/108) ([slaweet](https://github.com/slaweet)) +- Fix "send all funds" to set 0 if no funds - Closes \#62 [\#104](https://github.com/LiskHQ/lisk-nano/pull/104) ([slaweet](https://github.com/slaweet)) +- Fix peer dropdown for nodes without specified port - Closes \#99 [\#103](https://github.com/LiskHQ/lisk-nano/pull/103) ([slaweet](https://github.com/slaweet)) +- Move network selection to login module - Closes \#89 [\#102](https://github.com/LiskHQ/lisk-nano/pull/102) ([reyraa](https://github.com/reyraa)) +- Make peer dropdown work also with ports - Closes \#99 [\#101](https://github.com/LiskHQ/lisk-nano/pull/101) ([slaweet](https://github.com/slaweet)) +- Fix peer doesn't show the peer at first \#99 [\#100](https://github.com/LiskHQ/lisk-nano/pull/100) ([slaweet](https://github.com/slaweet)) +- Failed tests should fail travis build \#97 [\#98](https://github.com/LiskHQ/lisk-nano/pull/98) ([slaweet](https://github.com/slaweet)) +- Fix build by not minifying - Closes \#94 [\#95](https://github.com/LiskHQ/lisk-nano/pull/95) ([slaweet](https://github.com/slaweet)) +- Disable "New account" button after clicked - Closes \#92 [\#93](https://github.com/LiskHQ/lisk-nano/pull/93) ([slaweet](https://github.com/slaweet)) +- Fix floating-point arithmetic rounding error in balance \#80 [\#90](https://github.com/LiskHQ/lisk-nano/pull/90) ([slaweet](https://github.com/slaweet)) +- Create an issue template [\#86](https://github.com/LiskHQ/lisk-nano/pull/86) ([slaweet](https://github.com/slaweet)) +- Show pending transactions - Closes \#63 [\#84](https://github.com/LiskHQ/lisk-nano/pull/84) ([slaweet](https://github.com/slaweet)) +- Adding "send all funds" feature - Closes \#62 [\#83](https://github.com/LiskHQ/lisk-nano/pull/83) ([slaweet](https://github.com/slaweet)) +- Rework code for lisk-js 0.3 - Closes \#42 [\#82](https://github.com/LiskHQ/lisk-nano/pull/82) ([slaweet](https://github.com/slaweet)) +- Changing license from MIT to GPLv3 - Closes \#79 [\#81](https://github.com/LiskHQ/lisk-nano/pull/81) ([karmacoma](https://github.com/karmacoma)) +- E2e test tweaks [\#78](https://github.com/LiskHQ/lisk-nano/pull/78) ([slaweet](https://github.com/slaweet)) +- Finish unit tests [\#77](https://github.com/LiskHQ/lisk-nano/pull/77) ([slaweet](https://github.com/slaweet)) +- Update authors [\#76](https://github.com/LiskHQ/lisk-nano/pull/76) ([karmacoma](https://github.com/karmacoma)) +- Fixing eslint errors - Closes \#45 [\#75](https://github.com/LiskHQ/lisk-nano/pull/75) ([karmacoma](https://github.com/karmacoma)) +- Unit test send component [\#74](https://github.com/LiskHQ/lisk-nano/pull/74) ([slaweet](https://github.com/slaweet)) +- Test transactions component [\#73](https://github.com/LiskHQ/lisk-nano/pull/73) ([slaweet](https://github.com/slaweet)) +- Unit tests of login component [\#72](https://github.com/LiskHQ/lisk-nano/pull/72) ([slaweet](https://github.com/slaweet)) +- Adding unit tests [\#70](https://github.com/LiskHQ/lisk-nano/pull/70) ([slaweet](https://github.com/slaweet)) +- Adding test coverage metric that works with Angular - Closes \#51 [\#67](https://github.com/LiskHQ/lisk-nano/pull/67) ([slaweet](https://github.com/slaweet)) +- Set up unit test framework that works with Angular and Webpack - Closes \#52 [\#59](https://github.com/LiskHQ/lisk-nano/pull/59) ([slaweet](https://github.com/slaweet)) +- Test webpack build in travis - Closes \#57 [\#58](https://github.com/LiskHQ/lisk-nano/pull/58) ([slaweet](https://github.com/slaweet)) +- Adding babel-plugin-syntax-trailing-function-commas - Closes \#55 [\#56](https://github.com/LiskHQ/lisk-nano/pull/56) ([karmacoma](https://github.com/karmacoma)) +- Adding end to end tests - Closes \#41 [\#53](https://github.com/LiskHQ/lisk-nano/pull/53) ([slaweet](https://github.com/slaweet)) +- Review tests in travis - Closes \#46 [\#50](https://github.com/LiskHQ/lisk-nano/pull/50) ([slaweet](https://github.com/slaweet)) +- Fix eslint errors - Closes \#45 [\#49](https://github.com/LiskHQ/lisk-nano/pull/49) ([slaweet](https://github.com/slaweet)) +- Fix new passphrase autofocus - Closes \#15 [\#48](https://github.com/LiskHQ/lisk-nano/pull/48) ([slaweet](https://github.com/slaweet)) +- Migrate jade to pug - Closes \#38 [\#47](https://github.com/LiskHQ/lisk-nano/pull/47) ([slaweet](https://github.com/slaweet)) +- Gitignore generated file app.js [\#44](https://github.com/LiskHQ/lisk-nano/pull/44) ([slaweet](https://github.com/slaweet)) +- Make all npm dependencies version strict [\#43](https://github.com/LiskHQ/lisk-nano/pull/43) ([slaweet](https://github.com/slaweet)) +- Setup autologin from a cookie for development purposes [\#40](https://github.com/LiskHQ/lisk-nano/pull/40) ([slaweet](https://github.com/slaweet)) +- Fix multiple new passphrase windows bug - Closes \#16 [\#39](https://github.com/LiskHQ/lisk-nano/pull/39) ([slaweet](https://github.com/slaweet)) +- Setup travis based continuous intergation - Closes \#27 [\#37](https://github.com/LiskHQ/lisk-nano/pull/37) ([slaweet](https://github.com/slaweet)) +- Setup eslint in grunt - Closes \#26 [\#36](https://github.com/LiskHQ/lisk-nano/pull/36) ([slaweet](https://github.com/slaweet)) +- Ctrl+a to select passphrase on registration - Closes \#15 [\#35](https://github.com/LiskHQ/lisk-nano/pull/35) ([slaweet](https://github.com/slaweet)) +- Adding test coverage metric - Closes \#28 [\#34](https://github.com/LiskHQ/lisk-nano/pull/34) ([Tosch110](https://github.com/Tosch110)) +- Adding test suite - Closes \#25 [\#33](https://github.com/LiskHQ/lisk-nano/pull/33) ([Tosch110](https://github.com/Tosch110)) +- Update copyright [\#31](https://github.com/LiskHQ/lisk-nano/pull/31) ([Isabello](https://github.com/Isabello)) + +## [v0.1.2](https://github.com/LiskHQ/lisk-nano/tree/v0.1.2) (2016-12-01) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v0.1.1...v0.1.2) + +**Implemented enhancements:** + +- Passphrase unhide possibility [\#6](https://github.com/LiskHQ/lisk-nano/issues/6) + +**Closed issues:** + +- Mouse scroll changes transaction amount [\#18](https://github.com/LiskHQ/lisk-nano/issues/18) +- Enable Copy of Transaction ID's [\#14](https://github.com/LiskHQ/lisk-nano/issues/14) +- NPM warns electron-prebuilt is depreciated and renamed to electron [\#12](https://github.com/LiskHQ/lisk-nano/issues/12) + +**Merged pull requests:** + +- electron-prebuilt will be depreciated [\#13](https://github.com/LiskHQ/lisk-nano/pull/13) ([Citizen-X](https://github.com/Citizen-X)) + +## [v0.1.1](https://github.com/LiskHQ/lisk-nano/tree/v0.1.1) (2016-10-13) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v0.1.0...v0.1.1) + +**Implemented enhancements:** + +- Add support for second passphrases [\#1](https://github.com/LiskHQ/lisk-nano/issues/1) + +**Fixed bugs:** + +- No login with BIP39 mnemonic with more than 12 words [\#2](https://github.com/LiskHQ/lisk-nano/issues/2) + +## [v0.1.0](https://github.com/LiskHQ/lisk-nano/tree/v0.1.0) (2016-08-17) +[Full Changelog](https://github.com/LiskHQ/lisk-nano/compare/v0.0.1...v0.1.0) + +## [v0.0.1](https://github.com/LiskHQ/lisk-nano/tree/v0.0.1) (2016-08-01) + + +\* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..b9a0a3ccd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# How to contribute + +First of all, thank you for taking the time to contribute to this project. We've tried to make a stable project and try to fix bugs and add new features continuously. You can help us do more. + +Before you start, read the **README.md** file for info on the project and how to set it up. + +## Getting started + +### Check out the roadmap + +We have some functionalities in mind and we have issued them and there is a *milestone* label available on the issue. If there is a bug or a feature that is not listed in the **issues** page or there is no one assigned to the issue, feel free to fix/add it! Although it's better to discuss it in the issue or create a new issue for it so there is no conflicting code. + +### Filing issues + +Before starting work on a larger idea not discussed in an issue we recommend starting one to iron out your approach to implementation. PRs with conflicting ideas regarding architecture or other aspects of the project may be rejected. We appreciate any and all ideas you contribute providing they're discussed in a respectful and constructive manner. + +Issues created that are not relevant to this project will be closed immediately - this is purely for efficiency as we don't have time to address and or move all of them to their correct place. + +### Writing some code! + +Contributing to a project on Github is pretty straight forward. If this is you're first time, these are the steps you should take. + +- Fork this repo. + +And that's it! Read the code available and apply your changes according to the issue you're working on! You're change should not break the existing code and should pass the tests. Start from the branch **development**, create a new branch under the name of the issue and work in there. + +When you're done, submit a pull request and for one of the maintainers to check it out. We would let you know if there is any problem or any changes that should be considered. + +### Tests + +We've written tests and you can run them to assure the stability of the code, just try running `npm test`. If you're adding a new functionality please include tests for it. + +### Documentation + +Every chunk of code that may be hard to understand has some comments above it. If you write some new code or change some part of the existing code in a way that it would not be functional without changing it's usages, it needs to be documented. diff --git a/Gruntfile.js b/Gruntfile.js deleted file mode 100644 index 9d59ea14a..000000000 --- a/Gruntfile.js +++ /dev/null @@ -1,25 +0,0 @@ -module.exports = function (grunt) { - require('jit-grunt')(grunt); // eslint-disable-line import/no-extraneous-dependencies - - grunt.initConfig({ - eslint: { - options: { - configFile: '.eslintrc', - format: 'codeframe', - fix: false, - }, - all: { - src: ['src/**/*.js', 'features/**/*.js', 'test/**/*.js', 'app/main.js', '*.js'], - }, - }, - }); - - grunt.registerTask('test', ['newer:eslint']); - grunt.registerTask('travis', ['test']); - grunt.registerTask('default', ['test']); - - grunt.registerTask('eslint-fix', 'Run eslint and fix formatting', () => { - grunt.config.set('eslint.options.fix', true); - grunt.task.run('newer:eslint'); - }); -}; diff --git a/Jenkinsfile b/Jenkinsfile index 2b7afa324..3c85ccab3 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -33,7 +33,7 @@ node('lisk-nano-01'){ try { sh '''#!/bin/bash cd ~/lisk-test-nano - bash lisk.sh rebuild -0 + bash lisk.sh rebuild -f /home/lisk/lisk-test-nano/blockchain_explorer.db.gz ''' } catch (err) { currentBuild.result = 'FAILURE' @@ -42,15 +42,37 @@ node('lisk-nano-01'){ } } - stage ('Build Nano') { + stage ('Install npm dependencies') { try { sh '''#!/bin/bash - # Install Electron npm install # Build nano cd $WORKSPACE npm install + ''' + } catch (err) { + currentBuild.result = 'FAILURE' + milestone 1 + error('Stopping build, npm install failed') + } + } + + stage ('Run Eslint') { + try { + sh ''' + cd $WORKSPACE + npm run eslint + ''' + } catch (err) { + currentBuild.result = 'FAILURE' + error('Stopping build, Eslint failed') + } + } + + stage ('Build Nano') { + try { + sh '''#!/bin/bash # Add coveralls config file cp ~/.coveralls.yml-nano .coveralls.yml @@ -68,9 +90,13 @@ node('lisk-nano-01'){ try { sh ''' export ON_JENKINS=true + # Run test cd $WORKSPACE npm run test + + # Submit coverage to coveralls + cat coverage/*/lcov.info | coveralls -v ''' } catch (err) { currentBuild.result = 'FAILURE' @@ -81,9 +107,6 @@ node('lisk-nano-01'){ stage ('Start Dev Server and Run Tests') { try { sh ''' - # Prepare lisk core for testing - bash ~/tx.sh - # Run Dev build and Build cd $WORKSPACE export NODE_ENV= @@ -94,7 +117,6 @@ node('lisk-nano-01'){ export DISPLAY=:99 Xvfb :99 -ac -screen 0 1280x1024x24 & ./node_modules/protractor/bin/webdriver-manager update - ./node_modules/protractor/bin/webdriver-manager start & # Run End to End Tests npm run e2e-test @@ -106,6 +128,9 @@ node('lisk-nano-01'){ rm -rf /tmp/.X0-lock || true pkill -f webpack -9 || true + # Cleanup - delete all files on success + cd $WORKSPACE + rm -rf * ''' } catch (err) { currentBuild.result = 'FAILURE' diff --git a/README.md b/README.md index 7284fadcd..bf20c2abe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Lisk Nano -[![Build Status](https://jenkins.lisk.io/buildStatus/icon?job=Nano-Pipeline/development)](https://jenkins.lisk.io/job/Nano-Pipeline/development) +[![Build Status](https://jenkins.lisk.io/buildStatus/icon?job=lisk-nano/development)](https://jenkins.lisk.io/job/lisk-nano/job/development) [![Coverage Status](https://coveralls.io/repos/github/LiskHQ/lisk-nano/badge.svg?branch=development)](https://coveralls.io/github/LiskHQ/lisk-nano?branch=development) [![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](http://www.gnu.org/licenses/gpl-3.0) @@ -15,6 +15,16 @@ npm run dev Open http://localhost:8080 +For ease of development, you can setItem in localStorage to prefill a passphrase, e.g.: +``` +localStorage.setItem('passphrase', 'wagon stock borrow episode laundry kitten salute link globe zero feed marble') +``` + +And then you can setItem in localStorage to login automatically +``` +localStorage.setItem('autologin', true) +``` + ## Build ``` @@ -39,9 +49,9 @@ Build package for Windows. npm run dist:win ``` -### Mac OS X +### macOS -Build package for Mac OS X. +Build package for macOS. ``` npm run dist:mac @@ -71,17 +81,15 @@ npm run test-live ### Setup -To setup protractor as described on http://www.protractortest.org/#/ run: +Setup protractor ``` -npm install -g protractor -webdriver-manager update -webdriver-manager start +./node_modules/protractor/bin/webdriver-manager update ``` Setup a lisk test node to run on localhost:4000 as described in https://github.com/LiskHQ/lisk#tests -Make sure that the Lisk version of the node matches version in https://github.com/LiskHQ/lisk-nano/blob/development/src/app/services/peers/peer.js#L16 +And run it with [pm2](http://pm2.keymetrics.io/). ### Run @@ -98,6 +106,18 @@ Run the protractor tests (replace `~/git/lisk/` with your path to lisk core): npm run e2e-test ``` +## Launch React Storybook + +To launch storybook sandbox with components run +``` +npm run storybook +``` +and go to + +http://localhost:6006/ + + + ## Authors - Ricardo Ferro @@ -105,6 +125,7 @@ npm run e2e-test - Vít Stanislav - Tobias Schwarz - Ali Haghighatkhah +- Yashar Ayari ## License diff --git a/app/main.js b/app/main.js index 52b8bd7f3..d998d7efa 100644 --- a/app/main.js +++ b/app/main.js @@ -103,7 +103,7 @@ function createWindow() { { label: 'Report Issue...', click() { - electron.shell.openExternal('https://github.com/LiskHQ/lisk-nano/issues/new'); + electron.shell.openExternal('https://lisk.zendesk.com/hc/en-us/requests/new'); }, }, { @@ -153,7 +153,7 @@ function createWindow() { win.loadURL(`file://${__dirname}/dist/index.html`); - win.on('closed', () => win = null); + win.on('closed', () => { win = null; }); const selectionMenu = Menu.buildFromTemplate([ { role: 'copy' }, diff --git a/app/package.json b/app/package.json index 384f0b9ed..432772d1e 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "lisk-nano", - "version": "1.0.2", + "version": "1.1.0", "description": "Lisk Nano", "main": "main.js", "author":{ diff --git a/e2e-test-setup.sh b/e2e-test-setup.sh index 3cfb70a69..533d62179 100755 --- a/e2e-test-setup.sh +++ b/e2e-test-setup.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Purpose of this script is to clean lisk database and create some tranactions +# Purpose of this script is to clean lisk database and create some tranactions if [ -z "$1" ] then @@ -7,19 +7,17 @@ if [ -z "$1" ] exit 1 fi +if [ ! -f blockchain_explorer.db.gz ]; then + wget https://downloads.lisk.io/lisk-explorer/dev/blockchain_explorer.db.gz +fi + pwd=`pwd` cd $1 -forever stop app.js -dropdb lisk_test && createdb lisk_test -forever start app.js +pm2 stop app.js +dropdb lisk_test +createdb lisk_test +gunzip -fcq "$pwd/blockchain_explorer.db.gz" | psql -d lisk_test +pm2 start app.js sleep 5 cd $pwd -for i in {1..20} -do - curl -k -H "Content-Type: application/json" -X PUT -d '{"secret":"wagon stock borrow episode laundry kitten salute link globe zero feed marble","amount":'"$i"000000000',"recipientId":"537318935439898807L"}' http://localhost:4000/api/transactions - echo '' -done - curl -k -H "Content-Type: application/json" -X PUT -d '{"secret":"wagon stock borrow episode laundry kitten salute link globe zero feed marble","amount":'10000000000',"recipientId":"544792633152563672L"}' http://localhost:4000/api/transactions - curl -k -H "Content-Type: application/json" -X PUT -d '{"secret":"wagon stock borrow episode laundry kitten salute link globe zero feed marble","amount":'10000000000',"recipientId":"4264113712245538326L"}' http://localhost:4000/api/transactions -sleep 5 diff --git a/features/login.feature b/features/login.feature index 8e8986634..ba3189ef2 100644 --- a/features/login.feature +++ b/features/login.feature @@ -5,17 +5,38 @@ Feature: Login page And I click "login button" Then I should be logged in - Scenario: should allow to change network + Scenario: should allow to login to Mainnet Given I'm on login page - When I select option no. 2 from "network" select - Then the option "Testnet" is selected in "network" select + When I fill in "wagon stock borrow episode laundry kitten salute link globe zero feed marble" to "passphrase" field + And I select option no. 1 from "network" select + And I click "login button" + Then I should be logged in + And I should see text "Mainnet" in "peer network" element + + Scenario: should allow to login to Testnet + Given I'm on login page + When I fill in "wagon stock borrow episode laundry kitten salute link globe zero feed marble" to "passphrase" field + And I select option no. 2 from "network" select + And I click "login button" + Then I should be logged in + And I should see text "Testnet" in "peer network" element + + Scenario: should remember the selected network + Given I'm on login page + When I fill in "wagon stock borrow episode laundry kitten salute link globe zero feed marble" to "passphrase" field + And I select option no. 2 from "network" select + And I click "login button" + And I refresh the page + And I fill in "wagon stock borrow episode laundry kitten salute link globe zero feed marble" to "passphrase" field + And I click "login button" + Then I should be logged in + And I should see text "Testnet" in "peer network" element - @ignore Scenario: should allow to create a new account Given I'm on login page When I click "new account button" - And I click on "next button" + And I click "next button" And I 250 times move mouse randomly - And I remember passphrase, click "yes its save button", fill in missing word - And I click "ok button" + And I remember passphrase, click "next button", fill in missing word + And I click "next button" Then I should be logged in diff --git a/features/menu.feature b/features/menu.feature index a821dedb2..6d33e8c7e 100644 --- a/features/menu.feature +++ b/features/menu.feature @@ -4,22 +4,65 @@ Feature: Top right menu When I click "register second passphrase" in main menu And I click "next button" And I 250 times move mouse randomly - And I remember passphrase, click "yes its save button", fill in missing word - And I click "ok button" + And I remember passphrase, click "next button", fill in missing word + And I click "next button" Then I should see alert dialog with title "Success" and text "Second passphrase registration was successfully submitted. It can take several seconds before it is processed." + Scenario: should not allow to set 2nd passphrase again + Given I'm logged in as "second passphrase account" + Then There is no "register second passphrase" in main menu + + Scenario: should not allow to set 2nd passphrase if not enough funds for the fee + Given I'm logged in as "empty account" + When I click "register second passphrase" in main menu + Then I should see "Insufficient funds for 5 LSK fee" error message + And "next button" should be disabled + + Scenario: should allow to exit 2nd passphrase registration dialog + Given I'm logged in as "genesis" + When I click "register second passphrase" in main menu + And I click "cancel button" + Then I should see no "modal dialog" + Scenario: should allow to register a delegate Given I'm logged in as "delegate candidate" When I click "register as delegate" in main menu And I fill in "test" to "username" field And I click "register button" Then I should see alert dialog with title "Success" and text "Delegate registration was successfully submitted. It can take several seconds before it is processed." + And I click "ok button" + And I wait 15 seconds + And I should see text "test" in "delegate name" element + + Scenario: should not allow to register a delegate again + Given I'm logged in as "delegate" + Then There is no "register as delegate" in main menu + + Scenario: should allow to register a delegate with second passphrase + Given I'm logged in as "second passphrase account" + When I click "register as delegate" in main menu + And I fill in "test2" to "username" field + And I fill in second passphrase of "second passphrase account" to "second passphrase" field + And I click "register button" + Then I should see alert dialog with title "Success" and text "Delegate registration was successfully submitted. It can take several seconds before it is processed." + + Scenario: should allow to exit delegate registration dialog + Given I'm logged in as "genesis" + When I click "register as delegate" in main menu + And I click "cancel button" + Then I should see no "modal dialog" + + Scenario: should not allow to register delegate if not enough funds for the fee + Given I'm logged in as "empty account" + When I click "register as delegate" in main menu + Then I should see "Insufficient funds for 25 LSK fee" error message + And "register button" should be disabled Scenario: should allow to sign message Given I'm logged in as "any account" When I click "sign message" in main menu And I fill in "Hello world" to "message" field - And I click "sign button" + And I click "primary button" Then I should see in "result" field: """ -----BEGIN LISK SIGNED MESSAGE----- @@ -32,9 +75,27 @@ Feature: Top right menu -----END LISK SIGNED MESSAGE----- """ + Scenario: should allow to exit sign message dialog with "cancel button" + Given I'm logged in as "any account" + When I click "sign message" in main menu + And I click "cancel button" + Then I should see no "modal dialog" + + Scenario: should allow to exit sign message dialog with "x button" + Given I'm logged in as "any account" + When I click "sign message" in main menu + And I click "x button" + Then I should see no "modal dialog" + Scenario: should allow to verify message Given I'm logged in as "any account" When I click "verify message" in main menu And I fill in "c094ebee7ec0c50ebee32918655e089f6e1a604b83bcaa760293c61e0f18ab6f" to "public key" field And I fill in "079331d868678fd5f272f09d6dc8792fb21335aec42af7f11caadbfbc17d4707e7d7f343854b0c619b647b81ba3f29b23edb4eaf382a47c534746bad4529560b48656c6c6f20776f726c64" to "signature" field Then I should see "Hello world" in "result" field + + Scenario: should allow to exit verify message dialog + Given I'm logged in as "any account" + When I click "verify message" in main menu + And I click "x button" + Then I should see no "modal dialog" diff --git a/features/send.feature b/features/send.feature index 82d628a87..4c99ad562 100644 --- a/features/send.feature +++ b/features/send.feature @@ -20,3 +20,26 @@ Feature: Send dialog And I fill in "1243409812409" to "recipient" field And I fill in "1" to "amount" field Then I should see "Invalid" error message + + Scenario: should allow to exit send dialog + Given I'm logged in as "any account" + When I click "send button" + And I click "cancel button" + Then I should see no "modal dialog" + + Scenario: should allow to send all funds + Given I'm logged in as "send all account" + When I click "send button" + And I fill in "537318935439898807L" to "recipient" field + And I click "send maximum amount" in "transaction amount" menu + And I click "submit button" + Then I should see alert dialog with title "Success" and text "Your transaction of 101 LSK to 537318935439898807L was accepted and will be processed in a few seconds." + + Scenario: should allow to send with second passphrase + Given I'm logged in as "second passphrase account" + When I click "send button" + And I fill in "1" to "amount" field + And I fill in "537318935439898807L" to "recipient" field + And I fill in second passphrase of "second passphrase account" to "second passphrase" field + And I click "submit button" + Then I should see alert dialog with title "Success" and text "Your transaction of 1 LSK to 537318935439898807L was accepted and will be processed in a few seconds." diff --git a/features/step_definitions/forging.step.js b/features/step_definitions/forging.step.js index 5d1076510..9de1a033a 100644 --- a/features/step_definitions/forging.step.js +++ b/features/step_definitions/forging.step.js @@ -1,10 +1,10 @@ +/* eslint-disable import/no-extraneous-dependencies */ const { defineSupportCode } = require('cucumber'); const { waitForElemAndCheckItsText } = require('../support/util.js'); - defineSupportCode(({ Then }) => { Then('I should see forging center', (callback) => { - waitForElemAndCheckItsText('forging .delegate-name', 'genesis_17', callback); - waitForElemAndCheckItsText('forging md-card.forged-blocks md-card-title .md-title', 'Forged Blocks', callback); + waitForElemAndCheckItsText('.delegate-name', 'genesis_17', callback); + waitForElemAndCheckItsText('.forged-blocks h5', 'Forged Blocks', callback); }); }); diff --git a/features/step_definitions/generic.step.js b/features/step_definitions/generic.step.js index 9f8afa850..c8d3d8a6f 100644 --- a/features/step_definitions/generic.step.js +++ b/features/step_definitions/generic.step.js @@ -1,14 +1,17 @@ +/* eslint-disable import/no-extraneous-dependencies */ const { defineSupportCode } = require('cucumber'); const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); const { waitForElemAndCheckItsText, + waitForElemRemoved, waitForElemAndClickIt, waitForElemAndSendKeys, checkAlertDialog, waitTime, } = require('../support/util.js'); const accounts = require('../support/accounts.js'); +const localStorage = require('../support/localStorage.js'); chai.use(chaiAsPromised); const expect = chai.expect; @@ -19,11 +22,23 @@ defineSupportCode(({ Given, When, Then, setDefaultTimeout }) => { When('I fill in "{value}" to "{fieldName}" field', (value, fieldName, callback) => { const selectorClass = `.${fieldName.replace(/ /g, '-')}`; - waitForElemAndSendKeys(`input${selectorClass}, textarea${selectorClass}`, value, callback); + waitForElemAndSendKeys(`${selectorClass} input, ${selectorClass} textarea`, value, callback); }); + When('I fill in second passphrase of "{accountName}" to "{fieldName}" field', (accountName, fieldName, callback) => { + const selectorClass = `.${fieldName.replace(/ /g, '-')}`; + const secondPassphrase = accounts[accountName].secondPassphrase; + browser.sleep(500); + waitForElemAndSendKeys(`${selectorClass} input, ${selectorClass} textarea`, secondPassphrase, callback); + }); + + When('I wait {seconds} seconds', (seconds, callback) => { + browser.sleep(seconds * 1000).then(callback); + }); + + Then('I should see "{value}" in "{fieldName}" field', (value, fieldName, callback) => { - const elem = element(by.css(`.${fieldName.replace(/ /g, '-')}`)); + const elem = element(by.css(`.${fieldName.replace(/ /g, '-')} input, .${fieldName.replace(/ /g, '-')} textarea`)); expect(elem.getAttribute('value')).to.eventually.equal(value) .and.notify(callback); }); @@ -34,18 +49,27 @@ defineSupportCode(({ Given, When, Then, setDefaultTimeout }) => { }); When('I click tab number {index}', (index, callback) => { - waitForElemAndClickIt(`main md-tab-item:nth-child(${index})`, callback); + waitForElemAndClickIt(`.main-tabs *:nth-child(${index})`, callback); + }); + + When('I click "{elementName}" in "{menuName}" menu', (elementName, menuName, callback) => { + waitForElemAndClickIt(`.${menuName.replace(/ /g, '-')}`); + browser.sleep(1000); + waitForElemAndClickIt(`.${elementName.replace(/ /g, '-')}`, callback); }); When('I select option no. {index} from "{selectName}" select', (index, selectName, callback) => { - waitForElemAndClickIt(`md-select.${selectName}`); - const optionElem = element.all(by.css('md-select-menu md-option')).get(index - 1); + waitForElemAndClickIt(`.${selectName}`); + browser.sleep(500); + const optionElem = element.all(by.css(`.${selectName} ul li`)).get(index - 1); browser.wait(EC.presenceOf(optionElem), waitTime); optionElem.click().then(callback); }); Then('the option "{optionText}" is selected in "{selectName}" select', (optionText, selectName, callback) => { - waitForElemAndCheckItsText(`.${selectName} md-select-value .md-text`, optionText, callback); + const elem = element(by.css(`.${selectName} input`)); + expect(elem.getAttribute('value')).to.eventually.equal(optionText) + .and.notify(callback); }); Then('I should see alert dialog with title "{title}" and text "{text}"', (title, text, callback) => { @@ -53,27 +77,44 @@ defineSupportCode(({ Given, When, Then, setDefaultTimeout }) => { }); Then('I should see table with {lineCount} lines', (lineCount, callback) => { - browser.sleep(3500); + browser.sleep(500); expect(element.all(by.css('table tbody tr')).count()).to.eventually.equal(parseInt(lineCount, 10)) .and.notify(callback); }); - Then('I should see "{elementName}"', (elementName, callback) => { - expect(element.all(by.css(`.${elementName.replace(/ /g, '-')}`)).count()).to.eventually.equal(1) - .and.notify(callback); + Then('I should see no "{elementName}"', (elementName, callback) => { + const selector = `.${elementName.replace(/ /g, '-')}`; + waitForElemRemoved(selector, () => { + expect(element.all(by.css(selector)).count()).to.eventually.equal(0) + .and.notify(callback); + }); }); Then('I should see "{text}" error message', (text, callback) => { - waitForElemAndCheckItsText('.md-input-message-animation', text, callback); + browser.sleep(500); + waitForElemAndCheckItsText('.error-message, .theme__error___2k5Jz', text, callback); + }); + + Then('"{elementName}" should be disabled', (elementName, callback) => { + expect(element(by.css(`.${elementName.replace(/ /g, '-')}`)).getAttribute('disabled')) + .to.eventually.equal('true') + .and.notify(callback); + }); + + Then('I should see text "{text}" in "{fieldName}" element', (text, fieldName, callback) => { + const selectorClass = `.${fieldName.replace(/ /g, '-')}`; + waitForElemAndCheckItsText(selectorClass, text, callback); }); Given('I\'m logged in as "{accountName}"', (accountName, callback) => { browser.ignoreSynchronization = true; browser.driver.manage().window().setSize(1000, 1000); - browser.driver.get('about:blank'); - browser.get('http://localhost:8080/#/?peerStack=localhost'); - waitForElemAndSendKeys('.passphrase', accounts[accountName].passphrase); - waitForElemAndClickIt('.md-button.md-primary.md-raised', callback); + browser.get('http://localhost:8080/'); + localStorage.setItem('address', 'http://localhost:4000'); + localStorage.setItem('network', 2); + browser.get('http://localhost:8080/'); + waitForElemAndSendKeys('.passphrase input', accounts[accountName].passphrase); + waitForElemAndClickIt('.login-button', callback); }); When('I {iterations} times move mouse randomly', (iterations, callback) => { @@ -82,9 +123,8 @@ defineSupportCode(({ Given, When, Then, setDefaultTimeout }) => { * Generates a sequence of random pairs of x,y coordinates on the screen that simulates * the movement of mouse to produce a pass phrase. */ - for (let i = 0; i < iterations; i++) { - actions - .mouseMove(element(by.css('body')), { + for (let i = 0; i < iterations; i += 1) { + actions.mouseMove(element(by.css('body')), { x: 500 + (Math.floor((((i % 2) * 2) - 1) * (249 + (Math.random() * 250)))), y: 500 + (Math.floor((((i % 2) * 2) - 1) * (249 + (Math.random() * 250)))), }); @@ -94,20 +134,20 @@ defineSupportCode(({ Given, When, Then, setDefaultTimeout }) => { }); When('I remember passphrase, click "{nextButtonSelector}", fill in missing word', (nextButtonSelector, callback) => { - waitForElemAndCheckItsText('save-passphrase h2', 'Save your passphrase in a safe place!'); + waitForElemAndCheckItsText('.passphrase label', 'Save your passphrase in a safe place!'); - element(by.css('save-passphrase textarea.passphrase')).getText().then((passphrase) => { + element(by.css('.passphrase textarea')).getText().then((passphrase) => { // eslint-disable-next-line no-unused-expressions expect(passphrase).to.not.be.undefined; const passphraseWords = passphrase.split(' '); expect(passphraseWords.length).to.equal(12); waitForElemAndClickIt(`.${nextButtonSelector.replace(/ /g, '-')}`); - element.all(by.css('save-passphrase p.passphrase span')).get(0).getText().then((firstPartOfPassphrase) => { + element.all(by.css('.passphrase-verifier p span')).get(0).getText().then((firstPartOfPassphrase) => { const missingWordIndex = firstPartOfPassphrase.length ? firstPartOfPassphrase.split(' ').length : 0; - element(by.css('save-passphrase input')).sendKeys(passphraseWords[missingWordIndex]).then(callback); + element(by.css('.passphrase-verifier input')).sendKeys(passphraseWords[missingWordIndex]).then(callback); }); }); }); diff --git a/features/step_definitions/hooks.js b/features/step_definitions/hooks.js index e41604ec8..fbe42fa4b 100644 --- a/features/step_definitions/hooks.js +++ b/features/step_definitions/hooks.js @@ -1,3 +1,4 @@ +/* eslint-disable import/no-extraneous-dependencies */ const { defineSupportCode } = require('cucumber'); const fs = require('fs'); diff --git a/features/step_definitions/login.step.js b/features/step_definitions/login.step.js index 42ee984b1..c646e4ad9 100644 --- a/features/step_definitions/login.step.js +++ b/features/step_definitions/login.step.js @@ -1,7 +1,8 @@ +/* eslint-disable import/no-extraneous-dependencies */ const { defineSupportCode } = require('cucumber'); const { waitForElemAndCheckItsText } = require('../support/util.js'); -defineSupportCode(({ Given, Then }) => { +defineSupportCode(({ Given, Then, When }) => { Given('I\'m on login page', (callback) => { browser.ignoreSynchronization = true; browser.driver.manage().window().setSize(1000, 1000); @@ -9,6 +10,10 @@ defineSupportCode(({ Given, Then }) => { browser.get('http://localhost:8080/#/?peerStack=localhost').then(callback); }); + When('I refresh the page', (callback) => { + browser.driver.navigate().refresh().then(callback); + }); + Then('I should be logged in', (callback) => { waitForElemAndCheckItsText('.logout-button', 'LOGOUT', callback); }); diff --git a/features/step_definitions/menu.step.js b/features/step_definitions/menu.step.js index 25346b76e..163ba0ca8 100644 --- a/features/step_definitions/menu.step.js +++ b/features/step_definitions/menu.step.js @@ -1,3 +1,4 @@ +/* eslint-disable import/no-extraneous-dependencies */ const { defineSupportCode } = require('cucumber'); const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); @@ -8,13 +9,20 @@ const expect = chai.expect; defineSupportCode(({ When, Then }) => { When('I click "{itemSelector}" in main menu', (itemSelector, callback) => { - waitForElemAndClickIt('header .md-icon-button'); + waitForElemAndClickIt('.main-menu-icon-button'); browser.sleep(1000); - waitForElemAndClickIt(`md-menu-item .md-button.${itemSelector.replace(/ /g, '-')}`, callback); + waitForElemAndClickIt(`.${itemSelector.replace(/ /g, '-')}`, callback); + }); + + Then('There is no "{itemSelector}" in main menu', (itemSelector, callback) => { + waitForElemAndClickIt('.main-menu-icon-button'); + browser.sleep(1000); + expect(element.all(by.css(`md-menu-item .md-button.${itemSelector.replace(/ /g, '-')}`)).count()).to.eventually.equal(0) + .and.notify(callback); }); Then('I should see in "{fieldName}" field:', (fieldName, value, callback) => { - const elem = element(by.css(`.${fieldName.replace(/ /g, '-')}`)); + const elem = element(by.css(`.${fieldName.replace(/ /g, '-')} textarea`)); expect(elem.getAttribute('value')).to.eventually.equal(value) .and.notify(callback); }); diff --git a/features/step_definitions/top.step.js b/features/step_definitions/top.step.js index 2f37b8504..9d89fe52e 100644 --- a/features/step_definitions/top.step.js +++ b/features/step_definitions/top.step.js @@ -1,3 +1,4 @@ +/* eslint-disable import/no-extraneous-dependencies */ const { defineSupportCode } = require('cucumber'); const { waitForElemAndCheckItsText } = require('../support/util.js'); diff --git a/features/step_definitions/transactions.step.js b/features/step_definitions/transactions.step.js new file mode 100644 index 000000000..cfb2fee7d --- /dev/null +++ b/features/step_definitions/transactions.step.js @@ -0,0 +1,11 @@ +/* eslint-disable import/no-extraneous-dependencies */ +const { defineSupportCode } = require('cucumber'); +const { waitForElemAndClickIt } = require('../support/util.js'); + +defineSupportCode(({ When }) => { + When('I click "{elementName}" element on table row no. {index}', (elementName, index, callback) => { + const selectorClass = `.${elementName.replace(/ /g, '-')}`; + waitForElemAndClickIt(`table tr:nth-child(${index}) ${selectorClass}`, callback); + }); +}); + diff --git a/features/step_definitions/voting.step.js b/features/step_definitions/voting.step.js index bfcbced2d..e659e74bd 100644 --- a/features/step_definitions/voting.step.js +++ b/features/step_definitions/voting.step.js @@ -1,3 +1,4 @@ +/* eslint-disable import/no-extraneous-dependencies */ const { defineSupportCode } = require('cucumber'); const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); @@ -8,18 +9,19 @@ const expect = chai.expect; defineSupportCode(({ When, Then }) => { When('I click checkbox on table row no. {index}', (index, callback) => { - waitForElemAndClickIt(`delegates tr:nth-child(${index}) md-checkbox`, callback); + waitForElemAndClickIt(`table tr:nth-child(${index}) td label`, callback); }); When('Search twice for "{searchTerm}" in vote dialog', (searchTerm, callback) => { - element.all(by.css('md-autocomplete-wrap input')).get(0).sendKeys(searchTerm); - waitForElemAndClickIt('ul.md-autocomplete-suggestions li:nth-child(1) md-autocomplete-parent-scope'); - element.all(by.css('md-autocomplete-wrap input')).get(0).sendKeys(searchTerm); - waitForElemAndClickIt('ul.md-autocomplete-suggestions li:nth-child(1) md-autocomplete-parent-scope', callback); + element.all(by.css('.votedListSearch input')).get(0).sendKeys(searchTerm); + waitForElemAndClickIt('#votedResult ul li:nth-child(1)'); + element.all(by.css('.votedListSearch input')).get(0).sendKeys(searchTerm); + browser.sleep(500); + waitForElemAndClickIt('#votedResult ul li:nth-child(1)', callback); }); Then('I should see delegates list with {count} lines', (count, callback) => { - expect(element.all(by.css('md-menu-item.vote-list-item')).count()) + expect(element.all(by.css('.my-votes-button li')).count()) .to.eventually.equal(parseInt(count, 10)) .and.notify(callback); }); diff --git a/features/support/accounts.js b/features/support/accounts.js index 524e8a6aa..530aea0ba 100644 --- a/features/support/accounts.js +++ b/features/support/accounts.js @@ -21,6 +21,15 @@ const accounts = { passphrase: 'dolphin inhale planet talk insect release maze engine guilt loan attend lawn', address: '4264113712245538326L', }, + 'send all account': { + passphrase: 'oyster flush inquiry bright leopard gas replace ball hold pudding teach swear', + address: '16422276087748907680L', + }, + 'second passphrase account': { + passphrase: 'awkward service glimpse punch genre calm grow life bullet boil match like', + secondPassphrase: 'forest around decrease farm vanish permit hotel clay senior matter endorse domain', + address: '1155682438012955434L', + }, }; accounts['any account'] = accounts.genesis; diff --git a/features/support/localStorage.js b/features/support/localStorage.js new file mode 100644 index 000000000..8347e76cb --- /dev/null +++ b/features/support/localStorage.js @@ -0,0 +1,11 @@ +const localStorage = { + setItem: (key, value) => ( + browser.executeScript(`return window.localStorage.setItem('${key}', '${value}');`) + ), + clear: () => ( + browser.executeScript('return window.localStorage.clear();') + ), +}; + +module.exports = localStorage; + diff --git a/features/support/util.js b/features/support/util.js index cd2b3cf14..ba95c7315 100644 --- a/features/support/util.js +++ b/features/support/util.js @@ -1,3 +1,4 @@ +/* eslint-disable import/no-extraneous-dependencies */ const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); @@ -13,6 +14,12 @@ function waitForElemAndCheckItsText(selector, text, callback) { .and.notify(callback || (() => {})); } +function waitForElemRemoved(selector, callback) { + const elem = element(by.css(selector)); + browser.wait(EC.not(EC.presenceOf(elem)), waitTime, + `waiting for element '${selector}' not present`).then(callback || (() => {})); +} + function waitForElemAndClickIt(selector, callback) { const elem = element(by.css(selector)); browser.wait(EC.presenceOf(elem), waitTime, `waiting for element '${selector}'`); @@ -30,14 +37,15 @@ function waitForElemAndSendKeys(selector, keys, callback) { } function checkAlertDialog(title, text, callback) { - waitForElemAndCheckItsText('md-dialog h2', title); - waitForElemAndCheckItsText('md-dialog .md-dialog-content-body', text, () => { + waitForElemAndCheckItsText('.modal-dialog h1', title); + waitForElemAndCheckItsText('.modal-dialog .modal-dialog-body', text, () => { if (callback) callback(); }); } module.exports = { waitForElemAndCheckItsText, + waitForElemRemoved, waitForElemAndClickIt, waitForElemAndSendKeys, checkAlertDialog, diff --git a/features/top.feature b/features/top.feature index 08272c2af..c80e93092 100644 --- a/features/top.feature +++ b/features/top.feature @@ -3,13 +3,16 @@ Feature: Main page top area Given I'm logged in as "any account" When I click "logout button" Then I should be on login page + Scenario: should show peer Given I'm logged in as "any account" - Then I should see "peer" + Then I should see text "localhost : 4000" in "peer" element + Scenario: should show address - Given I'm logged in as "any account" - Then I should see "address" + Given I'm logged in as "genesis" + Then I should see text "16313739661670634666L" in "address" element + Scenario: should show balance - Given I'm logged in as "any account" - Then I should see "balance" + Given I'm logged in as "empty account" + Then I should see text "0 LSK" in "balance value" element diff --git a/features/transactions.feature b/features/transactions.feature index 26dfc969d..65c4a2032 100644 --- a/features/transactions.feature +++ b/features/transactions.feature @@ -2,4 +2,25 @@ Feature: Transactions tab Scenario: should show transactions Given I'm logged in as "genesis" When I click tab number 1 - Then I should see table with 20 lines + Then I should see table with 40 lines + + Scenario: should allow send to address + Given I'm logged in as "genesis" + When I click tab number 1 + And I click "from-to" element on table row no. 1 + And I fill in "100" to "amount" field + And I click "submit button" + Then I should see alert dialog with title "Success" and text "Your transaction of 100 LSK to 537318935439898807L was accepted and will be processed in a few seconds." + + Scenario: should allow to repeat the transaction + Given I'm logged in as "genesis" + When I click tab number 1 + And I click "amount" element on table row no. 1 + And I click "submit button" + Then I should see alert dialog with title "Success" and text "Your transaction of 100 LSK to 537318935439898807L was accepted and will be processed in a few seconds." + + Scenario: should provide "No transactions" message + Given I'm logged in as "empty account" + When I click tab number 1 + Then I should see table with 0 lines + And I should see text "No transactions" in "empty message" element diff --git a/features/voting.feature b/features/voting.feature index a256116f8..5d455df0a 100644 --- a/features/voting.feature +++ b/features/voting.feature @@ -2,12 +2,12 @@ Feature: Voting tab Scenario: should allow to view delegates Given I'm logged in as "any account" When I click tab number 2 - Then I should see table with 20 lines + Then I should see table with 100 lines Scenario: should allow to view delegates with cold account Given I'm logged in as "empty account" When I click tab number 2 - Then I should see table with 20 lines + Then I should see table with 100 lines Scenario: should allow to search delegates Given I'm logged in as "any account" @@ -15,12 +15,27 @@ Feature: Voting tab And I fill in "genesis_42" to "search" field Then I should see table with 1 lines + Scenario: search delegates should provide "no results" message + Given I'm logged in as "any account" + When I click tab number 2 + And I fill in "doesntexist" to "search" field + Then I should see table with 0 lines + And I should see text "No delegates found" in "empty message" element + Scenario: should allow to view my votes Given I'm logged in as "genesis" When I click tab number 2 And I click "my votes button" Then I should see delegates list with 101 lines + Scenario: should not allow to vote if not enough funds for the fee + Given I'm logged in as "empty account" + When I click tab number 2 + And I click checkbox on table row no. 3 + And I click "vote button" + Then I should see "Insufficient funds for 1 LSK fee" error message + And "submit button" should be disabled + Scenario: should allow to select delegates in the "Voting" tab and vote for them Given I'm logged in as "delegate candidate" When I click tab number 2 @@ -31,6 +46,17 @@ Feature: Voting tab And I click "submit button" Then I should see alert dialog with title "Success" and text "Your votes were successfully submitted. It can take several seconds before they are processed." + Scenario: should allow to vote with second passphrase account + Given I'm logged in as "second passphrase account" + When I click tab number 2 + And I click checkbox on table row no. 3 + And I click checkbox on table row no. 5 + And I click checkbox on table row no. 8 + And I click "vote button" + And I fill in second passphrase of "second passphrase account" to "second passphrase" field + And I click "submit button" + Then I should see alert dialog with title "Success" and text "Your votes were successfully submitted. It can take several seconds before they are processed." + Scenario: should allow to select delegates in the "Vote" dialog and vote for them Given I'm logged in as "delegate candidate" When I click tab number 2 @@ -47,3 +73,10 @@ Feature: Voting tab And I click "vote button" And I click "submit button" Then I should see alert dialog with title "Success" and text "Your votes were successfully submitted. It can take several seconds before they are processed." + + Scenario: should allow to exit vote dialog + Given I'm logged in as "genesis" + When I click tab number 2 + And I click "vote button" + And I click "cancel button" + Then I should see no "modal dialog" diff --git a/karma.conf.js b/karma.conf.js index 60312b3bc..3a74fad11 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -1,117 +1,55 @@ -const path = require('path'); -const webpackConfig = require('./webpack.config.babel'); -// Accessing [0] because there are mutli entry points for webpack hot loader -// var entry = path.resolve(webpackConfig.entry.app, '..', '..', 'app', 'app.js'); -const preprocessors = {}; -// preprocessors[entry] = ['webpack']; -preprocessors['**/*.html'] = ['ng-html2js']; -const libs = path.join(__dirname, 'src', 'libs.js'); -const app = path.join(__dirname, 'src', 'liskNano.js'); -const testLibs = path.join(__dirname, 'test', 'libs.js'); -const test = path.join(__dirname, 'test', 'test.js'); -preprocessors[libs] = ['webpack']; -preprocessors[app] = ['webpack']; -preprocessors[testLibs] = ['webpack']; -preprocessors[test] = ['webpack']; - -const opts = { - onJenkins: process.env.ON_JENKINS, - live: process.env.LIVE, -}; - +// Karma configuration +const webpackEnv = { test: true }; +const webpackConfig = require('./webpack.config')(webpackEnv); +webpackConfig.watch = true; + +const filePattern = 'src/**/*.test.js'; +const fileRoot = 'src/tests.js'; +const onJenkins = process.env.ON_JENKINS; +process.env.BABEL_ENV = 'test'; module.exports = function (config) { config.set({ - - // Base path that will be used to resolve all patterns (eg. files, exclude) + // base path that will be used to resolve all patterns (eg. files, exclude) basePath: '', - - // Frameworks to use - // Available frameworks: https://npmjs.org/browse/keyword/karma-adapter frameworks: ['mocha', 'chai'], - - // List of files / patterns to load in the browser - files: [libs, app, testLibs, test], + files: [ + fileRoot, + { pattern: filePattern, included: false, served: false, watched: false }, + ], + preprocessors: { + '**/*.js': ['sourcemap'], + [fileRoot]: ['webpack'], + }, + reporters: ['coverage', 'mocha'], + coverageReporter: { + reporters: [ + { + type: 'json', + dir: 'coverage/', + }, + { + type: onJenkins ? 'lcov' : 'html', + dir: 'coverage/', + }, + ].concat(onJenkins ? { type: 'text' } : []), + }, webpack: webpackConfig, - webpackMiddleware: { noInfo: true, - }, - - // List of files to exclude - exclude: [], - - // Rest results reporter to use - // Possible values: 'dots', 'progress' - // Available reporters: https://npmjs.org/browse/keyword/karma-reporter - reporters: ['coverage', 'mocha'].concat(opts.onJenkins ? ['coveralls'] : []), - - preprocessors, - - babelPreprocessor: { - options: { - presets: ['es2015'], + // and use stats to turn off verbose output + stats: { + // options i.e. + chunks: false, }, }, - - // Web server port port: 9876, - - // Enable / disable colors in the output (reporters and logs) colors: true, - - // Level of logging - // - // Possible values: - // config.LOG_DISABLE - // config.LOG_ERROR - // config.LOG_WARN - // config.LOG_INFO - // config.LOG_DEBUG logLevel: config.LOG_INFO, - - // Enable / disable watching file and executing tests whenever any file changes - autoWatch: opts.live, - - ngHtml2JsPreprocessor: { - stripPrefix: 'src/app/components/', - moduleName: 'my.templates', - }, - - coverageReporter: { - reporters: [{ - type: 'text', - dir: 'coverage/', - }, { - type: opts.onJenkins ? 'lcov' : 'html', - dir: 'coverage/', - }], - }, - - // Start these browsers - // Available browser launchers: https://npmjs.org/browse/keyword/karma-launcher - browsers: ['PhantomJS'], - - // Continuous Integration mode - // If true, Karma captures browsers, runs the tests and exits - singleRun: !opts.live, - client: { - captureConsole: true, - mocha: { - opts: 'test/mocha.opts', // You can set opts to equal true then plugin will load opts from default location 'test/mocha.opts' - }, - }, - - plugins: [ - require('karma-webpack'), // eslint-disable-line import/no-extraneous-dependencies - 'karma-chai', - 'karma-mocha', - 'karma-chrome-launcher', - 'karma-ng-html2js-preprocessor', - 'karma-mocha-reporter', - 'karma-jenkins-reporter', - 'karma-coverage', - 'karma-coveralls', - 'karma-phantomjs-launcher', - ], + autoWatch: false, + browsers: ['ChromeHeadless'], + singleRun: true, + browserNoActivityTimeout: 60000, + browserDisconnectTolerance: 3, + concurrency: Infinity, }); }; diff --git a/package.json b/package.json index 9f6e03d70..a3d7a146d 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,25 @@ { "name": "lisk-nano", - "version": "1.0.2", + "version": "1.1.0", "description": "Lisk Nano", "homepage": "https://github.com/LiskHQ/lisk-nano", "bugs": "https://github.com/LiskHQ/lisk-nano/issues", "main": "main.js", "scripts": { - "build": "export NODE_ENV=prod && webpack --profile --progress --display-modules --display-exclude --display-chunks --display-cached --display-cached-assets", - "dev": "webpack-dev-server --host 0.0.0.0 --profile --progress", + "build": "npm run clean && npm run copy-files && webpack --env.prod", + "dev": "webpack-dev-server --env.dev --hot", "e2e-test": "protractor protractor.conf.js", - "test": "grunt eslint && export NODE_ENV=test && karma start", - "test-live": "export NODE_ENV=test && export LIVE=true && karma start", + "test": "karma start", + "test-live": "npm test -- --auto-watch --no-single-run", "start": "electron app", "dist:win": "build --win --ia32 --x64", "dist:mac": "build --mac", - "dist:linux": "build --linux --ia32 --x64 --armv7l" + "dist:linux": "build --linux --ia32 --x64 --armv7l", + "copy-files": "mkdir app/dist && cp -r ./src/index.html ./app/dist", + "clean": "del app/dist -f", + "eslint": "eslint ./src/ ./app/main.js ./features/", + "storybook": "start-storybook -p 6006 -s ./src/", + "build-storybook": "build-storybook" }, "author": "Lisk Foundation , lightcurve GmbH ", "license": "GPL-3.0", @@ -23,107 +28,96 @@ "url": "https://github.com/LiskHQ/lisk-nano" }, "dependencies": { - "angular": "=1.5.8", - "angular-animate": "=1.5.8", - "angular-aria": "=1.5.8", - "angular-cookies": "=1.5.8", - "angular-material": "=1.1.1", - "angular-material-data-table": "=0.10.9", - "angular-messages": "=1.5.8", - "angular-svg-round-progressbar": "=0.4.8", - "angular-ui-router": "=1.0.0-rc.1", - "babel-polyfill": "=6.9.1", "bignumber.js": "=4.0.0", "bitcore-mnemonic": "=1.1.1", - "debug": "=2.2.0", - "jasmine-spec-reporter": "=3.3.0", - "jquery": "=2.2.4", - "lisk-js": "=0.4.4", - "lodash": "=4.16.4", + "copy-to-clipboard": "=3.0.6", + "flexboxgrid": "=6.3.1", + "lisk-js": "=0.4.5", "moment": "=2.15.1", - "ng-infinite-scroll": "=1.3.0", - "ngclipboard": "=1.1.1", - "numeral": "=1.5.3" + "numeral": "=2.0.6", + "postcss": "=6.0.2", + "postcss-cssnext": "=2.11.0", + "prop-types": "=15.5.10", + "react": "=15.6.x", + "react-animate-on-change": "^1.0.0", + "react-circular-progressbar": "=0.1.5", + "react-css-themr": "=2.1.2", + "react-dom": "=15.6.x", + "react-redux": "=5.0.5", + "react-router": "=4.1.2", + "react-router-dom": "=4.1.2", + "react-toolbox": "=2.0.0-beta.12", + "react-waypoint": "=7.0.4", + "redux": "=3.6.0", + "redux-logger": "=3.0.6", + "redux-thunk": "^2.2.0" }, "devDependencies": { - "angular-mocks": "=1.5.8", - "babel-core": "=6.9.1", - "babel-loader": "=6.2.4", - "babel-plugin-istanbul": "=4.0.0", + "@storybook/addon-actions": "=3.2.0", + "@storybook/react": "=3.1.8", + "babel-core": "=6.20.0", + "babel-loader": "=7.0.0-beta.1", + "babel-plugin-istanbul": "=4.1.4", "babel-plugin-syntax-trailing-function-commas": "=6.22.0", - "babel-preset-es2015": "=6.9.0", + "babel-preset-es2015": "=6.18.0", + "babel-preset-react": "=6.16.0", + "babel-preset-stage-3": "=6.24.1", "chai": "=3.5.0", "chai-as-promised": "=6.0.0", - "clean-webpack-plugin": "=0.1.9", - "css-loader": "=0.23.1", + "chai-enzyme": "=0.6.1", + "css-loader": "=0.28.0", "cucumber": "=2.2.0", + "del-cli": "=0.2.1", "electron": "=1.6.2", "electron-builder": "=16.8.3", + "enzyme": "=2.8.2", + "eslint": "=3.19.0", "eslint-config-airbnb": "=14.1.0", - "eslint-config-google": "^0.7.1", - "eslint-loader": "^1.7.1", - "eslint-plugin-html": "^2.0.3", + "eslint-config-google": "=0.7.1", + "eslint-loader": "=1.7.1", + "eslint-plugin-html": "=2.0.3", "eslint-plugin-import": "=2.2.0", + "eslint-plugin-react": "=6.10.3", "exports-loader": "=0.6.3", - "extract-text-webpack-plugin": "=1.0.1", + "extract-text-webpack-plugin": "=2.1.2", "file-loader": "=0.9.0", - "grunt": "=1.0.1", - "grunt-eslint": "=19.0.0", - "grunt-newer": "=1.2.0", - "html-webpack-plugin": "=2.19.0", "imports-loader": "=0.6.5", - "jit-grunt": "=0.10.0", + "js-nacl": "=1.2.2", "json-loader": "=0.5.4", - "karma": "=1.4.1", - "karma-babel-preprocessor": "=6.0.1", + "karma": "=1.7.0", "karma-chai": "=0.1.0", - "karma-chrome-launcher": "=2.0.0", + "karma-chrome-launcher": "=2.2.0", "karma-coverage": "=1.1.1", - "karma-coveralls": "=1.1.2", "karma-jenkins-reporter": "0.0.2", "karma-mocha": "=1.3.0", - "karma-mocha-reporter": "=2.2.2", - "karma-ng-html2js-preprocessor": "=1.0.0", - "karma-phantomjs-launcher": "=1.0.4", + "karma-mocha-reporter": "=2.2.3", + "karma-sourcemap-loader": "=0.3.7", "karma-verbose-reporter": "=0.0.6", - "karma-webpack": "=2.0.2", - "less": "=2.7.1", - "less-loader": "=2.2.3", + "karma-webpack": "=2.0.3", "mocha": "=3.2.0", - "nyc": "=10.1.2", - "phantomjs": "=2.1.7", - "phantomjs-prebuilt": "=2.1.14", + "postcss-for": "=2.1.1", + "postcss-loader": "=2.0.6", + "postcss-partial-import": "=4.1.0", + "postcss-reporter": "=4.0.0", "protractor": "=5.1.1", "protractor-cucumber-framework": "=3.1.0", - "pug": "=2.0.0-beta11", - "pug-cli": "=1.0.0-alpha6", - "pug-loader": "=2.3.0", "raw-loader": "=0.5.1", + "react-addons-test-utils": "=15.6.0", + "react-hot-loader": "^1.3.1", + "react-test-renderer": "=15.6.1", + "redux-mock-store": "=1.2.3", "should": "=11.2.0", "sinon": "=2.0.0", "sinon-chai": "=2.8.0", - "style-loader": "=0.13.1", + "sinon-stub-promise": "^4.0.0", + "style-loader": "=0.16.1", + "stylelint": "^8.0.0", + "stylelint-config-standard": "^17.0.0", + "stylelint-webpack-plugin": "^0.9.0", "url-loader": "=0.5.7", - "webpack": "=1.13.1", + "webpack": "=2.2.1", "webpack-bundle-analyzer": "=2.4.0", - "webpack-dev-server": "=1.14.1", - "webpack-merge": "=0.14.1", - "webpack-validator": "=2.2.6" - }, - "babel": { - "presets": [ - "es2015" - ], - "plugins": [ - "syntax-trailing-function-commas" - ], - "env": { - "test": { - "plugins": [ - "istanbul" - ] - } - } + "webpack-dev-server": "=2.4.2" }, "build": { "appId": "io.lisk.nano", diff --git a/src/actions/account.js b/src/actions/account.js new file mode 100644 index 000000000..ef800538e --- /dev/null +++ b/src/actions/account.js @@ -0,0 +1,110 @@ +import actionTypes from '../constants/actions'; +import { setSecondPassphrase, send } from '../utils/api/account'; +import { registerDelegate } from '../utils/api/delegate'; +import { transactionAdded } from './transactions'; +import { errorAlertDialogDisplayed } from './dialog'; +import Fees from '../constants/fees'; +import { toRawLsk } from '../utils/lsk'; + +/** + * Trigger this action to update the account object + * while already logged in + * + * @param {Object} data - account data + * @returns {Object} - Action object + */ +export const accountUpdated = data => ({ + data, + type: actionTypes.accountUpdated, +}); + +/** + * Trigger this action to log out of the account + * while already logged in + * + * @returns {Object} - Action object + */ +export const accountLoggedOut = () => ({ + type: actionTypes.accountLoggedOut, +}); + +/** + * Trigger this action to login to an account + * The login middleware triggers this action + * + * @param {Object} data - account data + * @returns {Object} - Action object + */ +export const accountLoggedIn = data => ({ + type: actionTypes.accountLoggedIn, + data, +}); + +/** + * + */ +export const secondPassphraseRegistered = ({ activePeer, secondPassphrase, account }) => + (dispatch) => { + setSecondPassphrase(activePeer, secondPassphrase, account.publicKey, account.passphrase) + .then((data) => { + dispatch(transactionAdded({ + id: data.transactionId, + senderPublicKey: account.publicKey, + senderId: account.address, + amount: 0, + fee: Fees.setSecondPassphrase, + type: 1, + })); + }).catch((error) => { + const text = (error && error.message) ? error.message : 'An error occurred while registering your second passphrase. Please try again.'; + dispatch(errorAlertDialogDisplayed({ text })); + }); + }; + +/** + * + */ +export const delegateRegistered = ({ activePeer, account, username, secondPassphrase }) => + (dispatch) => { + registerDelegate(activePeer, username, account.passphrase, secondPassphrase) + .then((data) => { + // dispatch to add to pending transaction + dispatch(transactionAdded({ + id: data.transactionId, + senderPublicKey: account.publicKey, + senderId: account.address, + username, + amount: 0, + fee: Fees.registerDelegate, + type: 2, + })); + }) + .catch((error) => { + const text = error && error.message ? `${error.message}.` : 'An error occurred while registering as delegate.'; + const actionObj = errorAlertDialogDisplayed({ text }); + dispatch(actionObj); + }); + }; + +/** + * + */ +export const sent = ({ activePeer, account, recipientId, amount, passphrase, secondPassphrase }) => + (dispatch) => { + send(activePeer, recipientId, toRawLsk(amount), passphrase, secondPassphrase) + .then((data) => { + dispatch(transactionAdded({ + id: data.transactionId, + senderPublicKey: account.publicKey, + senderId: account.address, + recipientId, + amount: toRawLsk(amount), + fee: Fees.send, + type: 0, + })); + }) + .catch((error) => { + const text = error && error.message ? `${error.message}.` : 'An error occurred while creating the transaction.'; + dispatch(errorAlertDialogDisplayed({ text })); + }); + }; diff --git a/src/actions/account.test.js b/src/actions/account.test.js new file mode 100644 index 000000000..6e0845187 --- /dev/null +++ b/src/actions/account.test.js @@ -0,0 +1,217 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import actionTypes from '../constants/actions'; +import { accountUpdated, accountLoggedOut, + secondPassphraseRegistered, delegateRegistered, sent } from './account'; +import { transactionAdded } from './transactions'; +import { errorAlertDialogDisplayed } from './dialog'; +import * as accountApi from '../utils/api/account'; +import * as delegateApi from '../utils/api/delegate'; +import Fees from '../constants/fees'; +import { toRawLsk } from '../utils/lsk'; + +describe('actions: account', () => { + describe('accountUpdated', () => { + it('should create an action to set values to account', () => { + const data = { + passphrase: 'robust swift grocery peasant forget share enable convince deputy road keep cheap', + }; + + const expectedAction = { + data, + type: actionTypes.accountUpdated, + }; + expect(accountUpdated(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('accountLoggedOut', () => { + it('should create an action to reset the account', () => { + const expectedAction = { + type: actionTypes.accountLoggedOut, + }; + + expect(accountLoggedOut()).to.be.deep.equal(expectedAction); + }); + }); + + describe('secondPassphraseRegistered', () => { + let accountApiMock; + const data = { + activePeer: {}, + secondPassphrase: 'sample second passphrase', + account: { + publicKey: 'test_public-key', + address: 'test_address', + }, + }; + const actionFunction = secondPassphraseRegistered(data); + let dispatch; + + beforeEach(() => { + accountApiMock = sinon.stub(accountApi, 'setSecondPassphrase'); + dispatch = sinon.spy(); + }); + + afterEach(() => { + accountApiMock.restore(); + }); + + it('should create an action function', () => { + expect(typeof actionFunction).to.be.deep.equal('function'); + }); + + it('should dispatch transactionAdded action if resolved', () => { + accountApiMock.returnsPromise().resolves({ transactionId: '15626650747375562521' }); + const expectedAction = { + id: '15626650747375562521', + senderPublicKey: 'test_public-key', + senderId: 'test_address', + amount: 0, + fee: Fees.setSecondPassphrase, + type: 1, + }; + + actionFunction(dispatch); + expect(dispatch).to.have.been.calledWith(transactionAdded(expectedAction)); + }); + + it('should dispatch errorAlertDialogDisplayed action if caught', () => { + accountApiMock.returnsPromise().rejects({ message: 'sample message' }); + + actionFunction(dispatch); + const expectedAction = errorAlertDialogDisplayed({ text: 'sample message' }); + expect(dispatch).to.have.been.calledWith(expectedAction); + }); + + it('should dispatch errorAlertDialogDisplayed action if caught but no message returned', () => { + accountApiMock.returnsPromise().rejects({}); + + actionFunction(dispatch); + const expectedAction = errorAlertDialogDisplayed({ text: 'An error occurred while registering your second passphrase. Please try again.' }); + expect(dispatch).to.have.been.calledWith(expectedAction); + }); + }); + + describe('delegateRegistered', () => { + let delegateApiMock; + const data = { + activePeer: {}, + username: 'test', + secondPassphrase: null, + account: { + publicKey: 'test_public-key', + address: 'test_address', + }, + }; + const actionFunction = delegateRegistered(data); + let dispatch; + + beforeEach(() => { + delegateApiMock = sinon.stub(delegateApi, 'registerDelegate'); + dispatch = sinon.spy(); + }); + + afterEach(() => { + delegateApiMock.restore(); + }); + + it('should create an action function', () => { + expect(typeof actionFunction).to.be.deep.equal('function'); + }); + + it('should dispatch transactionAdded action if resolved', () => { + delegateApiMock.returnsPromise().resolves({ transactionId: '15626650747375562521' }); + const expectedAction = { + id: '15626650747375562521', + senderPublicKey: 'test_public-key', + senderId: 'test_address', + username: data.username, + amount: 0, + fee: Fees.registerDelegate, + type: 2, + }; + + actionFunction(dispatch); + expect(dispatch).to.have.been.calledWith(transactionAdded(expectedAction)); + }); + + it('should dispatch errorAlertDialogDisplayed action if caught', () => { + delegateApiMock.returnsPromise().rejects({ message: 'sample message' }); + + actionFunction(dispatch); + const expectedAction = errorAlertDialogDisplayed({ text: 'sample message.' }); + expect(dispatch).to.have.been.calledWith(expectedAction); + }); + + it('should dispatch errorAlertDialogDisplayed action if caught but no message returned', () => { + delegateApiMock.returnsPromise().rejects({}); + + actionFunction(dispatch); + const expectedAction = errorAlertDialogDisplayed({ text: 'An error occurred while registering as delegate.' }); + expect(dispatch).to.have.been.calledWith(expectedAction); + }); + }); + + describe('sent', () => { + let accountApiMock; + const data = { + activePeer: {}, + recipientId: '15833198055097037957L', + amount: 100, + passphrase: 'sample passphrase', + secondPassphrase: null, + account: { + publicKey: 'test_public-key', + address: 'test_address', + }, + }; + const actionFunction = sent(data); + let dispatch; + + beforeEach(() => { + accountApiMock = sinon.stub(accountApi, 'send'); + dispatch = sinon.spy(); + }); + + afterEach(() => { + accountApiMock.restore(); + }); + + it('should create an action function', () => { + expect(typeof actionFunction).to.be.deep.equal('function'); + }); + + it('should dispatch transactionAdded action if resolved', () => { + accountApiMock.returnsPromise().resolves({ transactionId: '15626650747375562521' }); + const expectedAction = { + id: '15626650747375562521', + senderPublicKey: 'test_public-key', + senderId: 'test_address', + recipientId: data.recipientId, + amount: toRawLsk(data.amount), + fee: Fees.send, + type: 0, + }; + + actionFunction(dispatch); + expect(dispatch).to.have.been.calledWith(transactionAdded(expectedAction)); + }); + + it('should dispatch errorAlertDialogDisplayed action if caught', () => { + accountApiMock.returnsPromise().rejects({ message: 'sample message' }); + + actionFunction(dispatch); + const expectedAction = errorAlertDialogDisplayed({ text: 'sample message.' }); + expect(dispatch).to.have.been.calledWith(expectedAction); + }); + + it('should dispatch errorAlertDialogDisplayed action if caught but no message returned', () => { + accountApiMock.returnsPromise().rejects({}); + + actionFunction(dispatch); + const expectedAction = errorAlertDialogDisplayed({ text: 'An error occurred while creating the transaction.' }); + expect(dispatch).to.have.been.calledWith(expectedAction); + }); + }); +}); diff --git a/src/actions/dialog.js b/src/actions/dialog.js new file mode 100644 index 000000000..cc951babb --- /dev/null +++ b/src/actions/dialog.js @@ -0,0 +1,52 @@ +import actionTypes from '../constants/actions'; +import Alert from '../components/dialog/alert'; + +/** + * An action to dispatch to display a dialog + * + */ +export const dialogDisplayed = data => ({ + data, + type: actionTypes.dialogDisplayed, +}); + +/** + * An action to dispatch to display an alert dialog + * + */ +export const alertDialogDisplayed = data => dialogDisplayed({ + title: data.title, + type: data.type, + childComponent: Alert, + childComponentProps: { + text: data.text, + }, +}); + +/** + * An action to dispatch to display a success alert dialog + * + */ +export const successAlertDialogDisplayed = data => alertDialogDisplayed({ + title: 'Success', + text: data.text, + type: 'success', +}); + +/** + * An action to dispatch to display a error alert dialog + * + */ +export const errorAlertDialogDisplayed = data => alertDialogDisplayed({ + title: 'Error', + text: data.text, + type: 'error', +}); + +/** + * An action to dispatch to hide a dialog + * + */ +export const dialogHidden = () => ({ + type: actionTypes.dialogHidden, +}); diff --git a/src/actions/dialog.test.js b/src/actions/dialog.test.js new file mode 100644 index 000000000..059f5b93c --- /dev/null +++ b/src/actions/dialog.test.js @@ -0,0 +1,101 @@ +import { expect } from 'chai'; +import actionTypes from '../constants/actions'; +import { + dialogDisplayed, + alertDialogDisplayed, + successAlertDialogDisplayed, + errorAlertDialogDisplayed, + dialogHidden, +} from './dialog'; +import Alert from '../components/dialog/alert'; + + +describe('actions: dialog', () => { + describe('dialogDisplayed', () => { + it('should create an action to show dialog', () => { + const data = { + component: 'dummy', + props: {}, + }; + + const expectedAction = { + data, + type: actionTypes.dialogDisplayed, + }; + expect(dialogDisplayed(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('alertDialogDisplayed', () => { + it('should create an action to show alert dialog', () => { + const data = { + title: 'success', + text: 'some text', + }; + + const expectedAction = { + data: { + title: data.title, + type: undefined, + childComponent: Alert, + childComponentProps: { + text: data.text, + }, + }, + type: actionTypes.dialogDisplayed, + }; + expect(alertDialogDisplayed(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('successAlertDialogDisplayed', () => { + it('should create an action to show alert dialog', () => { + const data = { + text: 'some text', + }; + + const expectedAction = { + data: { + title: 'Success', + type: 'success', + childComponent: Alert, + childComponentProps: { + text: data.text, + }, + }, + type: actionTypes.dialogDisplayed, + }; + expect(successAlertDialogDisplayed(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('errorAlertDialogDisplayed', () => { + it('should create an action to show alert dialog', () => { + const data = { + text: 'some text', + }; + + const expectedAction = { + data: { + title: 'Error', + type: 'error', + childComponent: Alert, + childComponentProps: { + text: data.text, + }, + }, + type: actionTypes.dialogDisplayed, + }; + expect(errorAlertDialogDisplayed(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('dialogHidden', () => { + it('should create an action to hide dialog', () => { + const expectedAction = { + type: actionTypes.dialogHidden, + }; + expect(dialogHidden()).to.be.deep.equal(expectedAction); + }); + }); +}); diff --git a/src/actions/forging.js b/src/actions/forging.js new file mode 100644 index 000000000..cc51457e3 --- /dev/null +++ b/src/actions/forging.js @@ -0,0 +1,28 @@ +import actionTypes from '../constants/actions'; +import { getForgedBlocks, getForgedStats } from '../utils/api/forging'; + +export const forgedBlocksUpdated = data => ({ + data, + type: actionTypes.forgedBlocksUpdated, +}); + +export const fetchAndUpdateForgedBlocks = ({ activePeer, limit, offset, generatorPublicKey }) => + (dispatch) => { + getForgedBlocks(activePeer, limit, offset, generatorPublicKey) + .then(response => + dispatch(forgedBlocksUpdated(response.blocks)), + ); + }; + +export const forgingStatsUpdated = data => ({ + data, + type: actionTypes.forgingStatsUpdated, +}); + +export const fetchAndUpdateForgedStats = ({ activePeer, key, startMoment, generatorPublicKey }) => + (dispatch) => { + getForgedStats(activePeer, startMoment, generatorPublicKey) + .then(response => + dispatch(forgingStatsUpdated({ [key]: response.forged })), + ); + }; diff --git a/src/actions/forging.test.js b/src/actions/forging.test.js new file mode 100644 index 000000000..5920defc6 --- /dev/null +++ b/src/actions/forging.test.js @@ -0,0 +1,116 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import actionTypes from '../constants/actions'; +import { forgedBlocksUpdated, forgingStatsUpdated, + fetchAndUpdateForgedBlocks, fetchAndUpdateForgedStats } from './forging'; +import * as forgingApi from '../utils/api/forging'; + +describe('actions', () => { + describe('forgedBlocksUpdated', () => { + it('should create an action to update forged blocks', () => { + const data = { + online: true, + }; + + const expectedAction = { + data, + type: actionTypes.forgedBlocksUpdated, + }; + expect(forgedBlocksUpdated(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('forgingStatsUpdated', () => { + it('should create an action to update forging stats', () => { + const data = { last7d: 1000 }; + + const expectedAction = { + data, + type: actionTypes.forgingStatsUpdated, + }; + expect(forgingStatsUpdated(data)).to.be.deep.equal(expectedAction); + }); + }); + + + describe('fetchAndUpdateForgedBlocks', () => { + let forgingApiMock; + const data = { + activePeer: {}, + limit: 20, + offset: 0, + generatorPublicKey: 'test_public-key', + }; + const actionFunction = fetchAndUpdateForgedBlocks(data); + let dispatch; + + beforeEach(() => { + forgingApiMock = sinon.stub(forgingApi, 'getForgedBlocks'); + dispatch = sinon.spy(); + }); + + afterEach(() => { + forgingApiMock.restore(); + }); + + it('should create an action function', () => { + expect(typeof actionFunction).to.be.deep.equal('function'); + }); + + it('should dispatch forgedBlocksUpdated action if resolved', () => { + forgingApiMock.returnsPromise().resolves({ blocks: 'value' }); + + actionFunction(dispatch); + expect(dispatch).to.have.been.calledWith(forgedBlocksUpdated('value')); + }); + + it.skip('should dispatch errorAlertDialogDisplayed action if caught', () => { + forgingApiMock.returnsPromise().rejects({ message: 'sample message' }); + + // actionFunction(dispatch); + // const expectedAction = errorAlertDialogDisplayed({ text: 'sample message' }); + // expect(dispatch).to.have.been.calledWith(expectedAction); + }); + }); + + describe('fetchAndUpdateForgedStats', () => { + const key = 'sample_key'; + let forgingApiMock; + const data = { + activePeer: {}, + key, + startMoment: 0, + generatorPublicKey: 'test_public-key', + }; + const actionFunction = fetchAndUpdateForgedStats(data); + let dispatch; + + beforeEach(() => { + forgingApiMock = sinon.stub(forgingApi, 'getForgedStats'); + dispatch = sinon.spy(); + }); + + afterEach(() => { + forgingApiMock.restore(); + }); + + it('should create an action function', () => { + expect(typeof actionFunction).to.be.deep.equal('function'); + }); + + it('should dispatch forgingStatsUpdated action if resolved', () => { + forgingApiMock.returnsPromise().resolves({ forged: 'value' }); + + actionFunction(dispatch); + expect(dispatch).to.have.been.calledWith(forgingStatsUpdated({ [key]: 'value' })); + }); + + it.skip('should dispatch errorAlertDialogDisplayed action if caught', () => { + forgingApiMock.returnsPromise().rejects({ message: 'sample message' }); + + // actionFunction(dispatch); + // const expectedAction = errorAlertDialogDisplayed({ text: 'sample message' }); + // expect(dispatch).to.have.been.calledWith(expectedAction); + }); + }); +}); diff --git a/src/actions/loading.js b/src/actions/loading.js new file mode 100644 index 000000000..6a948d34b --- /dev/null +++ b/src/actions/loading.js @@ -0,0 +1,19 @@ +import actionTypes from '../constants/actions'; + +/** + * An action to dispatch loadingStarted + * + */ +export const loadingStarted = data => ({ + data, + type: actionTypes.loadingStarted, +}); + +/** + * An action to dispatch loadingFinished + * + */ +export const loadingFinished = data => ({ + data, + type: actionTypes.loadingFinished, +}); diff --git a/src/actions/loding.test.js b/src/actions/loding.test.js new file mode 100644 index 000000000..ab9fa12ae --- /dev/null +++ b/src/actions/loding.test.js @@ -0,0 +1,33 @@ +import { expect } from 'chai'; +import actionTypes from '../constants/actions'; +import { + loadingStarted, + loadingFinished, +} from './loading'; + + +describe('actions: loading', () => { + describe('loadingStarted', () => { + it('should create an action to show loading bar', () => { + const data = 'test'; + + const expectedAction = { + data, + type: actionTypes.loadingStarted, + }; + expect(loadingStarted(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('loadingFinished', () => { + it('should create an action to hide loading bar', () => { + const data = 'test'; + + const expectedAction = { + data, + type: actionTypes.loadingFinished, + }; + expect(loadingFinished(data)).to.be.deep.equal(expectedAction); + }); + }); +}); diff --git a/src/actions/peers.js b/src/actions/peers.js new file mode 100644 index 000000000..72aff148d --- /dev/null +++ b/src/actions/peers.js @@ -0,0 +1,53 @@ +import Lisk from 'lisk-js'; +import actionTypes from '../constants/actions'; + +/** + * Returns required action object to set + * the given peer data as active peer + * This should be called once in login page + * + * @param {Object} data - Active peer data and the passphrase of account + * @returns {Object} Action object + */ +export const activePeerSet = (data) => { + const addHttp = (url) => { + const reg = /^(?:f|ht)tps?:\/\//i; + return reg.test(url) ? url : `http://${url}`; + }; + + const { network } = data; + let config = { }; + if (network) { + config = network; + if (network.address) { + const normalizedUrl = new URL(addHttp(network.address)); + + config.node = normalizedUrl.hostname; + config.port = normalizedUrl.port; + config.ssl = normalizedUrl.protocol === 'https'; + } + if (config.testnet === undefined && config.port !== undefined) { + config.testnet = config.port === '7000'; + } + } + + return { + data: Object.assign({ + passphrase: data.passphrase, + activePeer: Lisk.api(config), + }), + type: actionTypes.activePeerSet, + }; +}; + +/** + * Returns required action object to partially + * update the active peer + * + * @param {Object} data - Active peer data + * @returns {Object} Action object + */ +export const activePeerUpdate = data => ({ + data, + type: actionTypes.activePeerUpdate, +}); diff --git a/src/actions/peers.test.js b/src/actions/peers.test.js new file mode 100644 index 000000000..b6580859a --- /dev/null +++ b/src/actions/peers.test.js @@ -0,0 +1,87 @@ +import { expect } from 'chai'; +import { spy } from 'sinon'; +import Lisk from 'lisk-js'; +import actionTypes from '../constants/actions'; +import { activePeerSet, activePeerUpdate } from './peers'; + + +describe('actions: peers', () => { + const passphrase = 'wagon stock borrow episode laundry kitten salute link globe zero feed marble'; + + describe('activePeerUpdate', () => { + it('should create an action to update the active peer', () => { + const data = { + online: true, + }; + + const expectedAction = { + data, + type: actionTypes.activePeerUpdate, + }; + expect(activePeerUpdate(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('activePeerSet', () => { + it('creates active peer config', () => { + const data = { + passphrase, + network: { + name: 'Custom Node', + custom: true, + address: 'http://localhost:4000', + testnet: true, + nethash: '198f2b61a8eb95fbeed58b8216780b68f697f26b849acf00c8c93bb9b24f783d', + }, + }; + const actionSpy = spy(Lisk, 'api'); + activePeerSet(data); + expect(actionSpy).to.have.been.calledWith(data.network); + Lisk.api.restore(); + }); + + it('dispatch activePeerSet action also when address http missing', () => { + const data = { + passphrase, + network: { + address: 'localhost:8000', + }, + }; + const actionSpy = spy(Lisk, 'api'); + activePeerSet(data); + expect(actionSpy).to.have.been.calledWith(); + Lisk.api.restore(); + }); + + it('dispatch activePeerSet action even if network is undefined', () => { + const data = { passphrase }; + const actionSpy = spy(Lisk, 'api'); + activePeerSet(data); + expect(actionSpy).to.have.been.calledWith(); + Lisk.api.restore(); + }); + + it('dispatch activePeerSet action even if network.address is undefined', () => { + const data = { passphrase, network: {} }; + const actionSpy = spy(Lisk, 'api'); + activePeerSet(data); + expect(actionSpy).to.have.been.calledWith(); + Lisk.api.restore(); + }); + + it('should set to testnet if not defined in config but port is 7000', () => { + const network7000 = { + address: 'http://127.0.0.1:7000', + nethash: '198f2b61a8eb95fbeed58b8216780b68f697f26b849acf00c8c93bb9b24f783d', + }; + const network4000 = { + address: 'http://127.0.0.1:4000', + nethash: '198f2b61a8eb95fbeed58b8216780b68f697f26b849acf00c8c93bb9b24f783d', + }; + let actionObj = activePeerSet({ passphrase, network: network7000 }); + expect(actionObj.data.activePeer.testnet).to.be.equal(true); + actionObj = activePeerSet({ passphrase, network: network4000 }); + expect(actionObj.data.activePeer.testnet).to.be.equal(false); + }); + }); +}); diff --git a/src/actions/toaster.js b/src/actions/toaster.js new file mode 100644 index 000000000..ab332f0fb --- /dev/null +++ b/src/actions/toaster.js @@ -0,0 +1,34 @@ +import actionTypes from '../constants/actions'; + +/** + * An action to dispatch to display a toast + * + */ +export const toastDisplayed = data => ({ + data, + type: actionTypes.toastDisplayed, +}); + +/** + * An action to dispatch to display a success toast + * + */ +export const successToastDisplayed = ({ type = 'success', ...rest }) => + toastDisplayed({ type, ...rest }); + + +/** + * An action to dispatch to display an error toast + * + */ +export const errorToastDisplayed = ({ type = 'error', ...rest }) => + toastDisplayed({ type, ...rest }); + +/** + * An action to dispatch to hide a toast + * + */ +export const toastHidden = data => ({ + data, + type: actionTypes.toastHidden, +}); diff --git a/src/actions/toaster.test.js b/src/actions/toaster.test.js new file mode 100644 index 000000000..0ef0ccf44 --- /dev/null +++ b/src/actions/toaster.test.js @@ -0,0 +1,49 @@ +import { expect } from 'chai'; +import actionTypes from '../constants/actions'; +import { toastDisplayed, successToastDisplayed, errorToastDisplayed, toastHidden } from './toaster'; + +describe('actions: toaster', () => { + const data = { + label: 'dummy', + }; + + describe('toastDisplayed', () => { + it('should create an action to show toast', () => { + const expectedAction = { + data, + type: actionTypes.toastDisplayed, + }; + expect(toastDisplayed(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('successToastDisplayed', () => { + it('should create an action to show success toast', () => { + const expectedAction = { + data: { ...data, type: 'success' }, + type: actionTypes.toastDisplayed, + }; + expect(successToastDisplayed(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('errorToastDisplayed', () => { + it('should create an action to show error toast', () => { + const expectedAction = { + data: { ...data, type: 'error' }, + type: actionTypes.toastDisplayed, + }; + expect(errorToastDisplayed(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('toastHidden', () => { + it('should create an action to hide toast', () => { + const expectedAction = { + data, + type: actionTypes.toastHidden, + }; + expect(toastHidden(data)).to.be.deep.equal(expectedAction); + }); + }); +}); diff --git a/src/actions/transactions.js b/src/actions/transactions.js new file mode 100644 index 000000000..bf580304f --- /dev/null +++ b/src/actions/transactions.js @@ -0,0 +1,44 @@ +import actionTypes from '../constants/actions'; +import { transactions } from '../utils/api/account'; + +/** + * An action to dispatch transactionAdded + * + */ +export const transactionAdded = data => ({ + data, + type: actionTypes.transactionAdded, +}); + +/** + * An action to dispatch transactionsUpdated + * + */ +export const transactionsUpdated = data => ({ + data, + type: actionTypes.transactionsUpdated, +}); + +/** + * An action to dispatch transactionsLoaded + * + */ +export const transactionsLoaded = data => ({ + data, + type: actionTypes.transactionsLoaded, +}); + +/** + * + * + */ +export const transactionsRequested = ({ activePeer, address, limit, offset }) => + (dispatch) => { + transactions(activePeer, address, limit, offset) + .then((response) => { + dispatch(transactionsLoaded({ + count: parseInt(response.count, 10), + confirmed: response.transactions, + })); + }); + }; diff --git a/src/actions/transactions.test.js b/src/actions/transactions.test.js new file mode 100644 index 000000000..a4a2c0c65 --- /dev/null +++ b/src/actions/transactions.test.js @@ -0,0 +1,86 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import actionTypes from '../constants/actions'; +import { transactionAdded, transactionsUpdated, + transactionsLoaded, transactionsRequested } from './transactions'; +import * as accountApi from '../utils/api/account'; + +describe('actions: transactions', () => { + describe('transactionAdded', () => { + it('should create an action to transactionAdded', () => { + const data = { + id: 'dummy', + }; + const expectedAction = { + data, + type: actionTypes.transactionAdded, + }; + + expect(transactionAdded(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('transactionsUpdated', () => { + it('should create an action to transactionsUpdated', () => { + const data = { + id: 'dummy', + }; + const expectedAction = { + data, + type: actionTypes.transactionsUpdated, + }; + + expect(transactionsUpdated(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('transactionsLoaded', () => { + it('should create an action to transactionsLoaded', () => { + const data = { + id: 'dummy', + }; + const expectedAction = { + data, + type: actionTypes.transactionsLoaded, + }; + + expect(transactionsLoaded(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('transactionsRequested', () => { + let accountApiMock; + const data = { + activePeer: {}, + address: '15626650747375562521', + limit: 20, + offset: 0, + }; + const actionFunction = transactionsRequested(data); + let dispatch; + + beforeEach(() => { + accountApiMock = sinon.stub(accountApi, 'transactions'); + dispatch = sinon.spy(); + }); + + afterEach(() => { + accountApiMock.restore(); + }); + + it('should create an action function', () => { + expect(typeof actionFunction).to.be.deep.equal('function'); + }); + + it('should dispatch transactionAdded action if resolved', () => { + accountApiMock.returnsPromise().resolves({ transactions: [], count: '0' }); + const expectedAction = { + count: 0, + confirmed: [], + }; + + actionFunction(dispatch); + expect(dispatch).to.have.been.calledWith(transactionsLoaded(expectedAction)); + }); + }); +}); diff --git a/src/actions/voting.js b/src/actions/voting.js new file mode 100644 index 000000000..bcb4e9053 --- /dev/null +++ b/src/actions/voting.js @@ -0,0 +1,69 @@ +import actionTypes from '../constants/actions'; +import { vote } from '../utils/api/delegate'; +import { transactionAdded } from './transactions'; +import { errorAlertDialogDisplayed } from './dialog'; +import Fees from '../constants/fees'; + +/** + * Add pending variable to the list of voted delegates and list of unvoted delegates + */ +export const pendingVotesAdded = () => ({ + type: actionTypes.pendingVotesAdded, +}); + +/** + * Remove all data from the list of voted delegates and list of unvoted delegates + */ +export const clearVoteLists = () => ({ + type: actionTypes.votesCleared, +}); + +/** + * + */ +export const votePlaced = ({ activePeer, account, votedList, unvotedList, secondSecret }) => + (dispatch) => { + // Make the Api call + vote( + activePeer, + account.passphrase, + account.publicKey, + votedList, + unvotedList, + secondSecret, + ).then((response) => { + // Ad to list + dispatch(pendingVotesAdded()); + + // Add the new transaction + // @todo Handle alerts either in transactionAdded action or middleware + dispatch(transactionAdded({ + id: response.transactionId, + senderPublicKey: account.publicKey, + senderId: account.address, + amount: 0, + fee: Fees.vote, + type: 3, + })); + }) + .catch((error) => { + const text = error && error.message ? `${error.message}.` : 'An error occurred while placing your vote.'; + dispatch(errorAlertDialogDisplayed({ text })); + }); + }; + +/** + * Add data to the list of voted delegates + */ +export const addedToVoteList = data => ({ + type: actionTypes.addedToVoteList, + data, +}); + +/** + * Remove data from the list of voted delegates + */ +export const removedFromVoteList = data => ({ + type: actionTypes.removedFromVoteList, + data, +}); diff --git a/src/actions/voting.test.js b/src/actions/voting.test.js new file mode 100644 index 000000000..1b3abc430 --- /dev/null +++ b/src/actions/voting.test.js @@ -0,0 +1,123 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import actionTypes from '../constants/actions'; +import { + addedToVoteList, + removedFromVoteList, + clearVoteLists, + pendingVotesAdded, + votePlaced, +} from './voting'; +import Fees from '../constants/fees'; +import { transactionAdded } from './transactions'; +import { errorAlertDialogDisplayed } from './dialog'; +import * as delegateApi from '../utils/api/delegate'; + +describe('actions: voting', () => { + describe('addedToVoteList', () => { + it('should create an action to add data to vote list', () => { + const data = { + label: 'dummy', + }; + const expectedAction = { + data, + type: actionTypes.addedToVoteList, + }; + + expect(addedToVoteList(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('removedFromVoteList', () => { + it('should create an action to remove data from vote list', () => { + const data = { + label: 'dummy', + }; + const expectedAction = { + data, + type: actionTypes.removedFromVoteList, + }; + + expect(removedFromVoteList(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('clearVoteLists', () => { + it('should create an action to remove all pending rows from vote list', () => { + const expectedAction = { + type: actionTypes.votesCleared, + }; + expect(clearVoteLists()).to.be.deep.equal(expectedAction); + }); + }); + + describe('pendingVotesAdded', () => { + it('should create an action to remove all pending rows from vote list', () => { + const expectedAction = { + type: actionTypes.pendingVotesAdded, + }; + expect(pendingVotesAdded()).to.be.deep.equal(expectedAction); + }); + }); + + describe('votePlaced', () => { + let delegateApiMock; + const account = { + publicKey: 'test_public-key', + address: 'test_address', + }; + const activePeer = {}; + const votedList = []; + const unvotedList = []; + const secondSecret = null; + + const actionFunction = votePlaced({ + activePeer, account, votedList, unvotedList, secondSecret, + }); + let dispatch; + + beforeEach(() => { + delegateApiMock = sinon.stub(delegateApi, 'vote'); + dispatch = sinon.spy(); + }); + + afterEach(() => { + delegateApiMock.restore(); + }); + + it('should create an action function', () => { + expect(typeof actionFunction).to.be.deep.equal('function'); + }); + + it('should dispatch transactionAdded action if resolved', () => { + delegateApiMock.returnsPromise().resolves({ transactionId: '15626650747375562521' }); + const expectedAction = { + id: '15626650747375562521', + senderPublicKey: account.publicKey, + senderId: account.address, + amount: 0, + fee: Fees.vote, + type: 3, + }; + + actionFunction(dispatch); + expect(dispatch).to.have.been.calledWith(transactionAdded(expectedAction)); + }); + + it('should dispatch errorAlertDialogDisplayed action if caught', () => { + delegateApiMock.returnsPromise().rejects({ message: 'sample message' }); + + actionFunction(dispatch); + const expectedAction = errorAlertDialogDisplayed({ text: 'sample message.' }); + expect(dispatch).to.have.been.calledWith(expectedAction); + }); + + it('should dispatch errorAlertDialogDisplayed action if caught but no message returned', () => { + delegateApiMock.returnsPromise().rejects({}); + + actionFunction(dispatch); + const expectedAction = errorAlertDialogDisplayed({ text: 'An error occurred while placing your vote.' }); + expect(dispatch).to.have.been.calledWith(expectedAction); + }); + }); +}); diff --git a/src/app.js b/src/app.js deleted file mode 100644 index bb0f84976..000000000 --- a/src/app.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * The main application - * This is an Angular module to nest all the other sub-modules - * and also to apply routing. - * - * @namespace app - */ -const app = angular.module('app', [ - 'ui.router', - 'angular-svg-round-progressbar', - 'ngMessages', - 'ngMaterial', - 'ngAnimate', - 'ngCookies', - 'infinite-scroll', - 'md.data.table', - 'ngclipboard', -]); - -export default app; diff --git a/src/assets/fonts/material-design-icons/style.css b/src/assets/fonts/material-design-icons/style.css new file mode 100644 index 000000000..07c29c235 --- /dev/null +++ b/src/assets/fonts/material-design-icons/style.css @@ -0,0 +1,32 @@ +@font-face { + font-family: 'Material Icons'; + font-style: normal; + font-weight: 400; + src: url('../../assets/fonts/material-design-icons/MaterialIcons-Regular.eot'); + src: + local('Material Icons'), + local('MaterialIcons-Regular'), + url('../../assets/fonts/material-design-icons/MaterialIcons-Regular.woff2') format('woff2'), + url('../../assets/fonts/material-design-icons/MaterialIcons-Regular.woff') format('woff'), + url('../../assets/fonts/material-design-icons/MaterialIcons-Regular.ttf') format('truetype'); +} + +:global .material-icons { + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 24px; + display: inline-block; + width: 1em; + height: 1em; + line-height: 1 !important; + text-transform: none; + letter-spacing: normal; + word-wrap: normal; + white-space: nowrap; + direction: ltr; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: 'liga'; +} diff --git a/src/assets/fonts/material-design-icons/style.less b/src/assets/fonts/material-design-icons/style.less deleted file mode 100644 index 48b838949..000000000 --- a/src/assets/fonts/material-design-icons/style.less +++ /dev/null @@ -1,41 +0,0 @@ - -/* http://google.github.io/material-design-icons */ - -@font-face { - font-family: 'Material Icons'; - font-style: normal; - font-weight: 400; - src: url(MaterialIcons-Regular.eot); /* For IE6-8 */ - src: local('Material Icons'), - local('MaterialIcons-Regular'), - url(MaterialIcons-Regular.woff2) format('woff2'), - url(MaterialIcons-Regular.woff) format('woff'), - url(MaterialIcons-Regular.ttf) format('truetype'); -} - -.material-icons { - font-family: 'Material Icons'; - font-weight: normal; - font-style: normal; - font-size: 24px; /* Preferred icon size */ - display: inline-block; - width: 1em; - height: 1em; - line-height: 1; - text-transform: none; - letter-spacing: normal; - word-wrap: normal; - white-space: nowrap; - direction: ltr; - - /* Support for all WebKit browsers. */ - -webkit-font-smoothing: antialiased; - /* Support for Safari and Chrome. */ - text-rendering: optimizeLegibility; - - /* Support for Firefox. */ - -moz-osx-font-smoothing: grayscale; - - /* Support for IE. */ - font-feature-settings: 'liga'; -} diff --git a/src/assets/fonts/roboto-mono/style.css b/src/assets/fonts/roboto-mono/style.css new file mode 100644 index 000000000..1f9aa38a1 --- /dev/null +++ b/src/assets/fonts/roboto-mono/style.css @@ -0,0 +1,47 @@ +/* roboto-mono-regular - latin */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + src: url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-regular.eot'); + src: + local('Roboto Mono'), + local('RobotoMono-Regular'), + url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-regular.eot?#iefix') format('embedded-opentype'), + url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-regular.woff2') format('woff2'), + url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-regular.woff') format('woff'), + url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-regular.ttf') format('truetype'), + url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-regular.svg#RobotoMono') format('svg'); +} + +/* roboto-mono-500 - latin */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 500; + src: url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-500.eot'); + src: + local('Roboto Mono Medium'), + local('RobotoMono-Medium'), + url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-500.eot?#iefix') format('embedded-opentype'), + url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-500.woff2') format('woff2'), + url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-500.woff') format('woff'), + url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-500.ttf') format('truetype'), + url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-500.svg#RobotoMono') format('svg'); +} + +/* roboto-mono-700 - latin */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 700; + src: url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-700.eot'); + src: + local('Roboto Mono Bold'), + local('RobotoMono-Bold'), + url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-700.eot?#iefix') format('embedded-opentype'), + url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-700.woff2') format('woff2'), + url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-700.woff') format('woff'), + url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-700.ttf') format('truetype'), + url('../../assets/fonts/roboto-mono/roboto-mono-v4-latin-700.svg#RobotoMono') format('svg'); +} diff --git a/src/assets/fonts/roboto-mono/style.less b/src/assets/fonts/roboto-mono/style.less deleted file mode 100644 index 161c871b7..000000000 --- a/src/assets/fonts/roboto-mono/style.less +++ /dev/null @@ -1,42 +0,0 @@ - -/* https://google-webfonts-helper.herokuapp.com */ - -/* roboto-mono-regular - latin */ -@font-face { - font-family: 'Roboto Mono'; - font-style: normal; - font-weight: 400; - src: url('roboto-mono-v4-latin-regular.eot'); /* IE9 Compat Modes */ - src: local('Roboto Mono'), local('RobotoMono-Regular'), - url('roboto-mono-v4-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('roboto-mono-v4-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */ - url('roboto-mono-v4-latin-regular.woff') format('woff'), /* Modern Browsers */ - url('roboto-mono-v4-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */ - url('roboto-mono-v4-latin-regular.svg#RobotoMono') format('svg'); /* Legacy iOS */ -} -/* roboto-mono-500 - latin */ -@font-face { - font-family: 'Roboto Mono'; - font-style: normal; - font-weight: 500; - src: url('roboto-mono-v4-latin-500.eot'); /* IE9 Compat Modes */ - src: local('Roboto Mono Medium'), local('RobotoMono-Medium'), - url('roboto-mono-v4-latin-500.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('roboto-mono-v4-latin-500.woff2') format('woff2'), /* Super Modern Browsers */ - url('roboto-mono-v4-latin-500.woff') format('woff'), /* Modern Browsers */ - url('roboto-mono-v4-latin-500.ttf') format('truetype'), /* Safari, Android, iOS */ - url('roboto-mono-v4-latin-500.svg#RobotoMono') format('svg'); /* Legacy iOS */ -} -/* roboto-mono-700 - latin */ -@font-face { - font-family: 'Roboto Mono'; - font-style: normal; - font-weight: 700; - src: url('roboto-mono-v4-latin-700.eot'); /* IE9 Compat Modes */ - src: local('Roboto Mono Bold'), local('RobotoMono-Bold'), - url('roboto-mono-v4-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('roboto-mono-v4-latin-700.woff2') format('woff2'), /* Super Modern Browsers */ - url('roboto-mono-v4-latin-700.woff') format('woff'), /* Modern Browsers */ - url('roboto-mono-v4-latin-700.ttf') format('truetype'), /* Safari, Android, iOS */ - url('roboto-mono-v4-latin-700.svg#RobotoMono') format('svg'); /* Legacy iOS */ -} diff --git a/src/assets/fonts/roboto/style.css b/src/assets/fonts/roboto/style.css new file mode 100644 index 000000000..1677782bf --- /dev/null +++ b/src/assets/fonts/roboto/style.css @@ -0,0 +1,46 @@ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + src: url('../../assets/fonts/roboto/roboto-v15-latin-regular.eot'); + src: + local('Roboto'), + local('Roboto-Regular'), + url('../../assets/fonts/roboto/roboto-v15-latin-regular.eot?#iefix') format('embedded-opentype'), + url('../../assets/fonts/roboto/roboto-v15-latin-regular.woff2') format('woff2'), + url('../../assets/fonts/roboto/roboto-v15-latin-regular.woff') format('woff'), + url('../../assets/fonts/roboto/roboto-v15-latin-regular.ttf') format('truetype'), + url('../../assets/fonts/roboto/roboto-v15-latin-regular.svg#Roboto') format('svg'); +} + +/* roboto-500 - latin */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + src: url('../../assets/fonts/roboto/roboto-v15-latin-500.eot'); + src: + local('Roboto Medium'), + local('Roboto-Medium'), + url('../../assets/fonts/roboto/roboto-v15-latin-500.eot?#iefix') format('embedded-opentype'), + url('../../assets/fonts/roboto/roboto-v15-latin-500.woff2') format('woff2'), + url('../../assets/fonts/roboto/roboto-v15-latin-500.woff') format('woff'), + url('../../assets/fonts/roboto/roboto-v15-latin-500.ttf') format('truetype'), + url('../../assets/fonts/roboto/roboto-v15-latin-500.svg#Roboto') format('svg'); +} + +/* roboto-700 - latin */ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + src: url('../../assets/fonts/roboto/roboto-v15-latin-700.eot'); + src: + local('Roboto Bold'), + local('Roboto-Bold'), + url('../../assets/fonts/roboto/roboto-v15-latin-700.eot?#iefix') format('embedded-opentype'), + url('../../assets/fonts/roboto/roboto-v15-latin-700.woff2') format('woff2'), + url('../../assets/fonts/roboto/roboto-v15-latin-700.woff') format('woff'), + url('../../assets/fonts/roboto/roboto-v15-latin-700.ttf') format('truetype'), + url('../../assets/fonts/roboto/roboto-v15-latin-700.svg#Roboto') format('svg'); +} diff --git a/src/assets/fonts/roboto/style.less b/src/assets/fonts/roboto/style.less deleted file mode 100644 index c812e669b..000000000 --- a/src/assets/fonts/roboto/style.less +++ /dev/null @@ -1,42 +0,0 @@ - -/* https://google-webfonts-helper.herokuapp.com */ - -/* roboto-regular - latin */ -@font-face { - font-family: 'Roboto'; - font-style: normal; - font-weight: 400; - src: url('roboto-v15-latin-regular.eot'); /* IE9 Compat Modes */ - src: local('Roboto'), local('Roboto-Regular'), - url('roboto-v15-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('roboto-v15-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */ - url('roboto-v15-latin-regular.woff') format('woff'), /* Modern Browsers */ - url('roboto-v15-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */ - url('roboto-v15-latin-regular.svg#Roboto') format('svg'); /* Legacy iOS */ -} -/* roboto-500 - latin */ -@font-face { - font-family: 'Roboto'; - font-style: normal; - font-weight: 500; - src: url('roboto-v15-latin-500.eot'); /* IE9 Compat Modes */ - src: local('Roboto Medium'), local('Roboto-Medium'), - url('roboto-v15-latin-500.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('roboto-v15-latin-500.woff2') format('woff2'), /* Super Modern Browsers */ - url('roboto-v15-latin-500.woff') format('woff'), /* Modern Browsers */ - url('roboto-v15-latin-500.ttf') format('truetype'), /* Safari, Android, iOS */ - url('roboto-v15-latin-500.svg#Roboto') format('svg'); /* Legacy iOS */ -} -/* roboto-700 - latin */ -@font-face { - font-family: 'Roboto'; - font-style: normal; - font-weight: 700; - src: url('roboto-v15-latin-700.eot'); /* IE9 Compat Modes */ - src: local('Roboto Bold'), local('Roboto-Bold'), - url('roboto-v15-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('roboto-v15-latin-700.woff2') format('woff2'), /* Super Modern Browsers */ - url('roboto-v15-latin-700.woff') format('woff'), /* Modern Browsers */ - url('roboto-v15-latin-700.ttf') format('truetype'), /* Safari, Android, iOS */ - url('roboto-v15-latin-700.svg#Roboto') format('svg'); /* Legacy iOS */ -} diff --git a/src/components/account/account.css b/src/components/account/account.css new file mode 100644 index 000000000..3d696b4eb --- /dev/null +++ b/src/components/account/account.css @@ -0,0 +1,91 @@ +:root { + --online: #73cba9; + --offline: #f45d4c; +} + +.wrapper { + margin: 8px -8px 16px; +} + +:global .online { + color: var(--online); +} + +:global .offline { + color: var(--offline); +} + +.value-wrapper { + position: relative; + width: 100%; + height: 70px; + text-align: center; + background: #eee; + overflow: hidden; + + & :global .inner { + font-size: 100%; + color: #5f696e; + display: inline-block; + width: 100%; + margin: 0; + box-sizing: border-box; + position: relative; + z-index: 1; + + &.primary { + font-weight: bold; + padding: 9px; + } + + &.secondary { + font-size: 100%; + } + + &.full { + line-height: 51px; + height: 70px; + } + + &.tooltip { + position: absolute; + width: 100%; + text-align: center; + left: 0; + top: 100%; + transition: all ease 200ms; + font-size: 85% !important; + z-index: 0; + } + + &.hasTip:hover { + color: #000; + } + + &.hasTip:hover + .tooltip { + top: 45px; + } + } + + & :global .status { + position: absolute; + top: 5px; + right: 5px; + } +} + +.title { + font-size: 20px; + font-weight: 500; + letter-spacing: 0; + margin-top: 0; + margin-top: 20px; + margin-bottom: 16px; + text-align: center; +} + +@media only screen and (min-width: 48em) { + .title { + margin-top: 0; + } +} diff --git a/src/components/account/account.js b/src/components/account/account.js new file mode 100644 index 000000000..8b93bba86 --- /dev/null +++ b/src/components/account/account.js @@ -0,0 +1,75 @@ +import React from 'react'; +import grid from 'flexboxgrid/dist/flexboxgrid.css'; +import styles from './account.css'; +import Address from './address'; +import LiskAmount from '../liskAmount'; +import ClickToSend from '../clickToSend'; +import { toRawLsk } from '../../utils/lsk'; + +/** + * Contains some of the important and basic information about the account + * + * @param {object} props - include properties of component + */ +const Account = ({ + account, peers, +}) => { + const status = (peers.status && peers.status.online) ? + check : + error; + + return ( +
+
+
+
+
+
+
+
+

Peer

+
+
+
+ + {status} + +

+ {peers.data.options.name} +

+

+ {peers.data.currentPeer} + : {peers.data.port} +

+
+
+
+
+
+
+
+
+
+

Balance

+
+
+ +
+

+ LSK +

+

+ Click to send all funds +

+
+
+
+
+
+
+
+ ); +}; + +export default Account; diff --git a/src/components/account/account.test.js b/src/components/account/account.test.js new file mode 100644 index 000000000..e67406bca --- /dev/null +++ b/src/components/account/account.test.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { shallow, mount } from 'enzyme'; +import { Provider } from 'react-redux'; +import store from '../../store'; +import Account from './account'; +import ClickToSend from '../clickToSend'; + + +describe('Account', () => { + let props; + + beforeEach(() => { + props = { + onActivePeerUpdated: sinon.spy(), + peers: { + status: { + online: false, + }, + data: { + currentPeer: 'localhost', + port: 4000, + options: { + name: 'Custom Node', + }, + }, + }, + account: { + isDelegate: false, + address: '16313739661670634666L', + username: 'lisk-nano', + balance: 1e8, + }, + }; + }); + + it('should render 3 article tags', () => { + const wrapper = shallow(); + expect(wrapper.find('article')).to.have.lengthOf(3); + }); + + it('depicts being online when peers.status.online is true', () => { + props.peers.status.online = true; + const wrapper = shallow(); + const expectedValue = 'check'; + expect(wrapper.find('.material-icons').text()).to.be.equal(expectedValue); + }); + + it('should render balance with ClickToSend component', () => { + const wrapper = mount( + + ); + expect(wrapper.find('.balance').find(ClickToSend)).to.have.lengthOf(1); + }); +}); diff --git a/src/components/account/address.js b/src/components/account/address.js new file mode 100644 index 000000000..ea8dd8ab0 --- /dev/null +++ b/src/components/account/address.js @@ -0,0 +1,36 @@ +import React from 'react'; +import grid from 'flexboxgrid/dist/flexboxgrid.css'; +import styles from './account.css'; + +const Address = (props) => { + const title = props.isDelegate ? 'Delegate' : 'Address'; + const content = props.isDelegate ? + (
+

+ {props.delegate.username} +

+

+ {props.address} +

+
) + : (

+ {props.address} +

); + + return ( +
+
+
+

{title}

+
+
+
+ {content} +
+
+
+
+ ); +}; + +export default Address; diff --git a/src/components/account/address.test.js b/src/components/account/address.test.js new file mode 100644 index 000000000..bf577f452 --- /dev/null +++ b/src/components/account/address.test.js @@ -0,0 +1,42 @@ +import React from 'react'; +import { expect } from 'chai'; +import { shallow } from 'enzyme'; +import Address from './address'; + +describe('Address', () => { + it('when value of "isDelegate" is "false" expect text of "h3#firstBox" to be equal "Address"', () => { + const inputValue = { + isDelegate: false, + address: '16313739661670634666L', + }; + const expectedHeaderValue = 'Address'; + const wrapper = shallow(
); + expect(wrapper.find('#firstBox').text()).to.be.equal(expectedHeaderValue); + }); + + it('when value of "isDelegate" is "true" expect text of "h3#firstBox" to be equal "Delegate"', () => { + const inputValue = { + isDelegate: true, + address: '16313739661670634666L', + delegate: { + username: 'lisk-nano', + }, + }; + const expectedHeaderValue = 'Delegate'; + const wrapper = shallow(
); + expect(wrapper.find('#firstBox').text()).to.be.equal(expectedHeaderValue); + }); + + it('when value of "isDelegate" is "true" expect text of "p.secondary" to be equal expectedValue', () => { + const inputValue = { + isDelegate: true, + address: '16313739661670634666L', + delegate: { + username: 'lisk-nano', + }, + }; + const expectedValue = 'lisk-nano'; + const wrapper = shallow(
); + expect(wrapper.find('p.primary').text()).to.be.equal(expectedValue); + }); +}); diff --git a/src/components/account/index.js b/src/components/account/index.js new file mode 100644 index 000000000..624f56a65 --- /dev/null +++ b/src/components/account/index.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; +import Account from './account'; + +/** + * Passing state + */ +const mapStateToProps = state => ({ + peers: state.peers, + account: state.account, +}); + +export default connect( + mapStateToProps, +)(Account); diff --git a/src/components/account/index.test.js b/src/components/account/index.test.js new file mode 100644 index 000000000..56a6b0431 --- /dev/null +++ b/src/components/account/index.test.js @@ -0,0 +1,49 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import AccountHOC from './index'; + +describe('Account HOC', () => { + // Mocking store + const peers = { + status: { + online: false, + }, + data: { + currentPeer: 'localhost', + port: 4000, + options: { + name: 'Custom Node', + }, + }, + }; + const account = { + isDelegate: false, + address: '16313739661670634666L', + username: 'lisk-nano', + }; + + const store = { + dispatch: () => {}, + subscribe: () => {}, + getState: () => ({ + peers, + account, + }), + }; + const options = { + context: { store }, + // childContextTypes: { store: PropTypes.object.isRequired }, + }; + let props; + + beforeEach(() => { + const mountedAccount = mount(, options); + props = mountedAccount.find('Account').props(); + }); + + it('should mount AccountComponent with appropriate properties', () => { + expect(props.peers).to.be.equal(peers); + expect(props.account).to.be.equal(account); + }); +}); diff --git a/src/components/account/stories.js b/src/components/account/stories.js new file mode 100644 index 000000000..374c02e78 --- /dev/null +++ b/src/components/account/stories.js @@ -0,0 +1,50 @@ +import React from 'react'; +import { Provider } from 'react-redux'; + +import { storiesOf } from '@storybook/react'; +import Account from './account'; +import Address from './address'; +import store from '../../store'; + +storiesOf('Account', module) + .add('delegate', () => ( + + + + )); + +storiesOf('Address', module) + .add('delegate', () => ( +
+ )) + .add('non-delegate', () => ( +
+ )); diff --git a/src/components/actionBar/actionBar.css b/src/components/actionBar/actionBar.css new file mode 100644 index 000000000..971dbe800 --- /dev/null +++ b/src/components/actionBar/actionBar.css @@ -0,0 +1,3 @@ +.wrapper { + margin: 0; +} diff --git a/src/components/actionBar/index.js b/src/components/actionBar/index.js new file mode 100644 index 000000000..7b255ad22 --- /dev/null +++ b/src/components/actionBar/index.js @@ -0,0 +1,28 @@ +import React from 'react'; +import Button from 'react-toolbox/lib/button'; +import grid from 'flexboxgrid/dist/flexboxgrid.css'; +import PricedButton from '../pricedButton'; +import styles from './actionBar.css'; + +const ActionBar = ({ + secondaryButton, primaryButton, account, +}) => ( +
+
+); + +export default ActionBar; diff --git a/src/components/actionBar/index.test.js b/src/components/actionBar/index.test.js new file mode 100644 index 000000000..8250fc570 --- /dev/null +++ b/src/components/actionBar/index.test.js @@ -0,0 +1,55 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import sinon from 'sinon'; +import { Provider } from 'react-redux'; +import ActionBar from './index'; +import store from '../../store'; +// import * as accountApi from '../../utils/api/account'; + + +describe('ActionBar', () => { + let wrapper; + let props; + + beforeEach(() => { + props = { + secondaryButton: { + label: 'Test cancel', + onClick: sinon.spy(), + }, + primaryButton: { + label: 'Test confirm', + disabled: false, + onClick: sinon.spy(), + }, + }; + wrapper = mount(); + }); + + it('renders two Button components', () => { + expect(wrapper.find('Button')).to.have.length(2); + }); + + it('binds props.secondaryButton.label to first button label', () => { + expect(wrapper.find('Button').at(0).props().label).to.equal(props.secondaryButton.label); + }); + + it('binds props.primaryButton.label to second button label', () => { + expect(wrapper.find('Button').at(1).props().label).to.equal(props.primaryButton.label); + }); + + it('binds props.primaryButton.disabled to second button disabled', () => { + expect(wrapper.find('Button').at(1).props().disabled).to.equal(props.primaryButton.disabled); + }); + + it('binds props.secondaryButton.onClick to first button onClick', () => { + wrapper.find('Button').at(0).simulate('click'); + expect(props.secondaryButton.onClick).to.have.been.calledWith(); + }); + + it('binds props.primaryButton.onClick to second button onClick', () => { + wrapper.find('Button').at(1).simulate('click'); + expect(props.primaryButton.onClick).to.have.been.calledWith(); + }); +}); diff --git a/src/components/app/app.css b/src/components/app/app.css new file mode 100644 index 000000000..ba7d106a8 --- /dev/null +++ b/src/components/app/app.css @@ -0,0 +1,53 @@ +@import '../../assets/fonts/roboto/style.css'; +@import '../../assets/fonts/roboto-mono/style.css'; +@import '../../assets/fonts/material-design-icons/style.css'; + +body { + margin: 0; + padding: 0; + width: 100%; + background-color: #eee; +} + +.body-wrapper { + flex: 1 1 80%; + max-width: 100%; + max-height: 100%; + box-sizing: border-box; + margin: 0 auto; + font-family: roboto; +} + +.hasMarginBottom { + margin-bottom: 20px; +} + +.text-center { + text-align: center; +} + +:global .material-icons { + font-size: 24px !important; +} + +:global .box { + width: 100%; + display: flex; + flex-direction: column; + box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.2), 0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 2px 1px -1px rgba(0, 0, 0, 0.12); + background-color: #fff; + padding: 16px; + box-sizing: border-box; + + &.noPaddingBox { + padding: 16px 0; + } +} + +:global .hasPaddingRow { + padding: 0 16px; +} + +:global .verticalScroll { + overflow-x: auto; +} diff --git a/src/components/app/index.js b/src/components/app/index.js new file mode 100644 index 000000000..a5dade165 --- /dev/null +++ b/src/components/app/index.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; +import grid from 'flexboxgrid/dist/flexboxgrid.css'; +import PrivateRoutes from '../privateRoute'; +import Account from '../account'; +import Header from '../header'; +import Login from '../login'; +import Transactions from '../transactions'; +import Voting from '../voting'; +import Forging from '../forging'; +import styles from './app.css'; +import Dialog from '../dialog'; +import Toaster from '../toaster'; +import Tabs from '../tabs'; +import LoadingBar from '../loadingBar'; +import OfflineWrapper from '../offlineWrapper'; +import offlineStyle from '../offlineWrapper/offlineWrapper.css'; + +const App = () => ( + +
+
+
+
+ ( +
+ + + + + +
+ )} /> + +
+ + + +
+
+
+); + +export default App; diff --git a/src/components/app/index.test.js b/src/components/app/index.test.js new file mode 100644 index 000000000..ab864a635 --- /dev/null +++ b/src/components/app/index.test.js @@ -0,0 +1,88 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { MemoryRouter } from 'react-router'; +import { Provider } from 'react-redux'; +import { expect } from 'chai'; +import configureStore from 'redux-mock-store'; +import App from './'; +import Login from '../login'; +import Transactions from '../transactions'; +import Voting from '../voting'; +import Forging from '../forging'; + +const fakeStore = configureStore(); + +const addRouter = Component => (props, path) => + mount( + + + + + , + ); + +const publicComponent = [ + { route: '/', component: Login }, +]; + +const privateComponent = [ + { route: '/main/transactions', component: Transactions }, + { route: '/main/voting', component: Voting }, + { route: '/main/forging', component: Forging }, +]; + +describe('App', () => { + const navigateTo = addRouter(App); + describe('renders correct routes', () => { + const store = fakeStore({ + account: {}, + dialog: {}, + peers: {}, + }); + publicComponent.forEach(({ route, component }) => { + it(`should render ${component.name} component at "${route}" route`, () => { + const wrapper = navigateTo({ store }, [route]); + expect(wrapper.find(component).exists()).to.be.equal(true); + }); + }); + + privateComponent.forEach(({ route, component }) => { + it(`should redirect from ${component.name} component if user is not authenticated`, () => { + const wrapper = navigateTo({ store }, [route]); + expect(wrapper.find(component).exists()).to.be.equal(false); + expect(wrapper.find(Login).exists()).to.be.equal(true); + }); + }); + }); + + // These tests are skipped because App component use many components and all of them need + // specific data to render. Each time you will add new components to App, this tests can be fall. + // Need solution for these kinds of tests. + describe.skip('allow to render private components after logged in', () => { + const store = fakeStore({ + account: { + publicKey: '000', + }, + forging: { + statics: {}, + }, + dialog: {}, + peers: { + status: { + online: true, + }, + data: { + options: { + name: 'Test', + }, + }, + }, + }); + privateComponent.forEach(({ route, component }) => { + it(`should reder ${component.name} component at "${route}" route if user is authenticated`, () => { + const wrapper = navigateTo({ store }, [route]); + expect(wrapper.find(component).exists()).to.be.equal(true); + }); + }); + }); +}); diff --git a/src/components/clickToSend/clickToSend.css b/src/components/clickToSend/clickToSend.css new file mode 100644 index 000000000..cd9df37a3 --- /dev/null +++ b/src/components/clickToSend/clickToSend.css @@ -0,0 +1,3 @@ +.clickable { + cursor: pointer; +} diff --git a/src/components/clickToSend/clickToSend.js b/src/components/clickToSend/clickToSend.js new file mode 100644 index 000000000..8af47ac73 --- /dev/null +++ b/src/components/clickToSend/clickToSend.js @@ -0,0 +1,22 @@ +import React from 'react'; +import styles from './clickToSend.css'; +import Send from '../send'; +import { fromRawLsk } from '../../utils/lsk'; + +const ClickToSend = props => ( + props.disabled ? + props.children : + (props.setActiveDialog({ + title: 'Send', + childComponent: Send, + childComponentProps: { + amount: props.rawAmount ? fromRawLsk(props.rawAmount) : props.amount, + recipient: props.recipient, + }, + }))}> + {props.children} + +); + +export default ClickToSend; diff --git a/src/components/clickToSend/clickToSend.test.js b/src/components/clickToSend/clickToSend.test.js new file mode 100644 index 000000000..01014c36b --- /dev/null +++ b/src/components/clickToSend/clickToSend.test.js @@ -0,0 +1,42 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import sinon from 'sinon'; +import ClickToSend from './clickToSend'; + +const Dummy = () => (); + +describe('ClickToSend', () => { + let setActiveDialog; + + beforeEach(() => { + setActiveDialog = sinon.spy(); + }); + + it('allows open send modal with pre-filled address ', () => { + const wrapper = mount( + ); + wrapper.simulate('click'); + expect(setActiveDialog).to.have.been.calledWith(); + expect(wrapper.find('Dummy')).to.have.length(1); + }); + + it('allows open send modal with pre-filled rawAmount ', () => { + const wrapper = mount( + ); + wrapper.simulate('click'); + expect(setActiveDialog).to.have.been.calledWith(); + expect(wrapper.find('Dummy')).to.have.length(1); + }); + + it('should do nothing if props.disabled', () => { + const wrapper = mount( + + ); + wrapper.simulate('click'); + expect(wrapper.find('Dummy')).to.have.length(1); + }); +}); diff --git a/src/components/clickToSend/index.js b/src/components/clickToSend/index.js new file mode 100644 index 000000000..6c2858577 --- /dev/null +++ b/src/components/clickToSend/index.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux'; +import { dialogDisplayed } from '../../actions/dialog'; +import ClickToSend from './clickToSend'; + +const mapDispatchToProps = dispatch => ({ + setActiveDialog: data => dispatch(dialogDisplayed(data)), +}); + +export default connect( + null, + mapDispatchToProps, +)(ClickToSend); diff --git a/src/components/clickToSend/index.test.js b/src/components/clickToSend/index.test.js new file mode 100644 index 000000000..8a44cf1d9 --- /dev/null +++ b/src/components/clickToSend/index.test.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import { Provider } from 'react-redux'; +import sinon from 'sinon'; +import * as dialogActions from '../../actions/dialog'; +import ClickToSendHOC from './index'; +import store from '../../store'; + + +describe('ClickToSendHOC', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + it('should render ClickToSend', () => { + expect(wrapper.find('ClickToSend')).to.have.lengthOf(1); + }); + + it('should bind dialogDisplayed action to ClickToSend props.setActiveDialog', () => { + const actionsSpy = sinon.spy(dialogActions, 'dialogDisplayed'); + wrapper.find('ClickToSend').props().setActiveDialog({}); + expect(actionsSpy).to.be.calledWith(); + actionsSpy.restore(); + }); +}); diff --git a/src/components/delegateRegistration/delegateRegistration.js b/src/components/delegateRegistration/delegateRegistration.js deleted file mode 100644 index 0a3c086e8..000000000 --- a/src/components/delegateRegistration/delegateRegistration.js +++ /dev/null @@ -1,86 +0,0 @@ -import './delegateRegistration.less'; - -/** - * @description The directive performing as the form to register the account as delegate - * - * @class app.delegateRegistration - * @memberOf app - */ -app.component('delegateRegistration', { - template: require('./delegateRegistration.pug')(), - bindings: { - closeDialog: '&', - }, - controller($scope, delegateApi, Account, dialog, $rootScope) { - $scope.account = Account; - - function checkPendingRegistration() { - delegateApi.getDelegate({ - username: $scope.username, - }).then((data) => { - Account.set({ - isDelegate: true, - username: data.delegate.username, - delegate: data.delegate, - }); - $scope.pendingRegistrationListener(); - }); - } - - $scope.form = { - name: '', - fee: 25, - error: '', - onSubmit: (form) => { - if (form.$valid) { - $scope.username = $scope.form.name.toLowerCase(); - delegateApi.registerDelegate( - $scope.username, - Account.get().passphrase, - $scope.form.secondPassphrase, - ) - .then(() => { - dialog.successAlert({ - text: 'Delegate registration was successfully submitted. It can take several seconds before it is processed.', - }) - .then(() => { - $scope.pendingRegistrationListener = $rootScope.$on('syncTick', () => { - checkPendingRegistration(); - }); - $scope.reset(form); - this.closeDialog(); - }); - }) - .catch((error) => { - $scope.form.error = error.message ? error.message : ''; - }); - } - }, - }; - - /** - * Resets the from fields and form state. - * - * @method reset - * @param {Object} from - The form event object. containing form elements and errors list. - */ - $scope.reset = (form) => { - $scope.form.name = ''; - $scope.form.error = ''; - - form.$setPristine(); - form.$setUntouched(); - }; - - /** - * hides the dialog and resets form. - * - * @method cancel - * @param {Object} from - The form event object. containing form elements and errors list. - */ - $scope.cancel = (form) => { - $scope.reset(form); - this.closeDialog(); - }; - }, -}); diff --git a/src/components/delegateRegistration/delegateRegistration.less b/src/components/delegateRegistration/delegateRegistration.less deleted file mode 100644 index 652261dd5..000000000 --- a/src/components/delegateRegistration/delegateRegistration.less +++ /dev/null @@ -1,31 +0,0 @@ -.dialog-delegate-registration { - background: transparent; - box-shadow: none; - - & > md-card { - box-shadow: - 0px 4px 6px -4px rgba(0, 0, 0, 0.2), - 0px 8px 10px 2px rgba(0, 0, 0, 0.14), - 0px 3px 12px 4px rgba(0, 0, 0, 0.12); - } - - input { - text-transform: lowercase; - } - - p.error { - font-size:.8em; - width: 100%; - text-align: center; - color: rgb(221,44,0); - } - - md-divider { - margin: 0 -24px; - clear: both; - } - - .info-icon-wrapper { - margin: 24px 24px 0 0; - } -} diff --git a/src/components/delegateRegistration/delegateRegistration.pug b/src/components/delegateRegistration/delegateRegistration.pug deleted file mode 100644 index 3361e155c..000000000 --- a/src/components/delegateRegistration/delegateRegistration.pug +++ /dev/null @@ -1,32 +0,0 @@ -div.dialog-delegate-registration(aria-label='Vote for delegates') - form(name='delegateRegistrationForm', ng-submit='form.onSubmit(delegateRegistrationForm)') - md-toolbar - .md-toolbar-tools - h2 Register as delegate - span(flex='') - md-button.md-icon-button(ng-click='cancel(delegateRegistrationForm)', aria-label='Close dialog') - i.material-icons close - md-dialog-content - .md-dialog-content - div - md-input-container.md-block - label Delegate name - input.username(type='text', name='delegateName', ng-model='form.name', required, ng-disabled='loading', md-autofocus) - div(ng-messages='delegateRegistrationForm.name.$error') - div(ng-message='required') Required - md-input-container.md-block(ng-if='account.get().secondSignature') - label Second Passphrase - input(type='password', ng-model='form.secondPassphrase', required) - md-divider - div(layout='row') - p.info-icon-wrapper - i.material-icons info - p - span Becoming a delegate requires registration. You may choose your own delegate name, which can be used to promote your delegate. Only the top 101 delegates are eligible to forge. All fees are shared equally between the top 101 delegates. - md-divider - p.error(ng-bind='form.error', ng-if='form.error') - md-dialog-actions(layout='row') - md-button.md-secondary(ng-disabled='loading', ng-click='cancel(delegateRegistrationForm)') {{ 'Cancel' }} - span(flex) - fee(data-fee='form.fee') - md-button.md-raised.md-primary.register-button(ng-disabled='!delegateRegistrationForm.$valid || loading || form.fee | fundsInsufficiency', type='submit') {{ loading ? 'Registering...' : 'Register' }} diff --git a/src/components/delegates/delegates.js b/src/components/delegates/delegates.js deleted file mode 100644 index ad1eebb1f..000000000 --- a/src/components/delegates/delegates.js +++ /dev/null @@ -1,283 +0,0 @@ -import './delegates.less'; - -const UPDATE_INTERVAL = 10000; - -/** - * The delegates tab component - * - * @module app - * @submodule delegates - */ -app.component('delegates', { - template: require('./delegates.pug')(), - bindings: { - account: '=', - passphrase: '<', - }, - /** - * The delegates tab component constructor class - * - * @class delegates - * @constructor - */ - controller: class delegates { - constructor($scope, $rootScope, Peers, dialog, $mdMedia, - $timeout, delegateApi, Account) { - this.$scope = $scope; - this.$rootScope = $rootScope; - this.peers = Peers; - this.delegateApi = delegateApi; - this.dialog = dialog; - this.$mdMedia = $mdMedia; - this.$timeout = $timeout; - this.account = Account; - - this.$scope.search = ''; - this.voteList = []; - this.votedDict = {}; - this.delegateStateByAddress = {}; - this.votedList = []; - this.unvoteList = []; - this.loading = true; - this.$scope.$emit('showLoadingBar'); - this.usernameInput = ''; - this.usernameSeparator = '\n'; - - this.updateAll(); - - this.$scope.$watch('search', (search, oldValue) => { - this.delegatesDisplayedCount = 20; - if (search || oldValue) { - this.loadDelegates(0, search, true); - } - }); - - this.$scope.$on('peerUpdate', () => { - this.updateAll(); - }); - } - - /** - * Updates the lists of delegates and voted delegates - * - * @method updateAll - */ - updateAll() { - this.delegates = []; - this.delegatesDisplayedCount = 20; - if (this.peers.active) { - this.delegateApi.listAccountDelegates(this.account.get().address, - ).then((data) => { - this.votedList = data.delegates || []; - this.votedList.forEach((delegate) => { - this.votedDict[delegate.username] = delegate; - }); - }).finally(() => { - this.loadDelegates(0, this.$scope.search); - }); - } - } - - /** - * Fetches a list of delegates based on the given search phrase - * - * @method loadDelegates - * @param {Number} offset - The starting index of for the results - * @param {String} search - The search phrase to match with the delegate name - * @param {Boolean} replace - Passed to addDelegates, defines if the results - * should replace the old delegates list - * @param {Number} limit - The maximum number of results - */ - loadDelegates(offset, search, replace, limit = 100) { - this.loading = true; - this.$scope.$emit('showLoadingBar'); - this.delegateApi.listDelegates({ - offset, - limit: limit.toString(), - q: search, - }).then((data) => { - this.addDelegates(data, replace); - }); - this.lastSearch = search; - } - - /** - * Fills the list of delegates, sets their voted and changed status - * - * @method addDelegates - * @param {Object} data - The result of delegateApi.listDelegates Api call - * @param {Boolean} replace - defines if the results should replace - * the old delegates list - */ - addDelegates(data, replace) { - if (data.success) { - if (replace) { - this.delegates = data.delegates; - } else { - this.delegates = this.delegates.concat(data.delegates); - } - - this.delegates = this.delegates.map(delegate => this.setDelegateStatus(delegate)); - - this.delegatesTotalCount = data.totalCount; - this.loading = false; - this.$scope.$emit('hideLoadingBar'); - } - } - - /** - * Needs summary - * - * @method showMore - */ - showMore() { - if (this.delegatesDisplayedCount < this.delegates.length) { - this.delegatesDisplayedCount += 20; - } - if (this.delegates.length - this.delegatesDisplayedCount <= 20 && - this.delegates.length < this.delegatesTotalCount && - !this.loading) { - this.loadDelegates(this.delegates.length, this.$scope.search); - } - } - - /** - * Needs summary - * - * @method selectionChange - * @param {any} delegate - */ - selectionChange(delegate) { - // eslint-disable-next-line no-param-reassign - delegate.status.changed = delegate.status.voted !== delegate.status.selected; - const list = delegate.status.voted ? this.unvoteList : this.voteList; - if (delegate.status.changed) { - list.push(delegate); - } else { - list.splice(list.indexOf(delegate), 1); - } - } - - /** - * Needs summary - * - * @method clearSearch - */ - clearSearch() { - this.$scope.search = ''; - } - - /** - * Adds delegates to vote delegates list - * - * @method addToUnvoteList - * @param {Object} vote - The delegate to add to voted delegates list - */ - addToUnvoteList(vote) { - const delegate = this.delegates.filter(d => d.username === vote.username)[0] || vote; - if (delegate.status.selected) { - this.unvoteList.push(delegate); - } - delegate.status.selected = false; - } - - /** - * Needs summary - * - * @method setPendingVotes - */ - setPendingVotes() { - this.voteList.forEach((delegate) => { - /* eslint-disable no-param-reassign */ - delegate = this.setDelegateStatus(delegate); - delegate.status.changed = false; - delegate.status.voted = true; - delegate.status.pending = true; - }); - this.votePendingList = this.voteList.splice(0, this.voteList.length); - - this.unvoteList.forEach((delegate) => { - delegate = this.setDelegateStatus(delegate); - delegate.status.changed = false; - delegate.status.voted = false; - delegate.status.pending = true; - /* eslint-enable no-param-reassign */ - }); - this.unvotePendingList = this.unvoteList.splice(0, this.unvoteList.length); - this.checkPendingVotes(); - } - - /** - * Sets deleagte.status to be always the same object for given delegate.address - * - * @method setDelegateStatus - */ - setDelegateStatus(delegate) { - const voted = this.votedDict[delegate.username] !== undefined; - const changed = this.voteList.concat(this.unvoteList) - .map(d => d.username).indexOf(delegate.username) !== -1; - this.delegateStateByAddress[delegate.address] = - this.delegateStateByAddress[delegate.address] || { - selected: (voted && !changed) || (!voted && changed), - voted, - changed, - }; - delegate.status = this.delegateStateByAddress[delegate.address]; - return delegate; - } - - /** - * Fetches the list of delegates we've voted for (voted delegates), - * and updates the list and removes the confirmed votes from votePendingList - * - * @method checkPendingVotes - * @todo Use Sync service and remove recursive timeout - */ - checkPendingVotes() { - this.$timeout(() => { - this.delegateApi.listAccountDelegates(this.account.get().address, - ).then((data) => { - this.votedList = data.delegates || []; - this.votedDict = {}; - (this.votedList).forEach((delegate) => { - this.votedDict[delegate.username] = delegate; - }); - this.votePendingList = this.votePendingList.filter((vote) => { - if (this.votedDict[vote.username]) { - // eslint-disable-next-line no-param-reassign - vote.status.pending = false; - return false; - } - return true; - }); - this.unvotePendingList = this.unvotePendingList.filter((vote) => { - if (!this.votedDict[vote.username]) { - // eslint-disable-next-line no-param-reassign - vote.status.pending = false; - return false; - } - return true; - }); - if (this.votePendingList.length + this.unvotePendingList.length > 0) { - this.checkPendingVotes(); - } - }); - }, UPDATE_INTERVAL); - } - - /** - * Uses dialog.modal to show vote list directive. - * - * @method openVoteDialog - */ - openVoteDialog() { - this.dialog.modal('vote', { - 'vote-list': this.voteList, - 'unvote-list': this.unvoteList, - }).then((() => { - this.setPendingVotes(); - })); - } - }, -}); - diff --git a/src/components/delegates/delegates.less b/src/components/delegates/delegates.less deleted file mode 100644 index cd2b96fe5..000000000 --- a/src/components/delegates/delegates.less +++ /dev/null @@ -1,100 +0,0 @@ -delegates { - .pull-right { - float: right; - } - - .right-action-buttons { - margin: -8px 0; - } - - button { - margin: -10px; - } - - i { - vertical-align: inherit; - margin-left: 8px; - margin-right: 4px; - } - - .green-link { - color: #7cb342; - } - .red-link { - color: #c62828; - } - .remove-votes-link { - margin-right: -2px; - } - - .upvote { - background-color: rgb(226, 238, 213); - } - .downvote { - background-color: rgb(255, 228, 220); - } - .voted{ - background-color: #d6f0ff; - } - .pending { - background-color: #eaeae9; - } - - md-card-title { - md-input-container { - margin: 0; - padding: 0; - } - .md-errors-spacer { - min-height: 0; - } - } - - .md-title.search { - font-size: 1em; - font-weight: normal; - md-input-container { - width: 232px; - } - } - - .search-append { - color: #aaa; - cursor: pointer; - margin-left: -30px; - z-index: 10; - } - - .label { - font-weight: bold; - background-color: #5f696e; - color: #fff; - border-radius: 3px; - padding: 4px 9px; - white-space: nowrap; - } - - .status { - line-height: 2em; - } - - .filter-select md-select{ - display: inline-block; - margin: 0 10px - } - - .spinner { - margin-left: -10px; - } -} - -.lsk-vote-remove-button { - float: right; - position: absolute; - right: 4px; - top: 4px; - - .material-icons { - vertical-align: inherit; - } -} diff --git a/src/components/delegates/delegates.pug b/src/components/delegates/delegates.pug deleted file mode 100644 index 30ff574bb..000000000 --- a/src/components/delegates/delegates.pug +++ /dev/null @@ -1,56 +0,0 @@ -div.offline-hide - md-card(flex-gt-xs=100) - md-card-title - md-card-title-text - span.md-title.search(layout='row') - md-input-container.md-block - label Search - input.search(type='text', name='name', ng-model='search', ng-model-options='{ debounce: 200 }') - i.material-icons.search-append(ng-click='$ctrl.clearSearch()', ng-if='search') close - i.material-icons.search-append(ng-hide='search') search - span.pull-right.right-action-buttons - md-menu.pull-right.right-action-buttons - md-button.pull-right.my-votes-button(ng-click='$mdOpenMenu()', ng-disabled='$ctrl.votedList.length == 0') - i.material-icons visibility - span My votes ({{$ctrl.votedList.length}}) - md-menu-content(width='4') - md-menu-item.vote-list-item(ng-repeat='(username, delegate) in $ctrl.votedDict') - md-button(ng-click='$ctrl.addToUnvoteList(delegate)') - div - span(ng-bind='username') - md-button.md-icon-button.lsk-vote-remove-button(ng-click='$ctrl.unselect(username)') - i.material-icons close - span.pull-right.right-action-buttons - md-button.vote-button(ng-click='$ctrl.openVoteDialog()') - i.material-icons done - span Vote - span(ng-if='$ctrl.voteList.length || $ctrl.unvoteList.length') - span ( - span.green-link(ng-if='$ctrl.voteList.length') +{{$ctrl.voteList.length}} - span(ng-if='$ctrl.voteList.length && $ctrl.unvoteList.length') / - span.red-link(ng-if='$ctrl.unvoteList.length') -{{$ctrl.unvoteList.length}} - span ) - md-content(layout='column') - md-table-container - table(md-table) - thead(md-head) - tr(md-row) - th(md-column) Vote - th(md-column) Rank - th(md-column) Name - th(md-column) Lisk Address - th(md-column) Uptime - th(md-column) Approval - tbody(md-body, infinite-scroll='$ctrl.showMore()', infinite-scroll-distance='1') - tr(md-row, ng-if='!$ctrl.filteredDelegates.length && !$ctrl.loading') - td(md-cell, colspan='6') No delegates found - tr(md-row, ng-repeat="delegate in ($ctrl.filteredDelegates = ($ctrl.delegates | filter : {username: search} )) | limitTo : $ctrl.delegatesDisplayedCount", ng-class='{"downvote": delegate.status.voted && !delegate.status.selected, "upvote": !delegate.status.voted && delegate.status.selected, "pending": delegate.status.pending, "voted": delegate.status.voted && delegate.status.selected}') - td(md-cell) - spinner(ng-show='delegate.status.pending') - md-checkbox.md-primary(ng-show='!delegate.status.pending', ng-model='delegate.status.selected', ng-change='$ctrl.selectionChange(delegate)', aria-label='delegate selected for voting') - td(md-cell, ng-bind='delegate.rank') - td(md-cell, ng-bind='delegate.username') - td(md-cell, ng-bind='delegate.address') - td(md-cell, ng-bind='delegate.productivity + "%"') - td(md-cell, ng-bind='delegate.approval + "%"') - md-button.more(ng-show='$ctrl.delegatesDisplayedCount < $ctrl.filteredDelegates.length', ng-click='$ctrl.showMore()') Show More diff --git a/src/components/delegates/vote.js b/src/components/delegates/vote.js deleted file mode 100644 index aa3e6a7e2..000000000 --- a/src/components/delegates/vote.js +++ /dev/null @@ -1,93 +0,0 @@ -import './vote.less'; - -/** - * The vote dialog component - * - * @module app - * @submodule vote - */ -app.component('vote', { - template: require('./vote.pug')(), - bindings: { - voteList: '=', - unvoteList: '=', - }, - /** - * The vote dialog component constructor class - * - * @class vote - * @constructor - */ - controller: class vote { - constructor($scope, $mdDialog, dialog, delegateApi, $rootScope, Account, lsk) { - this.$mdDialog = $mdDialog; - this.dialog = dialog; - this.delegateApi = delegateApi; - this.$rootScope = $rootScope; - this.account = Account; - this.lsk = lsk; - - this.votedDict = {}; - this.votedList = []; - - this.getDelegates(); - this.fee = 1; - } - - /** - * Needs summary - * - * @method getDelegates - */ - getDelegates() { - this.delegateApi.listAccountDelegates(this.account.get().address, - ).then((data) => { - this.votedList = data.delegates || []; - this.votedList.forEach((delegate) => { - this.votedDict[delegate.username] = delegate; - }); - }); - } - - /** - * for an existing voteList and unvoteList it calls delegateApi.vote - * to update vote list. Shows a toast on each state change. - * - * @method vote - */ - vote() { - this.votingInProgress = true; - this.delegateApi.vote( - this.account.get().passphrase, - this.account.get().publicKey, - this.voteList, - this.unvoteList, - this.secondPassphrase, - ).then(() => { - this.$mdDialog.hide(this.voteList, this.unvoteList); - this.dialog.successAlert({ - text: 'Your votes were successfully submitted. It can take several seconds before they are processed.', - }); - }).catch((response) => { - this.dialog.errorToast(response.message || 'Voting failed'); - }).finally(() => { - this.votingInProgress = false; - }); - } - - /** - * Checks for validity of votes list. used to enable/disable submit button. - * - * @method canVote - * @returns {Boolean} Is the vote form valid? - */ - canVote() { - const totalVotes = this.voteList.length + this.unvoteList.length; - return totalVotes > 0 && totalVotes <= 33 && - !this.votingInProgress && - (!this.account.get().secondSignature || this.secondPassphrase) && - this.lsk.normalize(this.account.get().balance) > this.fee; - } - }, -}); - diff --git a/src/components/delegates/vote.less b/src/components/delegates/vote.less deleted file mode 100644 index ab7603361..000000000 --- a/src/components/delegates/vote.less +++ /dev/null @@ -1,19 +0,0 @@ -.dialog-vote { - .info-icon-wrapper { - margin: 24px 24px 0 0; - } - - h4 { - margin-bottom: 0; - } - - md-divider { - margin: 0 -24px; - clear: both; - } - - .pull-right { - float: right; - } - -} diff --git a/src/components/delegates/vote.pug b/src/components/delegates/vote.pug deleted file mode 100644 index 5fe2af287..000000000 --- a/src/components/delegates/vote.pug +++ /dev/null @@ -1,41 +0,0 @@ -div.dialog-vote(aria-label='Vote for delegates') - form - md-toolbar - .md-toolbar-tools - h2 Vote for delegates - span(flex='') - md-button.md-icon-button(ng-click='$ctrl.$mdDialog.cancel()', aria-label='Close dialog') - i.material-icons close - md-dialog-content - .md-dialog-content - div - h4 Add vote to - md-chips(ng-model='$ctrl.voteList', md-require-match='true', md-max-chips='33', md-autocomplete-snap) - md-chip-template {{$chip.username}} - md-autocomplete(flex, required, md-input-minlength='2', md-no-cache='false', md-selected-item='$ctrl.selectedVoteDelegate', md-search-text='$ctrl.voteSearchText', md-items='delegate in $ctrl.delegateApi.voteAutocomplete($ctrl.voteSearchText, $ctrl.votedDict)', md-item-text='delegate.username', md-require-match, placeholder='Search by username') - span(md-highlight-text='$ctrl.voteSearchText') {{delegate.username}} - div - h4 Remove vote from - md-chips(ng-model='$ctrl.unvoteList', md-require-match='true', md-max-chips='33') - md-chip-template {{$chip.username}} - md-autocomplete(flex, required, md-input-minlength='2', md-no-cache='false', md-selected-item='$ctrl.selectedUnvoteDelegate', md-search-text='$ctrl.unvoteSearchText', md-items='delegate in $ctrl.delegateApi.unvoteAutocomplete($ctrl.unvoteSearchText, $ctrl.votedList)', md-item-text='delegate.username', md-require-match, placeholder='Search by username') - span(md-highlight-text='$ctrl.unvoteSearchText') {{delegate.username}} - md-input-container.md-block(ng-if='$ctrl.account.get().secondSignature') - label Second Passphrase - input(type='password', ng-model='$ctrl.secondPassphrase') - br - br - md-divider - div(layout='row') - p.info-icon-wrapper - i.material-icons info - p - span You can select up to 33 delegates in one voting turn. - br - span You can vote for up to 101 delegates in total. - md-divider - md-dialog-actions(layout='row') - md-button(ng-click="$ctrl.$mdDialog.cancel()") Cancel - span(flex) - fee(data-fee='$ctrl.fee') - md-button.md-primary.md-raised.submit-button(ng-disabled='!$ctrl.canVote()', ng-click="$ctrl.vote()") {{$ctrl.votingInProgress ? 'Voting...' : 'Confirm'}} diff --git a/src/components/dialog/alert.js b/src/components/dialog/alert.js new file mode 100644 index 000000000..bcb76bd43 --- /dev/null +++ b/src/components/dialog/alert.js @@ -0,0 +1,17 @@ +import React from 'react'; +import Button from 'react-toolbox/lib/button'; +import grid from 'flexboxgrid/dist/flexboxgrid.css'; + + +const Alert = props => ( +
+

{props.text}

+
+
+ +
+
+); + +export default Alert; diff --git a/src/components/dialog/alert.test.js b/src/components/dialog/alert.test.js new file mode 100644 index 000000000..3e382b2b8 --- /dev/null +++ b/src/components/dialog/alert.test.js @@ -0,0 +1,30 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import sinon from 'sinon'; +import Alert from './alert'; + + +describe('Alert', () => { + let wrapper; + let closeSpy; + const text = 'some random text'; + + beforeEach(() => { + closeSpy = sinon.spy(); + wrapper = mount(); + }); + + it('renders paragraph with props.text', () => { + expect(wrapper.find('p').text()).to.equal(text); + }); + + it('renders "Ok" Button', () => { + expect(wrapper.find('Button').text()).to.equal('Ok'); + }); + + it('renders "Ok" Button that calls props.closeDialog on click', () => { + wrapper.find('Button').simulate('click'); + expect(closeSpy).to.have.been.calledWith(); + }); +}); diff --git a/src/components/dialog/dialog.css b/src/components/dialog/dialog.css new file mode 100644 index 000000000..24a78f789 --- /dev/null +++ b/src/components/dialog/dialog.css @@ -0,0 +1,47 @@ +.dialog { + & .x-button { + right: -20px; + + & span { + color: rgba(255, 255, 255, 0.87); + } + } + + & p { + color: rgba(0, 0, 0, 0.87); + line-height: 1.4em; + font-size: 1em; + } + + & header { + margin: -24px; + margin-bottom: 24px; + border-radius: 2px 2px 0 0; + color: rgba(255, 255, 255, 0.87); + + & h1 { + color: rgba(255, 255, 255, 0.87); + font-weight: normal; + } + } + + & hr { + border-bottom-width: 0; + margin: 16px -24px; + border-color: rgba(0, 0, 0, 0.12); + } +} + +@media screen and (min-width: 960px) { + .fullscreen { + width: 75vw; /* stylelint-disable-line */ + } +} + +.error { + background-color: #c62828; +} + +.success { + background-color: #7cb342; +} diff --git a/src/components/dialog/dialog.js b/src/components/dialog/dialog.js new file mode 100644 index 000000000..045c556af --- /dev/null +++ b/src/components/dialog/dialog.js @@ -0,0 +1,50 @@ +import React, { Component } from 'react'; +import Dialog from 'react-toolbox/lib/dialog'; +import Navigation from 'react-toolbox/lib/navigation'; +import AppBar from 'react-toolbox/lib/app_bar'; +import { IconButton } from 'react-toolbox/lib/button'; +import styles from './dialog.css'; + + +class DialogElement extends Component { + constructor() { + super(); + this.state = {}; + } + + closeDialog() { + setTimeout(() => { + this.props.onCancelClick(); + this.setState({ hidden: false }); + }, 500); + this.setState({ hidden: true }); + } + + render() { + return ( + +
+ + + + + +
+ {this.props.dialog.childComponent ? + : + null + } +
+
+
+ ); + } +} + +export default DialogElement; diff --git a/src/components/dialog/dialog.test.js b/src/components/dialog/dialog.test.js new file mode 100644 index 000000000..ba48d4308 --- /dev/null +++ b/src/components/dialog/dialog.test.js @@ -0,0 +1,45 @@ +import React from 'react'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { shallow } from 'enzyme'; +import { Dialog as ReactToolboxDialog } from 'react-toolbox/lib/dialog'; +import Dialog from './dialog'; + +describe('Dialog', () => { + let wrapper; + const Dummy = props => (
DUMMY {props.name}
); + const dialogProps = { + title: 'Original title', + childComponentProps: { + name: 'Original name', + }, + childComponent: Dummy, + }; + + beforeEach(() => { + wrapper = shallow( {}}/>); + }); + + it('renders component from react-toolbox', () => { + expect(wrapper.find(ReactToolboxDialog)).to.have.length(1); + }); + + it('renders component passed in props.dialog.childComponent', () => { + wrapper = shallow( {}}/>); + expect(wrapper.find(Dummy)).to.have.length(1); + }); + + it('does not render a child component if none passed in props.dialog.childComponent', () => { + wrapper = shallow(); + expect(wrapper.find(Dummy)).to.have.length(0); + }); + + it('allows to close the dialog', () => { + const clock = sinon.useFakeTimers(); + wrapper.find('.x-button').simulate('click'); + expect(wrapper.state('hidden')).to.equal(true); + clock.tick(510); + expect(wrapper.state('hidden')).to.equal(false); + clock.restore(); + }); +}); diff --git a/src/components/dialog/index.js b/src/components/dialog/index.js new file mode 100644 index 000000000..df5d3ff08 --- /dev/null +++ b/src/components/dialog/index.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import { dialogHidden } from '../../actions/dialog'; +import Dialog from './dialog'; + +const mapStateToProps = state => ({ + dialog: state.dialog, +}); + +const mapDispatchToProps = dispatch => ({ + onCancelClick: () => dispatch(dialogHidden()), +}); + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(Dialog); diff --git a/src/components/dialog/stories.js b/src/components/dialog/stories.js new file mode 100644 index 000000000..daeee8803 --- /dev/null +++ b/src/components/dialog/stories.js @@ -0,0 +1,45 @@ +import React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import Dialog from './dialog'; +import Alert from './alert'; + +const dialogContent = () => (
Hello
); + +storiesOf('Dialog', module) + .add('default', () => ( + + )) + .add('Success alert', () => ( + + )) + .add('Error alert', () => ( + + )); diff --git a/src/components/fee/fee.js b/src/components/fee/fee.js deleted file mode 100644 index 4ebc8b13e..000000000 --- a/src/components/fee/fee.js +++ /dev/null @@ -1,29 +0,0 @@ -import './fee.less'; - -/** - * The fee component - * - * @module app - * @submodule fee - */ -app.component('fee', { - template: '{{$ctrl.text}}', - bindings: { - fee: '<', - }, - controller: class fee { - constructor($scope, Account, lsk, $element) { - this.account = Account; - const insufficientFunds = lsk.normalize(this.account.get().balance) < this.fee; - - this.text = insufficientFunds ? - `Not enough LSK to pay ${this.fee} LSK fee` : - `Fee: ${this.fee} LSK`; - - if (insufficientFunds) { - $element.addClass('error-message'); - } - } - }, -}); - diff --git a/src/components/fee/fee.less b/src/components/fee/fee.less deleted file mode 100644 index 380919e58..000000000 --- a/src/components/fee/fee.less +++ /dev/null @@ -1,13 +0,0 @@ -fee { - font-size: 12px; - line-height: 14px; - color: grey; - display: block; - text-align: right; - margin: 0 16px; - transition: all 0.3s cubic-bezier(0.55, 0, 0.55, 0.2); -} - -fee.error-message { - color: #dd2c00; -} diff --git a/src/components/forging/circularProgressbar.css b/src/components/forging/circularProgressbar.css new file mode 100644 index 000000000..d1a8ad827 --- /dev/null +++ b/src/components/forging/circularProgressbar.css @@ -0,0 +1,24 @@ +:global .CircularProgressbar { + /* + * This fixes an issue where the CircularProgressbar svg has + * 0 width inside a "display: flex" container, and thus not visible. + * + * If you're not using "display: flex", you can remove this style. + */ + width: 100%; +} + +:global .CircularProgressbar .CircularProgressbar-path { + stroke: rgb(2, 136, 209); + transition: stroke-dashoffset 500ms ease 0ms; +} + +:global .CircularProgressbar .CircularProgressbar-trail { + stroke: #d6d6d6; +} + +:global .CircularProgressbar .CircularProgressbar-text { + font-size: 20px; + dominant-baseline: middle; + text-anchor: middle; +} diff --git a/src/components/forging/delegateStats.js b/src/components/forging/delegateStats.js new file mode 100644 index 000000000..230180ed6 --- /dev/null +++ b/src/components/forging/delegateStats.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { Card, CardText } from 'react-toolbox/lib/card'; +import CircularProgressbar from 'react-circular-progressbar'; +import grid from 'flexboxgrid/dist/flexboxgrid.css'; +import style from './forging.css'; + +const identity = x => (x); +const addPercentSign = x => (`${x}%`); + +const progressCircleCardList = [ + { + key: 'rate', + label: 'Rank', + percentageTransform: percentage => (Math.max(0, 101 - percentage)), + textForPercentage: identity, + }, { + key: 'productivity', + label: 'Productivity', + percentageTransform: identity, + textForPercentage: addPercentSign, + }, { + key: 'approval', + label: 'Approval', + percentageTransform: identity, + textForPercentage: addPercentSign, + }, +]; + +const DelegateStats = props => ( +
+ {progressCircleCardList.map(cardItem => ( +
+ + +
+
+
{cardItem.label}
+ +
+
+
+
+
+ ))} +
+); + +export default DelegateStats; diff --git a/src/components/forging/delegateStats.test.js b/src/components/forging/delegateStats.test.js new file mode 100644 index 000000000..f079e7f37 --- /dev/null +++ b/src/components/forging/delegateStats.test.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import DelegateStats from './delegateStats'; + + +describe('DelegateStats', () => { + const delegate = { + username: 'genesis_17', + rate: 19, + approval: 30, + productivity: 99.2, + }; + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + it('should render 3 Card components', () => { + expect(wrapper.find('Card')).to.have.lengthOf(3); + }); + + it('should render 3 CircularProgressbar components', () => { + expect(wrapper.find('svg.CircularProgressbar')).to.have.lengthOf(3); + }); +}); diff --git a/src/components/forging/forgedBlocks.js b/src/components/forging/forgedBlocks.js new file mode 100644 index 000000000..dbcb2e7de --- /dev/null +++ b/src/components/forging/forgedBlocks.js @@ -0,0 +1,42 @@ +import React from 'react'; +import { Card, CardTitle } from 'react-toolbox/lib/card'; +import { Table, TableHead, TableRow, TableCell } from 'react-toolbox/lib/table'; +import grid from 'flexboxgrid/dist/flexboxgrid.css'; +import { TooltipTime } from '../timestamp'; +import LiskAmount from '../liskAmount'; +import FormattedNumber from '../formattedNumber'; +import style from './forging.css'; + + +const ForgedBlocks = props => ( + + + Forged Blocks + + { props.forgedBlocks.length ? +
+ + + Block height + Block Id + Timestamp + Total fee + Reward + + {props.forgedBlocks.map((block, idx) => ( + + + {block.id} + + + + + ))} +
+
: +

You have not forged any blocks yet.

+ } +
+); + +export default ForgedBlocks; diff --git a/src/components/forging/forgedBlocks.test.js b/src/components/forging/forgedBlocks.test.js new file mode 100644 index 000000000..6959f0bea --- /dev/null +++ b/src/components/forging/forgedBlocks.test.js @@ -0,0 +1,50 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import ForgedBlocks from './forgedBlocks'; + + +describe('ForgedBlocks', () => { + const forgedBlocks = [{ + id: '16113150790072764126', + timestamp: 36280810, + height: 29394, + totalFee: 0, + reward: 0, + }, + { + id: '13838471839278892195', + version: 0, + timestamp: 36280700, + height: 29383, + totalFee: 0, + reward: 0, + }, + { + id: '5654150596698663763', + version: 0, + timestamp: 36279700, + height: 29283, + totalFee: 0, + reward: 0, + }, + ]; + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + it('should render 1 Table component', () => { + expect(wrapper.find('Table')).to.have.lengthOf(1); + }); + + it('should render TableHead component with 5 TableCell componenets', () => { + expect(wrapper.find('TableHead')).to.have.lengthOf(1); + expect(wrapper.find('TableHead').find('TableCell')).to.have.lengthOf(5); + }); + + it('should render 3 TableRow components', () => { + expect(wrapper.find('TableRow')).to.have.lengthOf(3); + }); +}); diff --git a/src/components/forging/forging.css b/src/components/forging/forging.css new file mode 100644 index 000000000..85a872491 --- /dev/null +++ b/src/components/forging/forging.css @@ -0,0 +1,21 @@ +@import './circularProgressbar.css'; + +.delegateName { + margin: 0; + font-weight: normal; +} + +.grayCard { + background: #f7f8f9; +} + +.forgedBlocksTableWrapper { + margin: 0 -8px; + overflow-x: auto; +} + +.circularProgressTitle { + text-align: center; + padding-bottom: 16px; + width: 100%; +} diff --git a/src/components/forging/forging.js b/src/components/forging/forging.js index 68f4f7be7..b9bf75fcc 100644 --- a/src/components/forging/forging.js +++ b/src/components/forging/forging.js @@ -1,120 +1,63 @@ -import moment from 'moment'; -import './forging.less'; - -const UPDATE_INTERVAL = 20000; - -/** - * The forging tab component - * - * @module app - * @submodule forging - */ -app.component('forging', { - template: require('./forging.pug')(), - /** - * The forging tab component constructor class - * - * @class forging - * @constructor - */ - controller: class forging { - constructor($scope, $timeout, forgingApi, Account) { - this.$scope = $scope; - this.$timeout = $timeout; - this.forgingApi = forgingApi; - this.account = Account; - - this.statistics = {}; - this.blocks = []; - - if (Account.get().publicKey) this.updateAllData(); - this.$scope.$on('accountChange', this.updateAllData.bind(this)); - } - - /** - * @todo This should be removed after using SyncService - */ - $onDestroy() { - this.$timeout.cancel(this.timeout); - } - - /** - * Needs summary - * - * @method updateAllData - */ - updateAllData() { - this.delegate = this.account.get().delegate || {}; - this.updateForgedBlocks(20, 0, true); - - this.updateForgingStats('last24h', moment().subtract(1, 'days')); - this.updateForgingStats('last7d', moment().subtract(7, 'days')); - this.updateForgingStats('last30d', moment().subtract(30, 'days')); - this.updateForgingStats('last365d', moment().subtract(365, 'days')); - this.updateForgingStats('total', moment('2016-04-24 17:00')); - } - - /** - * Call forgingApi to fetch forged blocks considering the given limit and offset - * If offset is not defined and the fetched and existing lists aren't identical, - * it'll unshift assuming we're fetching new forged blocks - * - * @method updateForgedBlocks - * @param {Number} limit - * @param {Number} offset - * @param {Bool} showLoadingBar - */ - updateForgedBlocks(limit, offset, showLoadingBar) { - this.$timeout.cancel(this.timeout); - if (showLoadingBar) { - this.$scope.$emit('showLoadingBar'); +import React from 'react'; +import { Card } from 'react-toolbox/lib/card'; +import Waypoint from 'react-waypoint'; +import ForgingTitle from './forgingTitle'; +import DelegateStats from './delegateStats'; +import ForgingStats from './forgingStats'; +import ForgedBlocks from './forgedBlocks'; + +const Forging = ({ + account, statistics, forgedBlocks, peers, onForgedBlocksLoaded, onForgingStatsUpdated, +}) => { + const loadStats = (key, startMoment) => { + onForgingStatsUpdated({ + activePeer: peers.data, + key, + startMoment, + generatorPublicKey: account.publicKey, + }); + }; + + const loadForgedBlocks = (limit, offset) => { + onForgedBlocksLoaded({ + activePeer: peers.data, + limit, + offset, + generatorPublicKey: account.publicKey, + }); + }; + + + return ( + + {account && account.isDelegate ? +
+ +
+ +
+ +
+ + loadForgedBlocks(20, forgedBlocks.length) } /> +
: + null } - - this.forgingApi.getForgedBlocks(limit, offset).then((data) => { - if (this.blocks.length === 0) { - this.blocks = data.blocks; - } else if (offset) { - Array.prototype.push.apply(this.blocks, data.blocks); - } else if (this.blocks[0] && data.blocks[0] && this.blocks[0].id !== data.blocks[0].id) { - Array.prototype.unshift.apply(this.blocks, - data.blocks.filter(block => block.timestamp > this.blocks[0].timestamp)); - } - this.blocksLoaded = true; - this.moreBlocksExist = this.blocks.length < data.count; - }).finally(() => { - this.$scope.$emit('hideLoadingBar'); - /** - * @todo Replace this with SyncService - */ - this.timeout = this.$timeout(this.updateAllData.bind(this), UPDATE_INTERVAL); - }); - } - - /** - * Fetches older blocks using updateForgedBlocks. - * - * @method loadMoreBlocks - * @todo Replace loader with a loader service - */ - loadMoreBlocks() { - if (this.blocksLoaded && this.blocks.length !== 0 && this.moreBlocksExist) { - this.blocksLoaded = false; - this.updateForgedBlocks(20, this.blocks.length, true); + {account && account.delegate && !account.isDelegate ? +

+ You need to become a delegate to start forging. + If you already registered to become a delegate, + your registration hasn't been processed, yet. +

: + null } - } +
+ ); +}; - /** - * Uses forgingApi to update forging statistics - * - * @method updateForgingStats - * @param {String} key The key to categorize forged blocks stats. - * presently one of today, last24h, last7d, last30d, total. - * @param {Object} startMoment The moment.js date object - */ - updateForgingStats(key, startMoment) { - this.forgingApi.getForgedStats(startMoment).then((data) => { - this.statistics[key] = data.forged; - }); - } - }, -}); +export default Forging; diff --git a/src/components/forging/forging.less b/src/components/forging/forging.less deleted file mode 100644 index 45b23bc4f..000000000 --- a/src/components/forging/forging.less +++ /dev/null @@ -1,33 +0,0 @@ -forging { - md-card md-card { - background: #f7f8f9; - } - md-card-content { - padding: 0 0 5px; - } - - md-card-title md-menu { - margin: -8px -14px; - } - - .pull-right { - float: right; - } - - .progress-label { - right: auto; - left: auto; - position: absolute; - font-size: 2em; - margin-top: 15px; - padding: 70px 0; - width: 30%; - text-align: center; - } - - @media(max-width: 600px) { - .progress-label { - width: 94%; - } - } -} diff --git a/src/components/forging/forging.pug b/src/components/forging/forging.pug deleted file mode 100644 index fe50954ae..000000000 --- a/src/components/forging/forging.pug +++ /dev/null @@ -1,80 +0,0 @@ -md-card.offline-hide - div - md-content(ng-if='!$ctrl.account.get().isDelegate') - div(layout='row') - md-card(flex-100, flex-gt-xs=100, layout-align='center center', layout-padding) - span.title You need to become a delegate to start forging. If you already registered to become a delegate, your registration hasn't been processed, yet. - md-content(ng-if='$ctrl.account.get().isDelegate') - div(layout='column', layout-gt-xs='row') - md-card(flex-gt-xs=100, layout-padding) - md-card-title - md-card-title-text - span.md-title.delegate-name {{$ctrl.delegate.username}} - span(md-position-mode='target-right target') - span {{$ctrl.statistics.total | lsk | number:2 }} LSK Earned - md-content(layout='column', layout-gt-xs='row') - md-card(flex-50, flex-gt-xs=25, layout-padding) - .info-panel.info-panel-grey - span.title Last 24 hours - span.pull-right {{$ctrl.statistics.last24h | lsk | number:2 }} LSK - md-card(flex-50, flex-gt-xs=25, layout-padding) - .info-panel.info-panel-grey - span.title {{'Last 7 days'}} - span.pull-right {{$ctrl.statistics.last7d | lsk | number:2 }} LSK - md-card(flex-50, flex-gt-xs=25, layout-padding) - .info-panel.info-panel-grey - span.title {{'Last 30 days'}} - span.pull-right {{$ctrl.statistics.last30d | lsk | number:2 }} LSK - md-card(flex-50, flex-gt-xs=25, layout-padding) - .info-panel.info-panel-grey - span.title {{'Last 365 days'}} - span.pull-right {{$ctrl.statistics.last365d | lsk | number:2 }} LSK - div(layout='column', layout-gt-xs='row') - md-card(flex-gt-xs=33, layout-align='center center', layout-padding) - div Rank - div.progress-label {{$ctrl.delegate.rate}} - round-progress(max='101', current='101 - $ctrl.delegate.rate', color='#0288D1') - md-card(flex-gt-xs=33, layout-align='center center', layout-padding) - div Productivity - div.progress-label {{$ctrl.delegate.productivity}}% - round-progress(max='100', current='$ctrl.delegate.productivity', color='#0288D1') - md-card(flex-gt-xs=33, layout-align='center center', layout-padding) - div Approval - div.progress-label {{$ctrl.delegate.approval}}% - round-progress(max='100', current='$ctrl.delegate.approval', color='#0288D1') - md-card.forged-blocks(layout='column') - md-card-title - md-card-title-text - span.md-title Forged Blocks - span(md-position-mode='target-right target', ng-if='$ctrl.blocks.length === 0 && $ctrl.blocksLoaded') - span You have not forged any blocks yet. - md-menu(md-position-mode='target-right target', ng-if='$ctrl.blocks.length') - md-button.md-icon-button(ng-click='$mdOpenMenu()') - i.material-icons more_vert - md-menu-content(width='4') - md-menu-item(ng-click='check($event)') - div - md-checkbox#advanced.filled-in.violet(ng-model='$ctrl.showAllColumns') Show All Columns - md-card-content - md-content(layout='column') - md-table-container(ng-show='$ctrl.blocks.length') - table(md-table, ng-table='tableBlocks', border='0', width='100%', cellpadding='0', cellspacing='0', ng-show='$ctrl.blocks.length') - thead(md-head) - tr(md-row) - th(md-column) Block height - th(md-column, ng-show='$ctrl.showAllColumns') Block Id - th(md-column) Timestamp - th(md-column) Total fee - th(md-column) Reward - tbody(md-body, infinite-scroll='$ctrl.loadMoreBlocks()', infinite-scroll-distance='1') - tr(md-row, ng-repeat='block in $ctrl.blocks') - td(md-cell data-title='tableBlocks.cols.height', sortable="'height'") {{block.height | liskNumber}} - td(md-cell data-title='tableBlocks.cols.blockId', ng-show='$ctrl.showAllColumns') {{block.id}} - td(md-cell data-title='tableBlocks.cols.timestamp', sortable="'timestamp'") - span(ng-show='block.timestamp > 0') - timestamp(data='block.timestamp') - span(ng-show='block.timestamp == 0') - - td(md-cell data-title='tableBlocks.cols.totalFee', sortable="'totalFee'") {{block.totalFee | lsk}} - td(md-cell data-title='tableBlocks.cols.reward', sortable="'reward'") {{block.reward | lsk}} - td.width-80(md-cell data-title="''") - md-button.more(ng-show='$ctrl.moreBlocksExist && $ctrl.blocksLoaded', ng-click='$ctrl.loadMoreBlocks()') Load More diff --git a/src/components/forging/forging.test.js b/src/components/forging/forging.test.js new file mode 100644 index 000000000..f17177123 --- /dev/null +++ b/src/components/forging/forging.test.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import sinon from 'sinon'; +import Forging from './forging'; + +describe('Forging', () => { + let wrapper; + const props = { + account: { + delegate: {}, + isDelegate: true, + }, + peers: {}, + statistics: {}, + forgedBlocks: [], + onForgingStatsUpdated: sinon.spy(), + onForgedBlocksLoaded: sinon.spy(), + }; + let account; + + describe('For a delegate account', () => { + beforeEach(() => { + account = { + delegate: {}, + isDelegate: true, + }; + + wrapper = mount(); + }); + + it('should render ForgingTitle', () => { + expect(wrapper.find('ForgingTitle')).to.have.lengthOf(1); + }); + + it('should render ForgingStats', () => { + expect(wrapper.find('ForgingStats')).to.have.lengthOf(1); + }); + + it('should render DelegateStats', () => { + expect(wrapper.find('DelegateStats')).to.have.lengthOf(1); + }); + + it('should render ForgedBlocks', () => { + expect(wrapper.find('ForgedBlocks')).to.have.lengthOf(1); + }); + }); + + describe('For a non delegate account', () => { + beforeEach(() => { + account = { + delegate: {}, + isDelegate: false, + }; + + wrapper = mount(); + }); + + it('should render only a "not delegate" message if !props.account.isDelegate', () => { + expect(wrapper.find('ForgedBlocks')).to.have.lengthOf(0); + expect(wrapper.find('DelegateStats')).to.have.lengthOf(0); + expect(wrapper.find('p')).to.have.lengthOf(1); + }); + + // TODO: make these tests work + it.skip('should call props.onForgingStatsUpdate', () => { + expect(props.onForgingStatsUpdate).to.have.been.calledWith(); + }); + + it.skip('should call props.onForgedBlocksLoaded', () => { + expect(props.onForgedBlocksLoaded).to.have.been.calledWith(); + }); + }); +}); diff --git a/src/components/forging/forgingStats.js b/src/components/forging/forgingStats.js new file mode 100644 index 000000000..2539df28d --- /dev/null +++ b/src/components/forging/forgingStats.js @@ -0,0 +1,61 @@ +import React from 'react'; +import { Card, CardText } from 'react-toolbox/lib/card'; +import moment from 'moment'; +import grid from 'flexboxgrid/dist/flexboxgrid.css'; +import LiskAmount from '../liskAmount'; +import style from './forging.css'; + +const statCardObjects = [ + { + key: 'last24h', + label: 'Last 24 hours', + startMoment: moment().subtract(1, 'days'), + }, { + key: 'last7d', + label: 'Last 7 days', + startMoment: moment().subtract(7, 'days'), + }, { + key: 'last30d', + label: 'Last 30 days', + startMoment: moment().subtract(30, 'days'), + }, { + key: 'last365d', + label: 'Last 365 days', + startMoment: moment().subtract(365, 'days'), + }, +]; + + +class ForgingStats extends React.Component { + + componentDidMount() { + statCardObjects.map(obj => this.props.loadStats(obj.key, obj.startMoment)); + } + + render() { + return ( +
+ {statCardObjects.map(cardObj => ( +
+ + +
+
+ {cardObj.label} + + LSK + +
+
+
+
+
+ ))} +
+ ); + } +} + +export default ForgingStats; diff --git a/src/components/forging/forgingStats.test.js b/src/components/forging/forgingStats.test.js new file mode 100644 index 000000000..d2ebaa3b1 --- /dev/null +++ b/src/components/forging/forgingStats.test.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import ForgingStats from './forgingStats'; + + +describe('ForgingStats', () => { + const account = { + delegate: { + username: 'genesis_17', + rate: 19, + approval: 30, + productivity: 99.2, + }, + }; + const statistics = { + last24h: 321317, + last7d: 3213179124, + last30d: 321317912423, + last365d: 32131791242342, + }; + const loadStats = () => {}; + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + it('should render 4 Card components', () => { + expect(wrapper.find('Card')).to.have.lengthOf(4); + }); + + it('should render Card component for Last 24 hours', () => { + expect(wrapper.find('Card').at(0).text().trim()).to.equal('Last 24 hours 0 LSK'); + }); + + it('should render Card component for Last 7 days', () => { + expect(wrapper.find('Card').at(1).text().trim()).to.equal('Last 7 days 32.13 LSK'); + }); + + it('should render Card component for Last 30 days', () => { + expect(wrapper.find('Card').at(2).text().trim()).to.equal('Last 30 days 3,213.18 LSK'); + }); + + it('should render Card component for Last 365 days', () => { + expect(wrapper.find('Card').at(3).text().trim()).to.equal('Last 365 days 321,317.91 LSK'); + }); +}); diff --git a/src/components/forging/forgingTitle.js b/src/components/forging/forgingTitle.js new file mode 100644 index 000000000..4c4ac38a9 --- /dev/null +++ b/src/components/forging/forgingTitle.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { Card, CardText } from 'react-toolbox/lib/card'; +import moment from 'moment'; +import grid from 'flexboxgrid/dist/flexboxgrid.css'; +import LiskAmount from '../liskAmount'; +import style from './forging.css'; + + +class ForgingTitle extends React.Component { + + componentDidMount() { + this.props.loadStats('total', moment('2016-04-24 17:00')); + } + + render() { + return ( + + +
+

+ {this.props.account.delegate.username} +

+ + LSK Earned + +
+
+
+ ); + } +} + +export default ForgingTitle; diff --git a/src/components/forging/forgingTitle.test.js b/src/components/forging/forgingTitle.test.js new file mode 100644 index 000000000..23dbfecd0 --- /dev/null +++ b/src/components/forging/forgingTitle.test.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import ForgingTitle from './forgingTitle'; + + +describe('ForgingTitle', () => { + const account = { + delegate: { + username: 'genesis_17', + rate: 19, + approval: 30, + productivity: 99.2, + }, + }; + const statistics = { + total: 132423, + }; + const loadStats = () => {}; + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + it('should render 1 Card component', () => { + expect(wrapper.find('Card')).to.have.lengthOf(1); + }); + + it('should render h2 with delegate name', () => { + expect(wrapper.find('h2').text()).to.equal(account.delegate.username); + }); +}); diff --git a/src/components/forging/index.js b/src/components/forging/index.js new file mode 100644 index 000000000..9f799e467 --- /dev/null +++ b/src/components/forging/index.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux'; +import { fetchAndUpdateForgedBlocks, fetchAndUpdateForgedStats } from '../../actions/forging'; +import Forging from './forging'; + +const mapStateToProps = state => ({ + account: state.account, + peers: state.peers, + statistics: state.forging.statistics, + forgedBlocks: state.forging.forgedBlocks, +}); + +const mapDispatchToProps = dispatch => ({ + onForgedBlocksLoaded: data => dispatch(fetchAndUpdateForgedBlocks(data)), + onForgingStatsUpdated: data => dispatch(fetchAndUpdateForgedStats(data)), +}); + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(Forging); diff --git a/src/components/forging/index.test.js b/src/components/forging/index.test.js new file mode 100644 index 000000000..d3176ef07 --- /dev/null +++ b/src/components/forging/index.test.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import ForgingHOC from './index'; + +describe('Forging HOC', () => { + let wrapper; + let store; + + beforeEach(() => { + store = configureMockStore([])({ + account: { address: '10171906415056299071L' }, + peers: { data: {} }, + forging: { + statistics: {}, + forgedBlocks: [], + }, + }); + wrapper = mount(); + }); + + it('should render Forging component', () => { + expect(wrapper.find('Forging')).to.have.lengthOf(1); + }); + + it('should render Forging component with expected properties', () => { + const props = wrapper.find('Forging').props(); + const state = store.getState(); + + expect(props.account).to.be.equal(state.account); + expect(props.peers).to.be.equal(state.peers); + expect(props.statistics).to.be.equal(state.forging.statistics); + expect(props.forgedBlocks).to.be.equal(state.forging.forgedBlocks); + + expect(typeof props.onForgedBlocksLoaded).to.be.equal('function'); + expect(typeof props.onForgingStatsUpdated).to.be.equal('function'); + }); +}); diff --git a/src/components/formattedNumber/index.js b/src/components/formattedNumber/index.js new file mode 100644 index 000000000..fdf55dfe8 --- /dev/null +++ b/src/components/formattedNumber/index.js @@ -0,0 +1,25 @@ +import React from 'react'; + +/** + * + * @param {*} num - it is a number that we want to format it as formatted number + * @return {string} - formatted version of input number + */ +const formatNumber = (num) => { + let normalizeNum = parseFloat(num); + const sign = normalizeNum < 0 ? '-' : ''; + const absVal = String(parseInt(normalizeNum = Math.abs(Number(normalizeNum) || 0), 10)); + let remaining = absVal.length; + remaining = (remaining) > 3 ? remaining % 3 : 0; + const intPart = sign + (remaining ? `${absVal.substr(0, remaining)},` : '') + + absVal.substr(remaining).replace(/(\d{3})(?=\d)/g, '$1,'); + const floatPart = normalizeNum.toString().split('.')[1]; + normalizeNum = floatPart ? `${intPart}.${floatPart || ''}` : intPart; + return normalizeNum; +}; +const FormattedNumber = (props) => { + const formatedNumber = formatNumber(props.val); + return {formatedNumber}; +}; + +export default FormattedNumber; diff --git a/src/components/formattedNumber/index.test.js b/src/components/formattedNumber/index.test.js new file mode 100644 index 000000000..ef558144e --- /dev/null +++ b/src/components/formattedNumber/index.test.js @@ -0,0 +1,42 @@ +import React from 'react'; +import { expect } from 'chai'; +import { shallow } from 'enzyme'; +import FormattedNumber from '../formattedNumber/index'; + + +describe('FormattedNumber', () => { + it('should normalize "12932689.645" as "12,932,689.645"', () => { + const inputValue = '12932689.645'; + const expectedValue = '12,932,689.645'; + const wrapper = shallow(); + expect(wrapper.find('span').text()).to.be.equal(expectedValue); + }); + + it('should normalize "2500" as "2,500"', () => { + const inputValue = '2500'; + const expectedValue = '2,500'; + const wrapper = shallow(); + expect(wrapper.find('span').text()).to.be.equal(expectedValue); + }); + + it('should normalize "-78945" as "-78,945"', () => { + const inputValue = '78945'; + const expectedValue = '78,945'; + const wrapper = shallow(); + expect(wrapper.find('span').text()).to.be.equal(expectedValue); + }); + + it('should normalize "0" as "0"', () => { + const inputValue = '0'; + const expectedValue = '0'; + const wrapper = shallow(); + expect(wrapper.find('span').text()).to.be.equal(expectedValue); + }); + + it('should normalize "500.12345678" as "500.12345678"', () => { + const inputValue = '500.12345678'; + const expectedValue = '500.12345678'; + const wrapper = shallow(); + expect(wrapper.find('span').text()).to.be.equal(expectedValue); + }); +}); diff --git a/src/components/formattedNumber/stories.js b/src/components/formattedNumber/stories.js new file mode 100644 index 000000000..c9349265e --- /dev/null +++ b/src/components/formattedNumber/stories.js @@ -0,0 +1,10 @@ +import React from 'react'; + +import { storiesOf } from '@storybook/react'; + +import FormattedNumber from './'; + +storiesOf('FormattedNumber', module) + .add('with val', () => ( + + )); diff --git a/src/components/header/header.css b/src/components/header/header.css new file mode 100644 index 000000000..fb98562bf --- /dev/null +++ b/src/components/header/header.css @@ -0,0 +1,31 @@ +.wrapper { + margin: 5px -8px 8px 0; + padding: 8px; +} + +.logoWrapper { + width: 25%; +} + +.logo { + width: 100%; + min-width: 108px; + max-width: 200px; + padding: 8px; +} + +.button, +.iconButton { + padding: 8px; + margin: 6px 8px; + height: auto; + float: right; +} + +.material-icons { + font-size: 24px !important; +} + +.menu { + right: -8px !important; +} diff --git a/src/components/header/header.js b/src/components/header/header.js index 7ec28e68e..61d57ee3a 100644 --- a/src/components/header/header.js +++ b/src/components/header/header.js @@ -1,26 +1,68 @@ -import './header.less'; +import React from 'react'; +import { Button } from 'react-toolbox/lib/button'; +import { IconMenu, MenuItem } from 'react-toolbox/lib/menu'; +import grid from 'flexboxgrid/dist/flexboxgrid.css'; +import logo from '../../assets/images/LISK-nano.png'; +import styles from './header.css'; +import VerifyMessage from '../verifyMessage'; +import SignMessage from '../signMessage'; +import RegisterDelegate from '../registerDelegate'; +import Send from '../send'; +import PrivateWrapper from '../privateWrapper'; +import SecondPassphraseMenu from '../secondPassphrase'; +import offlineStyle from '../offlineWrapper/offlineWrapper.css'; -/** - * The main header component - * - * @module app - * @submodule header - */ -app.component('header', { - template: require('./header.pug')(), - controllerAs: '$ctrl', - - /** - * The main header component constructor class - * - * @class header - * @constructor - */ - controller: class header { - constructor($rootScope, Account) { - this.$rootScope = $rootScope; - this.account = Account; - } - }, -}); +const Header = props => ( +
+
+ logo +
+ + + { + !props.account.isDelegate && + props.setActiveDialog({ + title: 'Register as delegate', + childComponent: RegisterDelegate, + })} + /> + } + + props.setActiveDialog({ + title: 'Sign message', + childComponentProps: { + account: props.account, + }, + childComponent: SignMessage, + })} + /> + props.setActiveDialog({ + title: 'Verify message', + childComponent: VerifyMessage, + })} + /> + + + + +
+); +export default Header; diff --git a/src/components/header/header.less b/src/components/header/header.less deleted file mode 100644 index fe98ca38a..000000000 --- a/src/components/header/header.less +++ /dev/null @@ -1,13 +0,0 @@ -.header { - margin-top: 5px; - - h2 { - margin: 5px 0 - } - - .logo { - width: 25%; - min-width: 128px; - max-width: 256px; - } -} diff --git a/src/components/header/header.pug b/src/components/header/header.pug deleted file mode 100644 index b78229e4b..000000000 --- a/src/components/header/header.pug +++ /dev/null @@ -1,25 +0,0 @@ -md-content.header(layout='row', layout-align='center center', layout-padding) - img.logo(src=require('../../assets/images/LISK-nano.png')) - div(flex) - md-button.md-raised.md-primary.send-button.offline-hide(data-open-dialog='send', ng-if='$root.logged' ng-disabled='!$root.peers.online') Send - md-button.md-raised.md-secondary.logout-button(ng-click='$root.logout()', ng-if='$root.logged') Logout - md-menu.top-menu.offline-hide(ng-if='$root.logged', md-position-mode='target-right target', md-offset='14 0') - md-button.md-icon-button(ng-click='$mdOpenMenu()') - i.material-icons more_vert - md-menu-content(width='2') - md-menu-item(ng-if='$root.logged && !$ctrl.account.get().secondSignature') - md-button.register-second-passphrase(data-open-dialog='set-second-pass') - div(layout='row', flex='') - p(flex='') Register second passphrase - md-menu-item(ng-if='$root.logged && !$ctrl.account.get().isDelegate') - md-button.register-as-delegate(data-open-dialog='delegate-registration') - div(layout='row', flex='') - p(flex='') Register as delegate - md-menu-item - md-button.sign-message(data-open-dialog='sign-message') - div(layout='row', flex='') - p(flex='') Sign message - md-menu-item - md-button.verify-message(data-open-dialog='verify-message') - div(layout='row', flex='') - p(flex='') Verify message diff --git a/src/components/header/header.test.js b/src/components/header/header.test.js new file mode 100644 index 000000000..3b5058f1b --- /dev/null +++ b/src/components/header/header.test.js @@ -0,0 +1,50 @@ +import React from 'react'; +import { expect } from 'chai'; +import { shallow } from 'enzyme'; +import { Button } from 'react-toolbox/lib/button'; +import sinon from 'sinon'; +import styles from './header.css'; +import Header from './header'; +import logo from '../../assets/images/LISK-nano.png'; + +describe('Header', () => { + let wrapper; + let propsMock; + + beforeEach(() => { + const mockInputProps = { + setActiveDialog: () => { }, + account: {}, + }; + propsMock = sinon.mock(mockInputProps); + wrapper = shallow(
); + }); + + afterEach(() => { + propsMock.verify(); + propsMock.restore(); + }); + + it('renders two Button components', () => { + expect(wrapper.find(Button)).to.have.length(2); + }); + + it('should have an image with srouce of "logo"', () => { + expect(wrapper.contains(logo)) + .to.be.equal(true); + }); + + it('Sign Message menu item should call props.setActiveDialog("sign-message")', () => { + // TODO: figure out why the next line doesn't work + // propsMock.expects('setActiveDialog').withArgs('sign-message'); + wrapper.find('.main-menu-icon-button').simulate('click'); + wrapper.find('.sign-message').simulate('click'); + }); + + it('Verify Message menu item should call props.setActiveDialog("verify-message")', () => { + // TODO: figure out why the next line doesn't work + // propsMock.expects('setActiveDialog').withArgs('verify-message'); + wrapper.find('.main-menu-icon-button').simulate('click'); + wrapper.find('.verify-message').simulate('click'); + }); +}); diff --git a/src/components/header/index.js b/src/components/header/index.js new file mode 100644 index 000000000..bb858ad9d --- /dev/null +++ b/src/components/header/index.js @@ -0,0 +1,18 @@ +import { connect } from 'react-redux'; +import { dialogDisplayed } from '../../actions/dialog'; +import { accountLoggedOut } from '../../actions/account'; +import Header from './header'; + +const mapStateToProps = state => ({ + account: state.account, +}); + +const mapDispatchToProps = dispatch => ({ + setActiveDialog: data => dispatch(dialogDisplayed(data)), + logOut: () => dispatch(accountLoggedOut()), +}); + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(Header); diff --git a/src/components/header/index.test.js b/src/components/header/index.test.js new file mode 100644 index 000000000..196225312 --- /dev/null +++ b/src/components/header/index.test.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import { Provider } from 'react-redux'; +import sinon from 'sinon'; +import * as accountActions from '../../actions/account'; +import * as dialogActions from '../../actions/dialog'; +import Header from './header'; +import HeaderHOC from './index'; +import store from '../../store'; + + +describe('HeaderHOC', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + it('should render Header', () => { + expect(wrapper.find(Header)).to.have.lengthOf(1); + }); + + it('should bind accountLoggedOut action to Header props.logOut', () => { + const actionsSpy = sinon.spy(accountActions, 'accountLoggedOut'); + wrapper.find(Header).props().logOut({}); + expect(actionsSpy).to.be.calledWith(); + actionsSpy.restore(); + }); + + it('should bind dialogDisplayed action to Header props.setActiveDialog', () => { + const actionsSpy = sinon.spy(dialogActions, 'dialogDisplayed'); + wrapper.find(Header).props().setActiveDialog({}); + expect(actionsSpy).to.be.calledWith(); + actionsSpy.restore(); + }); +}); diff --git a/src/components/infoParagraph/index.js b/src/components/infoParagraph/index.js new file mode 100644 index 000000000..78845a9f9 --- /dev/null +++ b/src/components/infoParagraph/index.js @@ -0,0 +1,19 @@ +import React from 'react'; +import FontIcon from 'react-toolbox/lib/font_icon'; +import layout from './infoParagraph.css'; + +const InfoParagraph = props => ( +
+
+ + + +
+ {props.children} +
+
+
+
+); + +export default InfoParagraph; diff --git a/src/components/infoParagraph/infoParagraph.css b/src/components/infoParagraph/infoParagraph.css new file mode 100644 index 000000000..328265120 --- /dev/null +++ b/src/components/infoParagraph/infoParagraph.css @@ -0,0 +1,18 @@ +.layout-margin { + display: inline-block; + margin: 8px; +} + +.layout-padding { + display: inline-block; + padding: 8px; +} + +.layout-row { + display: flex; + flex-direction: row; +} + +.layout-align-center-center { + align-items: center; +} diff --git a/src/components/liskAmount/index.js b/src/components/liskAmount/index.js new file mode 100644 index 000000000..1b7c869dc --- /dev/null +++ b/src/components/liskAmount/index.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { fromRawLsk } from '../../utils/lsk'; +import FormattedNumber from '../formattedNumber'; + +const roundTo = (value, places) => { + if (!places) { + return value; + } + const x = 10 ** places; + return Math.round(value * x) / x; +}; + +const LiskAmount = props => (); + +export default LiskAmount; + diff --git a/src/components/liskAmount/index.test.js b/src/components/liskAmount/index.test.js new file mode 100644 index 000000000..cea1cb38f --- /dev/null +++ b/src/components/liskAmount/index.test.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import LiskAmount from './'; + +describe('LiskAmount', () => { + const normalizeNumber = 100000000; + it('should normalize "12932689.645" as "12,932,689.645"', () => { + const inputValue = '12932689.645' * normalizeNumber; + const expectedValue = '12,932,689.645'; + const wrapper = mount(); + expect(wrapper.text()).to.be.equal(expectedValue); + }); + + it('should round to props.roundTo decimal places', () => { + const inputValue = '12932689.64321' * normalizeNumber; + const expectedValue = '12,932,689.64'; + const wrapper = mount(); + expect(wrapper.text()).to.be.equal(expectedValue); + }); +}); diff --git a/src/components/loadingBar/index.js b/src/components/loadingBar/index.js new file mode 100644 index 000000000..86a17267f --- /dev/null +++ b/src/components/loadingBar/index.js @@ -0,0 +1,10 @@ + +import { connect } from 'react-redux'; +import LoadingBar from './loadingBar'; + +const mapStateToProps = state => ({ + loading: state.loading, +}); + +export default connect(mapStateToProps)(LoadingBar); + diff --git a/src/components/loadingBar/index.test.js b/src/components/loadingBar/index.test.js new file mode 100644 index 000000000..2e32c4323 --- /dev/null +++ b/src/components/loadingBar/index.test.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import { Provider } from 'react-redux'; +import LoadingBarHOC from './'; +import store from '../../store'; + + +describe('LoadingBarHOC', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + it('should render Send', () => { + expect(wrapper.find('LoadingBar')).to.have.lengthOf(1); + }); +}); diff --git a/src/components/loadingBar/loadingBar.css b/src/components/loadingBar/loadingBar.css new file mode 100644 index 000000000..3031e7c1a --- /dev/null +++ b/src/components/loadingBar/loadingBar.css @@ -0,0 +1,11 @@ +.fixedAtTop { + position: fixed; + top: -11px; + right: 0; + width: 100vw; /* stylelint-disable-line */ + z-index: 201; +} + +.linear { + background: rgb(60, 185, 253); +} diff --git a/src/components/loadingBar/loadingBar.js b/src/components/loadingBar/loadingBar.js index d477e2888..9c4964bf8 100644 --- a/src/components/loadingBar/loadingBar.js +++ b/src/components/loadingBar/loadingBar.js @@ -1,25 +1,14 @@ -import './loadingBar.less'; - -app.component('loadingBar', { - template: require('./loadingBar.pug')(), - controller: class loadingBar { - - constructor($scope, $rootScope) { - this.loaders = []; - $rootScope.$on('showLoadingBar', (event, name) => { - const index = this.loaders.indexOf(name); - if (index === -1) { - this.loaders.push(name); - } - }); - - $rootScope.$on('hideLoadingBar', (event, name) => { - const index = this.loaders.indexOf(name); - if (index > -1) { - this.loaders.splice(index, 1); - } - }); +import React from 'react'; +import ProgressBar from 'react-toolbox/lib/progress_bar'; +import styles from './loadingBar.css'; + +const LoadingBar = props => ( +
+ {props.loading && props.loading.length ? + : + null } - }, -}); +
+); +export default LoadingBar; diff --git a/src/components/loadingBar/loadingBar.less b/src/components/loadingBar/loadingBar.less deleted file mode 100644 index f3fd331de..000000000 --- a/src/components/loadingBar/loadingBar.less +++ /dev/null @@ -1,7 +0,0 @@ -loading-bar { - position: fixed; - width: 100%; - top: 0; - left: 0; -} - diff --git a/src/components/loadingBar/loadingBar.pug b/src/components/loadingBar/loadingBar.pug deleted file mode 100644 index a9fdd0aa5..000000000 --- a/src/components/loadingBar/loadingBar.pug +++ /dev/null @@ -1 +0,0 @@ -md-progress-linear(md-mode='indeterminate', ng-if='$ctrl.loaders.length') diff --git a/src/components/loadingBar/loadingBar.test.js b/src/components/loadingBar/loadingBar.test.js new file mode 100644 index 000000000..4f366b24d --- /dev/null +++ b/src/components/loadingBar/loadingBar.test.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import LoadingBar from './loadingBar'; + + +describe('LoadingBar Container', () => { + it('should show ProgresBar if props.loading.length is not 0', () => { + const wrapper = mount(); + expect(wrapper.find('ProgressBar')).to.have.lengthOf(1); + }); + + it('should not show ProgresBar if props.loading.length is 0', () => { + const wrapper = mount(); + expect(wrapper.find('ProgressBar')).to.have.lengthOf(0); + }); +}); diff --git a/src/components/login/index.js b/src/components/login/index.js new file mode 100644 index 000000000..0526eb2c2 --- /dev/null +++ b/src/components/login/index.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { withRouter } from 'react-router'; +import { dialogDisplayed } from '../../actions/dialog'; +import Login from './login'; +import { activePeerSet } from '../../actions/peers'; + +/** + * Using react-redux connect to pass state and dispatch to Login + */ +const mapStateToProps = state => ({ + account: state.account, + peers: state.peers, +}); + +const mapDispatchToProps = dispatch => ({ + activePeerSet: data => dispatch(activePeerSet(data)), + setActiveDialog: data => dispatch(dialogDisplayed(data)), +}); + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(withRouter(Login)); diff --git a/src/components/login/index.test.js b/src/components/login/index.test.js new file mode 100644 index 000000000..58118c8f2 --- /dev/null +++ b/src/components/login/index.test.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import { BrowserRouter as Router } from 'react-router-dom'; +import configureMockStore from 'redux-mock-store'; +import { Provider } from 'react-redux'; +import LoginHOC from './index'; +import Login from './login'; + +describe('LoginHOC', () => { + // Mocking store + const peers = { + status: { + online: false, + }, + data: { + currentPeer: 'localhost', + port: 4000, + options: { + name: 'Custom Node', + }, + }, + }; + const account = { + isDelegate: false, + address: '16313739661670634666L', + username: 'lisk-nano', + }; + const store = configureMockStore([])({ + peers, + account, + activePeerSet: () => {}, + }); + let wrapper; + + beforeEach(() => { + wrapper = mount( + ); + }); + + it('should mount Login', () => { + expect(wrapper.find(Login)).to.have.lengthOf(1); + }); + + it('should mount Login with appropriate properties', () => { + const props = wrapper.find(Login).props(); + expect(props.peers).to.be.equal(peers); + expect(props.account).to.be.equal(account); + expect(typeof props.activePeerSet).to.be.equal('function'); + }); +}); diff --git a/src/components/login/login.css b/src/components/login/login.css new file mode 100644 index 000000000..7c815bcf8 --- /dev/null +++ b/src/components/login/login.css @@ -0,0 +1,23 @@ +.wrapper { + padding-top: 50px !important; + margin-top: 8px; + padding-bottom: 24px !important; +} + +.newAccount { + margin-right: 8px; +} + +.network ul { + text-align: left; +} + +.error { + display: inline-block; + text-align: left; + width: 100%; +} + +.field { + margin-top: 10px; +} diff --git a/src/components/login/login.js b/src/components/login/login.js index 130430f29..ef896bbe6 100644 --- a/src/components/login/login.js +++ b/src/components/login/login.js @@ -1,119 +1,205 @@ -import './login.less'; - -app.component('login', { - template: require('./login.pug')(), - controller: class login { - - /* eslint no-param-reassign: ["error", { "props": false }] */ - - constructor($scope, $rootScope, $timeout, $document, $mdMedia, - $cookies, $location, Passphrase, $state, Account, Peers) { - this.$scope = $scope; - this.$rootScope = $rootScope; - this.$timeout = $timeout; - this.$document = $document; - this.$mdMedia = $mdMedia; - this.$cookies = $cookies; - this.$location = $location; - this.$state = $state; - this.account = Account; - this.peers = Peers; - - this.Passphrase = Passphrase; - this.generatingNewPassphrase = false; - this.$rootScope.loggingIn = false; - - this.networks = [{ - name: 'Mainnet', - ssl: true, - port: 443, - }, { - name: 'Testnet', - testnet: true, - }, { - name: 'Custom Node', - custom: true, - address: 'http://localhost:8000', - }]; - - this.network = this.networks[0]; - try { - const network = JSON.parse(this.$cookies.get('network')); - if (network.custom) { - this.networks[2].address = network.address; - this.network = this.networks[2]; - } else if (network.testnet) { - this.network = this.networks[1]; - } - } catch (e) { - this.$cookies.remove('network'); +import React from 'react'; +import grid from 'flexboxgrid/dist/flexboxgrid.css'; +import Input from 'react-toolbox/lib/input'; +import Dropdown from 'react-toolbox/lib/dropdown'; +import Button from 'react-toolbox/lib/button'; +import Checkbox from 'react-toolbox/lib/checkbox'; +import { isValidPassphrase } from '../../utils/passphrase'; +import networksRaw from './networks'; +import Passphrase from '../passphrase'; +import styles from './login.css'; +import env from '../../constants/env'; + +/** + * The container component containing login + * and create account functionality + */ +class Login extends React.Component { + constructor() { + super(); + + this.networks = networksRaw.map((network, index) => ({ + label: network.name, + value: index, + })); + + this.state = { + passphrase: '', + address: '', + network: 0, + }; + + this.validators = { + address: this.validateUrl, + passphrase: this.validatePassphrase, + }; + } + + componentDidMount() { + // pre-fill passphrase and address if exiting in cookies + this.devPreFill(); + } + + componentDidUpdate() { + if (this.props.account && this.props.account.address) { + this.props.history.replace(this.getReferrerRoute()); + if (this.state.address) { + localStorage.setItem('address', this.state.address); } + localStorage.setItem('network', this.state.network); + } + } - this.validity = { - url: true, - }; - - this.$scope.$watch('$ctrl.input_passphrase', val => this.validity.passphrase = this.Passphrase.isValidPassphrase(val)); - this.$scope.$watch('$ctrl.network.address', (val) => { - try { - // eslint-disable-next-line no-new - new URL(val); - this.validity.url = true; - } catch (e) { - this.validity.url = false; - } - }); + getReferrerRoute() { + const { isDelegate } = this.props.account; + const { search } = this.props.history.location; + const transactionRoute = '/main/transactions'; + const referrerRoute = search.indexOf('?referrer') === 0 ? search.replace('?referrer=', '') : transactionRoute; + if (!isDelegate && referrerRoute === '/main/forging') { + return transactionRoute; + } + return referrerRoute; + } - this.$timeout(this.devTestAccount.bind(this), 200); + // eslint-disable-next-line class-methods-use-this + validateUrl(value) { + const addHttp = (url) => { + const reg = /^(?:f|ht)tps?:\/\//i; + return reg.test(url) ? url : `http://${url}`; + }; - /** - * @todo Move this after creating the dialog service - */ - this.$scope.$watch(() => this.$mdMedia('xs') || this.$mdMedia('sm'), (wantsFullScreen) => { - this.$scope.customFullscreen = wantsFullScreen === true; - }); + const errorMessage = 'URL is invalid'; + + const isValidLocalhost = url => url.hostname === 'localhost' && url.port.length > 1; + const isValidRemote = url => /(([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3})/.test(url.hostname); + + let addressValidity = ''; + try { + const url = new URL(addHttp(value)); + addressValidity = url && (isValidRemote(url) || isValidLocalhost(url)) ? '' : errorMessage; + } catch (e) { + addressValidity = errorMessage; } - /** - * Called of login/sign-up form submission. this is where we set the active peer. - * - * @param {String} [_passphrase=this.input_passphrase] - */ - passConfirmSubmit(_passphrase = this.input_passphrase) { - this.$rootScope.loggingIn = true; - this.$scope.$emit('showLoadingBar'); - if (this.Passphrase.normalize.constructor === Function) { - this.peers.setActive(this.network).then(() => { - this.$rootScope.loggingIn = false; - this.$scope.$emit('hideLoadingBar'); - if (this.peers.online) { - this.account.set({ - passphrase: this.Passphrase.normalize(_passphrase), - network: this.network, - }); - this.$cookies.put('network', JSON.stringify(this.network)); - this.$state.go(this.$rootScope.landingUrl || 'main.transactions'); - } - }); - } + const data = { address: value, addressValidity }; + return data; + } + + // eslint-disable-next-line class-methods-use-this + validatePassphrase(value) { + const data = { passphrase: value }; + if (!value || value === '') { + data.passphraseValidity = 'Empty passphrase'; + } else { + data.passphraseValidity = isValidPassphrase(value) ? '' : 'Invalid passphrase'; } + return data; + } - devTestAccount() { - const peerStack = this.$location.search().peerStack || this.$cookies.get('peerStack'); - if (peerStack === 'localhost') { - this.network = this.networks[2]; - angular.merge(this.network, { - address: 'http://localhost:4000', - testnet: true, - nethash: '198f2b61a8eb95fbeed58b8216780b68f697f26b849acf00c8c93bb9b24f783d', - }); - } else if (peerStack === 'testnet') { - this.network = this.networks[1]; - } - const passphrase = this.$location.search().passphrase || this.$cookies.get('passphrase'); - if (passphrase) { - this.input_passphrase = passphrase; - } + changeHandler(name, value) { + const validator = this.validators[name] || (() => ({})); + this.setState({ + [name]: value, + ...validator(value), + }); + } + + onLoginSubmission(passphrase) { + const network = Object.assign({}, networksRaw[this.state.network]); + if (this.state.network === 2) { + network.address = this.state.address; + } + + // set active peer + this.props.activePeerSet({ + passphrase, + network, + }); + } + + devPreFill() { + const address = localStorage.getItem('address') || ''; + const passphrase = localStorage.getItem('passphrase') || ''; + const network = parseInt(localStorage.getItem('network'), 10) || 0; + + this.setState({ + network, + ...this.validators.address(address), + ...this.validators.passphrase(passphrase), + }); + + // ignore this in coverage as it is hard to test and does not run in production + /* istanbul ignore if */ + if (!env.production && localStorage.getItem('autologin') && !this.props.account.afterLogout && passphrase) { + setTimeout(() => { + this.onLoginSubmission(passphrase); + }); } - }, -}); + } + + render() { + return ( +
+
+
+
+ + { + this.state.network === 2 && + + } + + +
+
+
+
+ +
+
+
+ ); + } +} + +export default Login; diff --git a/src/components/login/login.less b/src/components/login/login.less deleted file mode 100644 index 9e98a7cad..000000000 --- a/src/components/login/login.less +++ /dev/null @@ -1,18 +0,0 @@ - -login { - input { - text-transform: lowercase; - } - - .move { - text-align: center; - } - - .random-input { - margin: 0 0 25px 0; - } - - md-card { - padding-top: 40px; - } -} diff --git a/src/components/login/login.pug b/src/components/login/login.pug deleted file mode 100644 index 26e5dde76..000000000 --- a/src/components/login/login.pug +++ /dev/null @@ -1,22 +0,0 @@ -md-card - md-card-content(flex='100', flex-gt-sm='70', flex-offset-gt-sm='15') - form(ng-submit='$ctrl.passConfirmSubmit()') - md-input-container.md-block - label.select Network - md-select.network(ng-model='$ctrl.network', aria-label='Network') - md-option(ng-repeat='network in $ctrl.networks', ng-value='network') {{ network.name }} - div(ng-if='$ctrl.network.custom') - md-input-container.md-block(md-is-error='!$ctrl.validity.url') - label.pass Node address - input(type="text", ng-model="$ctrl.network.address") - md-input-container.md-block(md-is-error='$ctrl.validity.passphrase === 0') - label.pass Enter your passphrase - input.passphrase(type="{{ $ctrl.show_passphrase ? 'text' : 'password' }}", ng-model='$ctrl.input_passphrase', ng-disabled='$ctrl.generatingNewPassphrase', autofocus) - md-input-container.md-block - md-checkbox.md-primary(ng-model="$ctrl.show_passphrase", aria-label="Show passphrase") Show passphrase - md-content(layout='row', layout-align='center center') - // md-button(ng-disabled='$ctrl.generatingNewPassphrase', ng-click='$ctrl.devTestAccount()') Dev Test Account - md-button.md-primary.new-account-button(ng-disabled='$ctrl.random || $ctrl.generatingNewPassphrase || ($ctrl.network.custom && !$ctrl.validity.url)', - data-open-dialog='new-account', data-options='{network: $ctrl.network}') NEW ACCOUNT - md-button.md-raised.md-primary.login-button(md-autofocus, ng-disabled='($ctrl.validity.passphrase !== 1 || ($ctrl.network.custom && !$ctrl.validity.url)) || $root.loggingIn', type='submit') Login - passphrase(ng-if='$ctrl.generatingNewPassphrase', data-on-save='onSave', data-target='primary-pass', data-ok-button-label='Login') diff --git a/src/components/login/login.test.js b/src/components/login/login.test.js new file mode 100644 index 000000000..6449da74f --- /dev/null +++ b/src/components/login/login.test.js @@ -0,0 +1,241 @@ +import React from 'react'; +import chai, { expect } from 'chai'; +import { spy } from 'sinon'; +import sinonChai from 'sinon-chai'; +import { mount, shallow } from 'enzyme'; +import Lisk from 'lisk-js'; +import Login from './login'; + +chai.use(sinonChai); + +describe('Login', () => { + let wrapper; + // Mocking store + const account = { + isDelegate: false, + address: '16313739661670634666L', + username: 'lisk-nano', + }; + + const props = { + peers: {}, + account, + history: {}, + onAccountUpdated: () => {}, + setActiveDialog: spy(), + activePeerSet: (network) => { + props.peers.data = Lisk.api(network); + }, + }; + props.spyActivePeerSet = spy(props.activePeerSet); + + describe('Generals', () => { + beforeEach(() => { + wrapper = mount(); + }); + + it('should render a form tag', () => { + }); + + it('should render address input if state.network === 2', () => { + wrapper.setState({ network: 2 }); + expect(wrapper.find('.address')).to.have.lengthOf(1); + }); + + it('should allow to change passphrase field to type="text"', () => { + expect(wrapper.find('.passphrase input').props().type).to.equal('password'); + wrapper.setState({ showPassphrase: true }); + expect(wrapper.find('.passphrase input').props().type).to.equal('text'); + }); + + it('should show "Invalid passphrase" error message if passphrase is invalid', () => { + wrapper.find('.passphrase input').simulate('change', { target: { value: 'INVALID' } }); + expect(wrapper.find('.passphrase').text()).to.contain('Invalid passphrase'); + }); + + it('should show call props.setActiveDialog when "new account" button is clicked', () => { + wrapper.find('.new-account-button').simulate('click'); + expect(props.setActiveDialog).to.have.been.calledWith(); + }); + }); + + describe('componentDidMount', () => { + it('calls devPreFill', () => { + const spyFn = spy(Login.prototype, 'devPreFill'); + mount(); + expect(spyFn).to.have.been.calledWith(); + }); + }); + + describe('componentDidUpdate', () => { + const address = 'http:localhost:8080'; + props.account = { address: 'dummy' }; + props.history = { + replace: spy(), + location: { + search: '', + }, + }; + + it('calls this.props.history.replace(\'/main/transactions\')', () => { + wrapper = mount(); + wrapper.setProps(props); + expect(props.history.replace).to.have.been.calledWith('/main/transactions'); + }); + + it('calls this.props.history.replace with referrer address', () => { + props.history.location.search = '?referrer=/main/voting'; + wrapper = mount(); + expect(props.history.replace).to.have.been.calledWith('/main/voting'); + }); + + it('call this.props.history.replace with "/main/transaction" if referrer address is "/main/forging" and account.isDelegate === false', () => { + props.history.location.search = '?referrer=/main/forging'; + props.account.isDelegate = false; + wrapper = mount(); + expect(props.history.replace).to.have.been.calledWith('/main/transactions'); + }); + + it('calls localStorage.setItem(\'address\', address) if this.state.address', () => { + const spyFn = spy(localStorage, 'setItem'); + wrapper = mount(); + wrapper.setState({ address }); + wrapper.setProps(props); + expect(spyFn).to.have.been.calledWith('address', address); + + spyFn.restore(); + localStorage.removeItem('address'); + }); + }); + + describe('validateUrl', () => { + beforeEach('', () => { + wrapper = shallow(); + }); + + it('should set address and addressValidity="" for a valid address', () => { + const validURL = 'http://localhost:8080'; + const data = wrapper.instance().validateUrl(validURL); + const expectedData = { + address: validURL, + addressValidity: '', + }; + expect(data).to.deep.equal(expectedData); + }); + + it('should set address and addressValidity correctly event without http', () => { + const validURL = '127.0.0.1:8080'; + const data = wrapper.instance().validateUrl(validURL); + const expectedData = { + address: validURL, + addressValidity: '', + }; + expect(data).to.deep.equal(expectedData); + }); + + it('should set address and addressValidity="URL is invalid" for a valid address', () => { + const validURL = 'http:localhost:8080'; + const data = wrapper.instance().validateUrl(validURL); + const expectedData = { + address: validURL, + addressValidity: 'URL is invalid', + }; + expect(data).to.deep.equal(expectedData); + }); + }); + + describe('validatePassphrase', () => { + beforeEach('', () => { + wrapper = shallow(); + }); + + it('should set passphraseValidity="" for a valid passphrase', () => { + const passphrase = 'wagon stock borrow episode laundry kitten salute link globe zero feed marble'; + const data = wrapper.instance().validatePassphrase(passphrase); + const expectedData = { + passphrase, + passphraseValidity: '', + }; + expect(data).to.deep.equal(expectedData); + }); + + it('should set passphraseValidity="Empty passphrase" for an empty string', () => { + const passphrase = ''; + const data = wrapper.instance().validatePassphrase(passphrase); + const expectedData = { + passphrase, + passphraseValidity: 'Empty passphrase', + }; + expect(data).to.deep.equal(expectedData); + }); + + it.skip('should set passphraseValidity="Invalid passphrase" for a non-empty invalid passphrase', () => { + const passphrase = 'invalid passphrase'; + const data = wrapper.instance().validatePassphrase(passphrase); + const expectedData = { + passphrase, + passphraseValidity: 'URL is invalid', + }; + expect(data).to.deep.equal(expectedData); + }); + }); + + describe('changeHandler', () => { + it('call setState with matching data', () => { + wrapper = shallow(); + const key = 'network'; + const value = 0; + const spyFn = spy(Login.prototype, 'setState'); + wrapper.instance().changeHandler(key, value); + expect(spyFn).to.have.been.calledWith({ [key]: value }); + }); + }); + + describe('onLoginSubmission', () => { + it.skip('it should call activePeerSet', () => { + wrapper = shallow(); + wrapper.instance().onLoginSubmission(); + expect(wrapper.props().spyActivePeerSet).to.have.been.calledWith(); + }); + }); + + describe.skip('devPreFill', () => { + it('should call validateUrl', () => { + const spyFn = spy(Login.prototype, 'validateUrl'); + + mount(); + expect(spyFn).to.have.been.calledWith(); + }); + + it('should set state with correct network index and passphrase', () => { + const spyFn = spy(Login.prototype, 'validateUrl'); + const passphrase = 'Test Passphrase'; + localStorage.setItem('address', 'http:localhost:4000'); + localStorage.setItem('passphrase', passphrase); + + // for invalid address, it should set network to 0 + mount(); + expect(spyFn).to.have.been.calledWith({ + passphrase, + network: 0, + }); + + Login.prototype.validateUrl.restore(); + }); + + it('should set state with correct network index and passphrase', () => { + const spyFn = spy(Login.prototype, 'validateUrl'); + // for valid address should set network to 2 + const passphrase = 'Test Passphrase'; + localStorage.setItem('passphrase', passphrase); + localStorage.setItem('address', 'http:localhost:4000'); + mount(); + expect(spyFn).to.have.been.calledWith({ + passphrase, + network: 2, + }); + + Login.prototype.validateUrl.restore(); + }); + }); +}); diff --git a/src/components/login/networks.js b/src/components/login/networks.js new file mode 100644 index 000000000..0b578ab88 --- /dev/null +++ b/src/components/login/networks.js @@ -0,0 +1,22 @@ +import env from '../../constants/env'; + +export default [ + { + name: 'Mainnet', + ssl: true, + port: 443, + }, { + name: 'Testnet', + testnet: true, + ssl: true, + port: 443, + }, { + name: 'Custom Node', + custom: true, + address: 'http://localhost:4000', + ...(env.production ? {} : { + testnet: true, + nethash: '198f2b61a8eb95fbeed58b8216780b68f697f26b849acf00c8c93bb9b24f783d', + }), + }, +]; diff --git a/src/components/login/newAccount.js b/src/components/login/newAccount.js deleted file mode 100644 index 40a115daa..000000000 --- a/src/components/login/newAccount.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * The directive to show the second passphrase form and register it using AccountApi - * - * @module app - * @submodule SetSecondPassCtrl - */ -app.component('newAccount', { - bindings: { - network: '=', - closeDialog: '&', - }, - template: require('./newAccount.pug')(), - controller($scope, Account, $rootScope, $cookies, - Passphrase, $state, Peers) { - /** - * We call this after second passphrase is generated. - * Shows an alert with appropriate message in case the request fails. - * - * @param {String} passphrase - The validated passphrase to register as primary passphrase - */ - $scope.passConfirmSubmit = (passphrase) => { - $rootScope.loggingIn = true; - $scope.$emit('showLoadingBar'); - Peers.setActive(this.network).then(() => { - $rootScope.loggingIn = false; - $scope.$emit('hideLoadingBar'); - if (Peers.online) { - Account.set({ - passphrase, - network: this.network, - }); - $cookies.put('network', JSON.stringify(this.network)); - $state.go($rootScope.landingUrl || 'main.transactions'); - } - }); - }; - - $scope.onSave = (passphrase) => { - $scope.passConfirmSubmit(passphrase); - }; - - $scope.cancel = () => { - this.closeDialog(); - }; - }, - // controllerAs: 'md', -}); diff --git a/src/components/login/newAccount.pug b/src/components/login/newAccount.pug deleted file mode 100644 index e8e05787d..000000000 --- a/src/components/login/newAccount.pug +++ /dev/null @@ -1,24 +0,0 @@ -div.dialog-primary(aria-label='Generate a primary passphrase for a new account', data-ng-init='$ctrl.step = 0') - md-toolbar - .md-toolbar-tools - h2 New Account - span(flex='') - md-button.md-icon-button(ng-click='cancel()', aria-label='Close dialog') - i.material-icons close - div(layout='row', layout-padding, layout-align="left center", data-ng-if='$ctrl.step === 0') - div(layout-margin) - i.material-icons info - p - span Please click Next, then move around your mouse randomly to generate a random passphrase. - br - br - span Note: After registration completes, your passphrase will be required for logging in to your account. -
- span This passphrase is not recoverable and if you lose it, you will lose access to your account forever. Please keep it safe! - - md-dialog-actions(layout='row', data-ng-if='$ctrl.step === 0') - md-button.md-secondary(ng-disabled='$ctrl.loading', ng-click='cancel()') Cancel - span(flex) - md-button.md-raised.md-primary.submit-button.next-button(ng-click='$ctrl.step = 1') Next - section(data-ng-if='$ctrl.step === 1') - passphrase(data-on-save='onSave', data-label='Login') diff --git a/src/components/lsk/lsk.js b/src/components/lsk/lsk.js deleted file mode 100644 index b6fb70320..000000000 --- a/src/components/lsk/lsk.js +++ /dev/null @@ -1,26 +0,0 @@ -import './lsk.less'; - -/** - * The lsk component showing the amount and unit of the transaction - * This component adds the unit and it just needs the raw amount - * - * @module app - * @submodule lsk - */ -app.component('lsk', { - template: require('./lsk.pug')(), - bindings: { - amount: '<', - }, - /** - * The lsk component constructor class - * - * @class lsk - * @constructor - */ - controller: class lsk { - constructor($attrs) { - this.append = typeof $attrs.append !== 'undefined'; - } - }, -}); diff --git a/src/components/lsk/lsk.less b/src/components/lsk/lsk.less deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/components/lsk/lsk.pug b/src/components/lsk/lsk.pug deleted file mode 100644 index 0a1658113..000000000 --- a/src/components/lsk/lsk.pug +++ /dev/null @@ -1,2 +0,0 @@ -span(ng-bind='$ctrl.amount | lsk | liskNumber') -span(ng-show='$ctrl.append')= ' LSK' diff --git a/src/components/main/main.js b/src/components/main/main.js deleted file mode 100644 index 1becc59a6..000000000 --- a/src/components/main/main.js +++ /dev/null @@ -1,126 +0,0 @@ -import './main.less'; -/** - * The main component, used as parent for transaction, forging and delgate tabs. - * - * @module app - * @submodule main - */ -app.component('main', { - template: require('./main.pug')(), - controllerAs: '$ctrl', - /** - * The main component constructor class - * - * @class main - * @constructor - */ - controller: class main { - constructor($scope, $rootScope, $timeout, $q, $state, Peers, - dialog, Account, AccountApi, Notification) { - this.$scope = $scope; - this.$rootScope = $rootScope; - this.$timeout = $timeout; - this.$q = $q; - this.peers = Peers; - this.dialog = dialog; - this.$state = $state; - this.account = Account; - this.accountApi = AccountApi; - this.notify = Notification.init(); - - this.activeTab = this.init(); - } - - /** - * - Redirects to login if not logged in yet. - * - Updates account info. - * - Tries to find an active peer for 10 times until finds one. - * - * @param {Number} [attempts=0] The number of attempts to find an active peer - * @returns {string} The name of the current state - */ - init(attempts = 0) { - if (!this.account.get() || !this.account.get().passphrase) { - // Return to login but keep the state - this.$rootScope.landingUrl = this.$state.current.name; - this.$state.go('login'); - return ''; - } - - this.$scope.$emit('showLoadingBar'); - - this.update(attempts) - .then(() => { - this.$scope.$emit('hideLoadingBar'); - this.$rootScope.logged = true; - if (this.$timeout) { - clearTimeout(this.$timeout); - delete this.$timeout; - } - - if (this.account.get() && this.account.get().publicKey) { - this.checkIfIsDelegate(); - this.$scope.$on('syncTick', this.update.bind(this)); - } - }) - .catch(() => { - if (attempts < 10) { - this.$timeout(() => this.update(attempts + 1), 1000); - } else { - this.dialog.errorAlert({ text: 'No peer connection' }); - this.$rootScope.logout(); - } - }); - - return this.$state.current.name; - } - - /** - * Uses peers service to check if the current account is a delegate - * - * @todo This property can be included in accountApi.get to - * eliminate this Api call - */ - checkIfIsDelegate() { - this.peers.active.sendRequest('delegates/get', { - publicKey: this.account.get().publicKey, - }, (data) => { - if (data.success && data.delegate) { - this.account.set({ - isDelegate: true, - username: data.delegate.username, - delegate: data.delegate, - }); - } - }); - } - - /** - * Sets account credentials and balance using accountApi.get - * - * @returns {promise} Api call promise - */ - update() { - return this.accountApi.get(this.account.get().address) - .then((res) => { - if (res.publicKey === null) { - // because res.publicKey is null if the account didn't send any transaction yet, - // but we have the publicKey computed from passphrase - delete res.publicKey; - } - - const amount = res.balance - this.account.get().balance; - if (amount > 0) { - this.notify.about('deposit', amount); - } - - this.account.set(res); - }) - .catch((res) => { - this.account.set({ balance: null }); - return this.$q.reject(res); - }) - .finally(() => this.$q.resolve()); - } - }, -}); diff --git a/src/components/main/main.less b/src/components/main/main.less deleted file mode 100644 index 49d10c2ac..000000000 --- a/src/components/main/main.less +++ /dev/null @@ -1,9 +0,0 @@ -md-tabs.main-tabs .md-tab.md-active { - background: white; - box-shadow: none; -} -md-tabs.main-tabs md-tabs-wrapper.md-stretch-tabs md-pagination-wrapper { - padding-left: 0; - box-shadow: 0px 1px 1px 1px rgba(0, 0, 0, 0.12); - margin-left: 2px; -} diff --git a/src/components/main/main.pug b/src/components/main/main.pug deleted file mode 100644 index d3e89eb36..000000000 --- a/src/components/main/main.pug +++ /dev/null @@ -1,9 +0,0 @@ -top -md-tabs.main-tabs.offline-hide(md-stretch-tabs='always') - md-tab(data-ui-sref='main.transactions', md-active='$ctrl.activeTab === "main.transactions"') - md-tab-label Transactions - md-tab(data-ui-sref='main.voting', md-active='$ctrl.activeTab === "main.voting"') - md-tab-label Voting - md-tab(data-ng-if='$ctrl.account.get().isDelegate', data-ui-sref='main.forging', md-active='$ctrl.activeTab === "main.forging"') - md-tab-label Forging -div(data-ui-view) diff --git a/src/components/main/secondPass.js b/src/components/main/secondPass.js deleted file mode 100644 index 92be8a045..000000000 --- a/src/components/main/secondPass.js +++ /dev/null @@ -1,47 +0,0 @@ -import './secondPass.less'; - -/** - * The directive to show the second passphrase form and register it using AccountApi - * - * @module app - * @submodule SetSecondPassCtrl - */ -app.component('setSecondPass', { - template: require('./secondPass.pug')(), - controller($scope, Account, $rootScope, dialog, AccountApi, $mdDialog) { - this.fee = 5; - /** - * We call this after second passphrase is generated. - * Shows an alert with appropriate message in case the request fails. - * - * @param {String} secondSecret - The validated passphrase to register as second secret - */ - $scope.passConfirmSubmit = (secondSecret) => { - AccountApi.setSecondSecret(secondSecret, Account.get().publicKey, Account.get().passphrase) - .then(() => { - dialog.successAlert({ - text: 'Second passphrase registration was successfully submitted. It can take several seconds before it is processed.', - }); - }) - .catch((err) => { - let text = ''; - if (err.message === 'Missing sender second signature') { - text = 'You already have a second passphrase.'; - } else if (/^(Account does not have enough LSK)/.test(err.message)) { - text = 'You have insufficient funds to register a second passphrase.'; - } else { - text = err.message || 'An error occurred while registering your second passphrase. Please try again.'; - } - dialog.errorAlert({ text }); - }); - }; - - $scope.onSave = (secondPass) => { - $scope.passConfirmSubmit(secondPass); - }; - - $scope.cancel = function () { - $mdDialog.hide(); - }; - }, -}); diff --git a/src/components/main/secondPass.less b/src/components/main/secondPass.less deleted file mode 100644 index 600f9ceed..000000000 --- a/src/components/main/secondPass.less +++ /dev/null @@ -1,4 +0,0 @@ -md-dialog.dialog-second { - width: 80%; - max-width: 1000px; -} \ No newline at end of file diff --git a/src/components/main/secondPass.pug b/src/components/main/secondPass.pug deleted file mode 100644 index 01d50001a..000000000 --- a/src/components/main/secondPass.pug +++ /dev/null @@ -1,25 +0,0 @@ -div.dialog-second(aria-label='Generate a second passphrase for your account', data-ng-init='$ctrl.step = 0') - md-toolbar - .md-toolbar-tools - h2 Generate a second passphrase of your account - span(flex='') - md-button.md-icon-button(ng-click='cancel()', aria-label='Close dialog') - i.material-icons close - div(layout='row', layout-padding, layout-align="left center", data-ng-if='$ctrl.step === 0') - div(layout-margin) - i.material-icons info - p - span Please click Next, then move around your mouse randomly to generate a random passphrase. - br - br - span Note: After registration completes, your second passphrase will be required for all transactions sent from this account. - br - span Losing access to this passphrase will mean no funds can be sent from this account. So be sure to keep it safe! - - md-dialog-actions(layout='row', data-ng-if='$ctrl.step === 0') - md-button.md-secondary(ng-disabled='$ctrl.loading', ng-click='cancel()') Cancel - span(flex) - fee(data-fee='$ctrl.fee') - md-button.md-raised.md-primary.submit-button.next-button(ng-click='$ctrl.step = 1', ng-disabled='$ctrl.fee | fundsInsufficiency') Next - section(data-ng-if='$ctrl.step === 1') - passphrase(data-on-save='onSave', data-label='Register', data-fee='{{$ctrl.fee}}') diff --git a/src/components/offlineWrapper/index.js b/src/components/offlineWrapper/index.js new file mode 100644 index 000000000..786094495 --- /dev/null +++ b/src/components/offlineWrapper/index.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import styles from './offlineWrapper.css'; + +export const OfflineWrapperComponent = props => ( + + { props.children } + +); + + +const mapStateToProps = state => ({ + offline: state.loading && state.loading.indexOf('offline') > -1, +}); + +export default connect(mapStateToProps)(OfflineWrapperComponent); diff --git a/src/components/offlineWrapper/index.test.js b/src/components/offlineWrapper/index.test.js new file mode 100644 index 000000000..1823dec1d --- /dev/null +++ b/src/components/offlineWrapper/index.test.js @@ -0,0 +1,42 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount, shallow } from 'enzyme'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import OfflineWrapperHOC, { OfflineWrapperComponent } from './index'; +import styles from './offlineWrapper.css'; + +const fakeStore = configureStore(); + +describe('OfflineWrapper', () => { + it('renders props.children inside a span with "offline" class if props.offline', () => { + const wrapper = shallow( +

); + expect(wrapper).to.contain(

); + expect(wrapper).to.have.className(styles.isOffline); + }); + + it('renders without "offline" class if props.offline', () => { + const wrapper = shallow( +

); + expect(wrapper).not.to.have.className(styles.isOffline); + }); +}); + +describe('OfflineWrapperHOC', () => { + it('should set props.offline = false if "offline" is not in store.loading', () => { + const store = fakeStore({ + loading: [], + }); + const wrapper = mount(); + expect(wrapper.find(OfflineWrapperComponent).props().offline).to.equal(false); + }); + + it('should set props.offline = true if "offline" is in store.loading', () => { + const store = fakeStore({ + loading: ['offline'], + }); + const wrapper = mount(); + expect(wrapper.find(OfflineWrapperComponent).props().offline).to.equal(true); + }); +}); diff --git a/src/components/offlineWrapper/offlineWrapper.css b/src/components/offlineWrapper/offlineWrapper.css new file mode 100644 index 000000000..a1e8feaf7 --- /dev/null +++ b/src/components/offlineWrapper/offlineWrapper.css @@ -0,0 +1,4 @@ +.isOffline .disableWhenOffline { + opacity: 0.5; + pointer-events: none; +} diff --git a/src/components/openDialog/openDialog.js b/src/components/openDialog/openDialog.js deleted file mode 100644 index 0f3c67d22..000000000 --- a/src/components/openDialog/openDialog.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * a directive to use dialog.modal as a directive - */ -app.directive('openDialog', (dialog) => { - const linkFunc = ($scope, $element) => { - $element.bind('click', () => { - dialog.modal($scope.openDialog, $scope.options); - }); - }; - return { - scope: { - options: '=', - openDialog: '@', - }, - link: linkFunc, - }; -}); diff --git a/src/components/passphrase/index.js b/src/components/passphrase/index.js new file mode 100644 index 000000000..a3b70ca79 --- /dev/null +++ b/src/components/passphrase/index.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import Passphrase from './passphrase'; +import { accountUpdated } from '../../actions/account'; +import { activePeerSet } from '../../actions/peers'; + +/** + * Using react-redux connect to pass state and dispatch to LoginForm + */ +const mapStateToProps = state => ({ + account: state.account, + peers: state.peers, +}); + +const mapDispatchToProps = dispatch => ({ + onAccountUpdated: data => dispatch(accountUpdated(data)), + activePeerSet: network => dispatch(activePeerSet(network)), +}); + +const PassphraseConnected = connect( + mapStateToProps, + mapDispatchToProps, +)(Passphrase); + +export default PassphraseConnected; diff --git a/src/components/passphrase/passphrase.css b/src/components/passphrase/passphrase.css new file mode 100644 index 000000000..b8da596d6 --- /dev/null +++ b/src/components/passphrase/passphrase.css @@ -0,0 +1,32 @@ +.byte { + display: inline-block; + text-align: center; + font-size: 140%; + margin: 5px; + font-family: monospace; + transition: all ease 300ms; +} + +.missing { + padding: 0 5px; + font-weight: bold; + color: #0288d1; +} + +.stable { + transform: scale(1); + display: inline-block; + transition: all ease 300ms; +} + +.bouncing { + transform: scale(1.2); +} + +hr { + display: none; +} + +.templateItem { + min-height: 130px; +} diff --git a/src/components/passphrase/passphrase.js b/src/components/passphrase/passphrase.js index 7eafffdac..e576836f6 100644 --- a/src/components/passphrase/passphrase.js +++ b/src/components/passphrase/passphrase.js @@ -1,74 +1,84 @@ -import './passphrase.less'; +import React from 'react'; +import Input from 'react-toolbox/lib/input'; +import grid from 'flexboxgrid/dist/flexboxgrid.css'; +import styles from './passphrase.css'; +import InfoParagraph from '../infoParagraph'; +import PassphraseGenerator from './passphraseGenerator'; +import PassphraseVerifier from './passphraseVerifier'; +import ActionBar from '../actionBar'; +import stepsConfig from './steps'; -app.directive('passphrase', ($rootScope, $document, Passphrase, dialog) => { - /* eslint no-param-reassign: ["error", { "props": false }] */ - const PassphraseLink = function (scope, element, attrs) { - const bindEvents = (listener) => { - $document.bind('mousemove', listener); +class Passphrase extends React.Component { + constructor() { + super(); + this.state = { + current: 'info', + answer: '', }; + } - const unbindEvents = (listener) => { - $document.unbind('mousemove', listener); - }; + changeHandler(name, value) { + this.setState({ [name]: value }); + } - scope.$on('$destroy', () => { - unbindEvents(); - }); + render() { + const templates = {}; + const { current } = this.state; + const steps = stepsConfig(this); - /** - * Uses passphrase.generatePassPhrase to generate passphrase from a given seed - * Randomly asks for one of the words in passphrase to ensure it's noted down - * - * @param {string[]} seed - The array of 16 hex numbers in string format - * @todo Why we're broadcasting onAfterSignup here? - * Isn't this only related to login component? - */ - const generateAndDoubleCheck = (seed) => { - const passphrase = Passphrase.generatePassPhrase(seed); + const useCaseNote = 'your passphrase will be required for logging in to your account.'; + const securityNote = 'This passphrase is not recoverable and if you lose it, you will lose access to your account forever.'; - dialog.modal('save-passphrase', { - passphrase, - label: attrs.label, - fee: attrs.fee, - 'on-save': scope.onSave, - }); - }; + // Step 1: Information/introduction + templates.info = + Please click Next, then move around your mouse randomly to generate a random passphrase. +
+
+ Note: After registration completes, { this.props.useCaseNote || useCaseNote } +
+ { this.props.securityNote || securityNote } Please keep it safe! +
; - const terminate = (seed) => { - unbindEvents(Passphrase.listene); - generateAndDoubleCheck(seed); - }; + // step 2: Generator, binds mouse events + templates.generate = ; - scope.simulateMousemove = () => { - $document.mousemove(); - }; + // step 3: Confirmation, shows the generated passphrase for user to save it + templates.show = ; - /** - * Tests useragent with a regexp and defines if the account is mobile device - * - * @param {String} [agent] - The useragent string, This parameter is used for - * unit testing purpose - * @returns {Boolean} - whether the agent represents a mobile device or not - */ - scope.mobileAndTabletcheck = (agent) => { - let check = false; - if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test(agent || navigator.userAgent || navigator.vendor || window.opera)) { - check = true; - } - return check; - }; + // step 4: Verification, Asks for a random word to make sure the user has copied the passphrase + templates.confirm = ; + + return ( +
+
+
+ { templates[current] } +
+
- Passphrase.init(); - bindEvents(e => Passphrase.listener(e, terminate)); - scope.progress = Passphrase.progress; - }; + +
+ ); + } +} - return { - link: PassphraseLink, - restrict: 'E', - scope: { - onSave: '=', - }, - template: require('./passphrase.pug')(), - }; -}); +export default Passphrase; diff --git a/src/components/passphrase/passphrase.less b/src/components/passphrase/passphrase.less deleted file mode 100644 index d1aeb5f80..000000000 --- a/src/components/passphrase/passphrase.less +++ /dev/null @@ -1,19 +0,0 @@ -md-content.bytes .byte { - display: inline-block; - text-align: center; - font-size: 140%; - margin: 5px; - font-family: monospace; - - &.change-add, &.change-remove { - transition: all .15s ease; - } - - &.change, &.change-add-active, &.change-remove { - transform: scale(1.3); - } - - &.change-remove-active { - transform: none; - } - } \ No newline at end of file diff --git a/src/components/passphrase/passphrase.pug b/src/components/passphrase/passphrase.pug deleted file mode 100644 index 5692044bc..000000000 --- a/src/components/passphrase/passphrase.pug +++ /dev/null @@ -1,7 +0,0 @@ -md-content(layout-padding, layout='column', layout-align='center center') - h4.move(ng-show='mobileAndTabletcheck()') Enter text below to generate random bytes - h4.move(ng-hide='mobileAndTabletcheck()') Move your mouse to generate random bytes - input.random-input(type="text", ng-keydown='simulateMousemove()', ng-show='mobileAndTabletcheck()') - md-progress-linear(md-mode='determinate', value='{{ progress.percentage }}') - md-content.bytes - span.byte(ng-repeat='byte in progress.seed track by $index', ng-bind='byte', animate-on-change='byte') diff --git a/src/components/passphrase/passphrase.test.js b/src/components/passphrase/passphrase.test.js new file mode 100644 index 000000000..27f0b3356 --- /dev/null +++ b/src/components/passphrase/passphrase.test.js @@ -0,0 +1,35 @@ +import React from 'react'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { Provider } from 'react-redux'; +import { mount } from 'enzyme'; +import store from '../../store'; +import Passphrase from './passphrase'; + + +describe('Passphrase Component', () => { + let wrapper; + const clock = sinon.useFakeTimers(); + + beforeEach(() => { + wrapper = mount(); + }); + + it('should render 2 buttons', () => { + expect(wrapper.find('button')).to.have.lengthOf(2); + }); + + it('should initially render InfoParagraph', () => { + expect(wrapper.find('InfoParagraph')).to.have.lengthOf(1); + }); + + it.skip('should render PassphraseGenerator component if step is equal info', () => { + wrapper.find('.primary-button').simulate('click'); + clock.tick(100); + expect(wrapper.find('PassphraseGenerator')).to.have.lengthOf(1); + }); + + it.skip('should render PassphraseVerifier component if step is equal confirm', () => { + expect(wrapper.find('PassphraseVerifier')).to.have.lengthOf(1); + }); +}); diff --git a/src/components/passphrase/passphraseGenerator.js b/src/components/passphrase/passphraseGenerator.js new file mode 100644 index 000000000..d8e300780 --- /dev/null +++ b/src/components/passphrase/passphraseGenerator.js @@ -0,0 +1,120 @@ +import React from 'react'; +import AnimateOnChange from 'react-animate-on-change'; +import ProgressBar from 'react-toolbox/lib/progress_bar'; +import Input from 'react-toolbox/lib/input'; +import grid from 'flexboxgrid/dist/flexboxgrid.css'; +import { generateSeed, generatePassphrase, emptyByte } from '../../utils/passphrase'; +import styles from './passphrase.css'; + + +const Byte = props => ( + + { props.value } + +); + +class PassphraseGenerator extends React.Component { + constructor() { + super(); + this.state = { + step: 'info', + lastCaptured: { + x: 0, + y: 0, + }, + zeroSeed: emptyByte('00'), + seedDiff: emptyByte(0), + }; + this.seedGeneratorBoundToThis = this.seedGenerator.bind(this); + document.addEventListener('mousemove', this.seedGeneratorBoundToThis, true); + } + + componentWillUnmount() { + document.removeEventListener('mousemove', this.seedGeneratorBoundToThis, true); + } + + /** + * Tests useragent with a regexp and defines if the account is mobile device + * + * @param {String} [agent] - The useragent string, This parameter is used for + * unit testing purpose + * @returns {Boolean} - whether the agent represents a mobile device or not + */ + // it is on class so that we can mock it in unit tests + // eslint-disable-next-line class-methods-use-this + isTouchDevice(agent) { + return (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test(agent || navigator.userAgent || navigator.vendor || window.opera)); + } + + seedGenerator(nativeEvent) { + let shouldTrigger; + if (typeof nativeEvent === 'string') { + shouldTrigger = true; + } else { + const distance = + Math.sqrt(((nativeEvent.pageX - this.state.lastCaptured.x) ** 2) + + ((nativeEvent.pageY - this.state.lastCaptured.y) ** 2)); + shouldTrigger = distance > 120; + } + + if (shouldTrigger && (!this.state.data || this.state.data.percentage < 100)) { + this.setState({ + lastCaptured: { + x: nativeEvent.pageX, + y: nativeEvent.pageY, + }, + }); + + // defining diffSeed to use for animating HEX numbers + // note: in the first iteration data is undefined + const oldSeed = this.state.data ? this.state.data.seed : this.state.zeroSeed; + const data = generateSeed(this.state.data); + const seedDiff = oldSeed.map((item, index) => + ((item !== data.seed[index]) ? index : null)) + .filter(item => item !== null); + this.setState({ data, seedDiff }); + } else if (this.state.data && this.state.data.percentage >= 100 && !this.state.passphrase) { + // also change the step here + const phrase = generatePassphrase(this.state.data); + this.setState({ + passphrase: phrase, + }); + this.props.changeHandler('passphrase', phrase); + this.props.changeHandler('current', 'show'); + } + } + + render() { + return ( +
+
+ {this.isTouchDevice() ? +
+

Enter text below to generate random bytes

+ +
: +

Move your mouse to generate random bytes

+ } +
+
+ +
+
+ { + (this.state.data ? this.state.data.seed : this.state.zeroSeed) + .map((byte, index) => ( + = 0} /> + )) + } +
+
+ ); + } +} + +export default PassphraseGenerator; diff --git a/src/components/passphrase/passphraseGenerator.test.js b/src/components/passphrase/passphraseGenerator.test.js new file mode 100644 index 000000000..216add9ba --- /dev/null +++ b/src/components/passphrase/passphraseGenerator.test.js @@ -0,0 +1,95 @@ +import React from 'react'; +import { expect } from 'chai'; +import { spy, mock } from 'sinon'; +import { mount, shallow } from 'enzyme'; +import PassphraseGenerator from './passphraseGenerator'; + + +describe('PassphraseConfirmator', () => { + describe('seedGenerator', () => { + const props = { + changeHandler: () => {}, + }; + const mockEvent = { + pageX: 140, + pageY: 140, + }; + + it('calls setState to setValues locally', () => { + const wrapper = shallow(); + const spyFn = spy(wrapper.instance(), 'setState'); + wrapper.instance().seedGenerator(mockEvent); + expect(spyFn).to.have.been.calledWith(); + wrapper.instance().setState.restore(); + }); + + it('shows an Input fallback if this.isTouchDevice()', () => { + const wrapper = mount(); + const isTouchDeviceMock = mock(wrapper.instance()).expects('isTouchDevice'); + isTouchDeviceMock.returns(true); + wrapper.instance().setState({}); // to rerender the component + expect(wrapper.find('.touch-fallback textarea')).to.have.lengthOf(1); + }); + + it('shows at least some progress on pressing input if this.isTouchDevice()', () => { + const wrapper = mount(); + const isTouchDeviceMock = mock(wrapper.instance()).expects('isTouchDevice'); + isTouchDeviceMock.returns(true).twice(); + wrapper.instance().setState({}); // to rerender the component + wrapper.find('.touch-fallback textarea').simulate('change', { target: { value: 'random key presses' } }); + expect(wrapper.find('ProgressBar').props().value).to.be.at.least(1); + }); + + it('removes mousemove event listener in componentWillUnmount', () => { + const wrapper = mount(); + const documentSpy = spy(document, 'removeEventListener'); + wrapper.instance().componentWillUnmount(); + expect(documentSpy).to.have.be.been.calledWith('mousemove'); + documentSpy.restore(); + }); + + it('sets "data" and "lastCaptured" if distance is over 120', () => { + const wrapper = shallow(); + wrapper.instance().seedGenerator(mockEvent); + + expect(wrapper.instance().state.data).to.not.equal(undefined); + expect(wrapper.instance().state.lastCaptured).to.deep.equal({ + x: 140, + y: 140, + }); + }); + + it('should do nothing if distance is bellow 120', () => { + const wrapper = shallow(); + const nativeEvent = { + pageX: 10, + pageY: 10, + }; + wrapper.instance().seedGenerator(nativeEvent); + + expect(wrapper.instance().state.data).to.be.equal(undefined); + expect(wrapper.instance().state.lastCaptured).to.deep.equal({ + x: 0, + y: 0, + }); + }); + + it('should generate passphrase if seed is completed', () => { + const wrapper = shallow(); + // set mock data + wrapper.instance().setState({ + data: { + seed: ['e6', '3c', 'd1', '36', 'e9', '70', '5f', + 'c0', '4d', '31', 'ef', 'b8', 'd6', '53', '48', '11'], + percentage: 100, + step: 1, + byte: [0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1, 0], + }, + }); + + wrapper.instance().seedGenerator(mockEvent); + + expect(wrapper.instance().state.passphrase).to.not.equal(undefined); + }); + }); +}); diff --git a/src/components/passphrase/passphraseService.js b/src/components/passphrase/passphraseService.js deleted file mode 100644 index 5d2ce52c4..000000000 --- a/src/components/passphrase/passphraseService.js +++ /dev/null @@ -1,142 +0,0 @@ -import crypto from 'crypto'; -import mnemonic from 'bitcore-mnemonic'; - -/* eslint no-param-reassign: ["error", { "props": false }] */ - -app.factory('Passphrase', function ($rootScope) { - this.progress = { - seed: null, - percentage: 0, - step: 0, - }; - const lastCaptured = { - coordination: { - x: 0, - y: 0, - }, - time: 0, - }; - let byte = null; - - const emptyBytes = () => [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; - - /** - * Returns a single space separated lower-cased string from a given string. - * - * @param {String} [str = ''] - The string to get normalized. - * @returns {string} - The single space separated lower-cased string - */ - this.normalize = (str = '') => str.replace(/ +/g, ' ').trim().toLowerCase(); - - this.reset = () => { - this.progress.percentage = 0; - this.progress.seed = emptyBytes().map(() => '00'); - }; - - /** - * fills the left side of str with a given padding string to meet the required length - * - * @param {String} str - The string to fill with pad - * @param {String} pad - The string used as padding - * @param {Number} length - The final length of the string after adding padding - * @private - * @returns {string} padded string - */ - const leftPadd = (str, pad, length) => { - let paddedStr = str; - while (paddedStr.length < length) paddedStr = pad + paddedStr; - return paddedStr; - }; - - /** - * Checks if given value is a valid passphrase - * - * @param {String} value - * @returns {number} 0, 1, 2, respectively if invalid, valid or empty string. - */ - this.isValidPassphrase = (value) => { - const normalizedValue = this.normalize(value); - - if (normalizedValue === '') { - return 2; - } else if (normalizedValue.split(' ').length < 12 || !mnemonic.isValid(normalizedValue)) { - return 0; - } - return 1; - }; - - /** - * Resets previous settings and creates a step with a random length between 1.6% to 3.2% - */ - this.init = () => { - this.reset(); - byte = emptyBytes(); - this.progress.step = (160 + Math.floor(Math.random() * 160)) / 100; - }; - - /** - * - From a zero byte: - * - Removes all the 1s and replaces all the 1s with their index - * - Creates a random number with the length of resulting array (pos) - * - sets the bit in the pos position - * - creates random byte using crypto and assigns that to seed in the - * position of pos - * - Repeats this until the length of the given byte is zero. - * - * @returns {number[]} The input array whose member is pos is set - */ - const updateSeedAndProgress = () => { - let pos; - const available = byte.map((bit, index) => (!bit ? index : null)).filter(bit => (bit !== null)); - if (!available.length) { - byte = byte.map(() => 0); - pos = parseInt(Math.random() * byte.length, 10); - } else { - pos = available[parseInt(Math.random() * available.length, 10)]; - } - - this.progress.seed[pos] = leftPadd(crypto.randomBytes(1)[0].toString(16), '0', 2); - - /** - * @todo why it's not working without manual digestion - */ - if ($rootScope.$$phase !== '$apply' && $rootScope.$$phase !== '$digest') { - $rootScope.$apply(); - } - - byte[pos] = 1; - return byte; - }; - - /** - * Generates a passphrase from a given seed array using mnemonic - * - * @param {string[]} seed - An array of 16 hex numbers in string format - * @returns {string} The generated passphrase - */ - this.generatePassPhrase = seed => (new mnemonic(new Buffer(seed.join(''), 'hex'))).toString(); - - this.listener = (ev, callback) => { - const distance = Math.sqrt(Math.pow(ev.pageX - lastCaptured.coordination.x, 2) + - (Math.pow(ev.pageY - lastCaptured.coordination.y), 2)); - - if (distance > 120 || ev.isTrigger) { - for (let p = 0; p < 2; p++) { - if (this.progress.percentage >= 100) { - callback(this.progress.seed); - return; - } - - if (!ev.isTrigger) { - lastCaptured.coordination.x = ev.pageX; - lastCaptured.coordination.y = ev.pageY; - } - - this.progress.percentage += this.progress.step; - byte = updateSeedAndProgress(byte); - } - } - }; - - return this; -}); diff --git a/src/components/passphrase/passphraseVerifier.js b/src/components/passphrase/passphraseVerifier.js new file mode 100644 index 000000000..e097f53f4 --- /dev/null +++ b/src/components/passphrase/passphraseVerifier.js @@ -0,0 +1,62 @@ +import React from 'react'; +import Input from 'react-toolbox/lib/input'; +import grid from 'flexboxgrid/dist/flexboxgrid.css'; +import styles from './passphrase.css'; + +class PassphraseConfirmator extends React.Component { + constructor() { + super(); + this.state = { + passphraseParts: [], + }; + } + + componentDidMount() { + this.props.updateAnswer(false); + this.state = { + passphraseParts: this.hideRandomWord.call(this), + }; + } + + hideRandomWord(rand = Math.random()) { + const words = this.props.passphrase.trim().split(/\s+/); + const index = Math.floor(rand * (words.length - 1)); + + this.setState({ + passphraseParts: this.props.passphrase.split(` ${words[index]} `), + missing: words[index], + answer: '', + }); + } + + changeHandler(value) { + this.props.updateAnswer(value === this.state.missing); + } + + // eslint-disable-next-line + focus({ nativeEvent }) { + nativeEvent.target.focus(); + } + + render() { + return ( +
+
+

+ {this.state.passphraseParts[0]} + ----- + {this.state.passphraseParts[1]} +

+
+
+ +
+
+ ); + } +} + +export default PassphraseConfirmator; diff --git a/src/components/passphrase/passphraseVerifier.test.js b/src/components/passphrase/passphraseVerifier.test.js new file mode 100644 index 000000000..6415ce6dd --- /dev/null +++ b/src/components/passphrase/passphraseVerifier.test.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { mount, shallow } from 'enzyme'; +import PassphraseVerifier from './passphraseVerifier'; + + +describe('PassphraseVerifier', () => { + const props = { + updateAnswer: () => {}, + passphrase: 'survey stereo pool fortune oblige slight gravity goddess mistake sentence anchor pool', + }; + + describe('componentDidMount', () => { + it('should call updateAnswer with "false"', () => { + const spyFn = spy(props, 'updateAnswer'); + mount(); + expect(spyFn).to.have.been.calledWith(); + props.updateAnswer.restore(); + }); + }); + + describe('changeHandler', () => { + it('call updateAnswer with received value', () => { + const spyFn = spy(props, 'updateAnswer'); + const value = 'sample'; + const wrapper = shallow(); + wrapper.instance().changeHandler(value); + expect(spyFn).to.have.been.calledWith(); + props.updateAnswer.restore(); + }); + }); + + describe('hideRandomWord', () => { + it('should break passphrase, hide a word and store all in state', () => { + const wrapper = shallow(); + + const randomIndex = 0.6; + const expectedValues = { + passphraseParts: [ + 'survey stereo pool fortune oblige slight', + 'goddess mistake sentence anchor pool', + ], + missing: 'gravity', + answer: '', + }; + const spyFn = spy(wrapper.instance(), 'setState'); + + wrapper.instance().hideRandomWord(randomIndex); + expect(spyFn).to.have.been.calledWith(expectedValues); + }); + }); +}); diff --git a/src/components/passphrase/savePassphrase.js b/src/components/passphrase/savePassphrase.js deleted file mode 100644 index 1bfc5920a..000000000 --- a/src/components/passphrase/savePassphrase.js +++ /dev/null @@ -1,53 +0,0 @@ -import './savePassphrase.less'; - -app.component('savePassphrase', { - template: require('./savePassphrase.pug')(), - bindings: { - passphrase: '<', - label: '<', - fee: '<', - onSave: '=', - }, - controller: class savePassphrase { - constructor($scope, $rootScope, $mdDialog) { - this.$mdDialog = $mdDialog; - this.$rootScope = $rootScope; - - this.step = 1; - - $scope.$watch('$ctrl.missing_input', () => { - this.missing_ok = this.missing_input && this.missing_input === this.missing_word; - }); - } - - next() { - this.step = 2; - - const words = this.passphrase.split(' '); - const missingNumber = parseInt(Math.random() * words.length, 10); - - this.missing_word = words[missingNumber]; - this.pre = words.slice(0, missingNumber).join(' '); - this.pos = words.slice(missingNumber + 1).join(' '); - } - - ok() { - this.$mdDialog.hide(); - this.onSave(this.passphrase); - } - - back() { - this.step = 1; - } - - close() { - this.$mdDialog.cancel(); - this.$rootScope.$broadcast('onSignupCancel'); - } - - preventBlur(event) { //eslint-disable-line - angular.element(event.currentTarget)[0].focus(); - } - }, -}); - diff --git a/src/components/passphrase/savePassphrase.less b/src/components/passphrase/savePassphrase.less deleted file mode 100644 index 2b15240bb..000000000 --- a/src/components/passphrase/savePassphrase.less +++ /dev/null @@ -1,18 +0,0 @@ -save-passphrase { - .missing { - padding: 0 5px 0; - font-weight: bold; - color: rgb(2,136,209); - } - .fee { - position: absolute; - left: auto; - right: 6px; - bottom: 7px; - font-size: 12px; - line-height: 14px; - transition: all 0.3s cubic-bezier(0.55, 0, 0.55, 0.2); - color: grey; - } -} - diff --git a/src/components/passphrase/savePassphrase.pug b/src/components/passphrase/savePassphrase.pug deleted file mode 100644 index f0c807606..000000000 --- a/src/components/passphrase/savePassphrase.pug +++ /dev/null @@ -1,37 +0,0 @@ -form(ng-if='$ctrl.step === 1') - md-toolbar - .md-toolbar-tools - h2 Save your passphrase in a safe place! - span(flex='') - md-button.md-icon-button(ng-click='$ctrl.close()', aria-label='Close dialog') - i.material-icons close - md-dialog-content - .md-dialog-content - md-input-container.md-block - textarea.passphrase(ng-bind='$ctrl.passphrase', md-autofocus, readonly, aria-label='Passphrase', ng-blur='$ctrl.preventBlur($event)') - md-dialog-actions(layout='row') - md-button.close-button(ng-click="$ctrl.close()") Cancel - span(flex) - md-button.md-raised.md-primary.yes-its-save-button(ng-click="$ctrl.next()") Yes! It's safe! -form(ng-if='$ctrl.step === 2') - md-toolbar - .md-toolbar-tools - h2 Enter the missing word to continue - span(flex='') - md-button.md-icon-button(ng-click='$ctrl.close()', aria-label='Close dialog') - i.material-icons close - md-dialog-content - .md-dialog-content - div - p.passphrase - span {{ $ctrl.pre }} - span.missing ----- - span {{ $ctrl.pos }} - md-input-container.md-block(md-is-error='!$ctrl.missing_ok') - label Enter the missing word - input(ng-model='$ctrl.missing_input', md-autofocus, aria-label='Enter the missing word') - div.fee(ng-if='$ctrl.fee') Fee: {{$ctrl.fee}} LSK - md-dialog-actions(layout='row') - md-button.back-button(ng-click="$ctrl.back()") Back - span(flex) - md-button.md-raised.md-primary.ok-button(ng-click="$ctrl.ok()", ng-disabled='!$ctrl.missing_ok', ng-bind='$ctrl.label') diff --git a/src/components/passphrase/steps.js b/src/components/passphrase/steps.js new file mode 100644 index 000000000..5f5ed9012 --- /dev/null +++ b/src/components/passphrase/steps.js @@ -0,0 +1,51 @@ +export default context => ({ + info: { + cancelButton: { + title: 'cancel', + onClick: () => { context.props.closeDialog(); }, + }, + confirmButton: { + title: () => 'next', + fee: () => context.props.fee, + onClick: () => { context.setState({ current: 'generate' }); }, + }, + }, + generate: { + cancelButton: { + title: 'cancel', + onClick: () => { context.props.closeDialog(); }, + }, + confirmButton: { + title: () => 'Next', + fee: () => {}, + onClick: () => {}, + }, + }, + show: { + cancelButton: { + title: 'cancel', + onClick: () => { context.props.closeDialog(); }, + }, + confirmButton: { + title: () => 'Yes! It\'s safe', + fee: () => {}, + onClick: () => { context.setState({ current: 'confirm' }); }, + }, + }, + confirm: { + cancelButton: { + title: 'Back', + onClick: () => { context.setState({ current: 'show' }); }, + }, + confirmButton: { + title: () => (context.props.confirmButton || 'Login'), + fee: () => {}, + onClick: () => { + context.props.onPassGenerated(context.state.passphrase); + if (!context.props.keepModal) { + context.props.closeDialog(); + } + }, + }, + }, +}); diff --git a/src/components/passphrase/steps.test.js b/src/components/passphrase/steps.test.js new file mode 100644 index 000000000..37d9a5a02 --- /dev/null +++ b/src/components/passphrase/steps.test.js @@ -0,0 +1,36 @@ +import { expect } from 'chai'; +import steps from './steps'; + +describe('Passphrase: steps', () => { + const stepNames = ['info', 'generate', 'show', 'confirm']; + const context = { + props: { + closeDialog: () => {}, + onPassGenerated: () => {}, + }, + setState: () => {}, + state: {}, + }; + const returnSteps = steps(context); + + it('should return an object including defined steps', () => { + const returnedKeys = Object.keys(returnSteps); + expect(returnedKeys).to.deep.equal(stepNames); + }); + + stepNames.forEach((step) => { + describe(`step: ${step}`, () => { + it('should have a confirmButton with title and onClick', () => { + expect(returnSteps[step].confirmButton.title).to.not.equal(undefined); + expect(returnSteps[step].confirmButton.onClick).to.not.equal('function'); + expect(returnSteps[step].confirmButton.onClick()).to.be.equal(undefined); + }); + + it('should have a cancelButton with title and onClick', () => { + expect(returnSteps[step].cancelButton.title).to.not.equal(undefined); + expect(returnSteps[step].cancelButton.onClick).to.not.equal('function'); + expect(returnSteps[step].cancelButton.onClick()).to.be.equal(undefined); + }); + }); + }); +}); diff --git a/src/components/pricedButton/index.js b/src/components/pricedButton/index.js new file mode 100644 index 000000000..568a43172 --- /dev/null +++ b/src/components/pricedButton/index.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Button from 'react-toolbox/lib/button'; +import { fromRawLsk } from '../../utils/lsk'; +import styles from './pricedButton.css'; + +export const PricedButtonComponent = ({ + balance, fee, label, customClassName, onClick, disabled, +}) => { + const hasFunds = balance >= fee; + return ( +
+ { + fee && + ( + { + hasFunds ? `Fee: ${fromRawLsk(fee)} LSK` : + `Insufficient funds for ${fromRawLsk(fee)} LSK fee` + } + ) + } +
+ ); +}; + +const mapStateToProps = state => ({ + balance: state.account.balance, +}); + +export default connect(mapStateToProps)(PricedButtonComponent); diff --git a/src/components/pricedButton/index.test.js b/src/components/pricedButton/index.test.js new file mode 100644 index 000000000..95d5aeb92 --- /dev/null +++ b/src/components/pricedButton/index.test.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { shallow } from 'enzyme'; +import Button from 'react-toolbox/lib/button'; +import { PricedButtonComponent } from './index'; + + +describe('PricedButton', () => { + let wrapper; + const props = { + fee: 5e8, + onClick: sinon.spy(), + }; + const insufficientBalance = 4.9999e8; + const sufficientBalance = 6e8; + + it('renders

+ ); + } +} +export default VotingHeader; diff --git a/src/components/voting/votingHeader.test.js b/src/components/voting/votingHeader.test.js new file mode 100644 index 000000000..dcf9c2329 --- /dev/null +++ b/src/components/voting/votingHeader.test.js @@ -0,0 +1,88 @@ +import React from 'react'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { mount } from 'enzyme'; +import configureStore from 'redux-mock-store'; +import PropTypes from 'prop-types'; +import VotingHeader from './votingHeader'; + +describe('VotingHeader', () => { + let wrapper; + const mockStore = configureStore(); + const props = { + store: mockStore({ runtime: {} }), + search: sinon.spy(), + votedDelegates: [ + { + username: 'yashar', + address: 'address 1', + }, + { + username: 'tom', + address: 'address 2', + }, + ], + votedList: [ + { + username: 'yashar', + address: 'address 1', + pending: true, + }, + { + username: 'tom', + address: 'address 2', + }, + ], + unvotedList: [ + { + username: 'yashar', + address: 'address 1', + }, + { + username: 'tom', + address: 'address 2', + pending: true, + }, + ], + setActiveDialog: () => {}, + addToUnvoted: sinon.spy(), + addToVoteList: sinon.spy(), + }; + + beforeEach(() => { + wrapper = mount(, { + context: { store: mockStore }, + childContextTypes: { store: PropTypes.object.isRequired }, + }); + }); + + it('should render an Input', () => { + expect(wrapper.find('Input')).to.have.lengthOf(1); + }); + it('should render 2 menuItem', () => { + expect(wrapper.find('MenuItem')).to.have.lengthOf(2); + }); + + it('should render i#searchIcon with text of "search" when this.search is not called', () => { + // expect(wrapper.find('i.material-icons')).to.have.lengthOf(1); + expect(wrapper.find('#searchIcon').text()).to.be.equal('search'); + }); + + it('should render i#searchIcon with text of "close" when this.search is called', () => { + wrapper.instance().search('query', '555'); + expect(wrapper.find('#searchIcon').text()).to.be.equal('close'); + }); + + it('should this.props.search when this.search is called', () => { + const clock = sinon.useFakeTimers(); + wrapper.instance().search('query', '555'); + clock.tick(250); + expect(props.search).to.have.been.calledWith('555'); + }); + + it('click on #searchIcon should clear value of search input', () => { + wrapper.instance().search('query', '555'); + wrapper.find('#searchIcon').simulate('click'); + expect(wrapper.state('query')).to.be.equal(''); + }); +}); diff --git a/src/components/voting/votingRow.js b/src/components/voting/votingRow.js new file mode 100644 index 000000000..69ced7c6e --- /dev/null +++ b/src/components/voting/votingRow.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { TableRow, TableCell } from 'react-toolbox/lib/table'; +import styles from './voting.css'; +import Checkbox from './voteCheckbox'; + +const setRowClass = ({ pending, selected, voted }) => { + if (pending) { + return styles.pendingRow; + } else if (selected) { + return voted ? styles.votedRow : styles.upVoteRow; + } + return voted ? styles.downVoteRow : ''; +}; + +class VotingRow extends React.Component { + // eslint-disable-next-line class-methods-use-this + shouldComponentUpdate(nextProps) { + return !!nextProps.data.dirty; + } + + render() { + const props = this.props; + const { data } = props; + return ( + + + + {data.rank} + {data.username} + {data.address} + {data.productivity} % + {data.approval} % + + ); + } +} + +export default VotingRow; diff --git a/src/components/voting/votingRow.test.js b/src/components/voting/votingRow.test.js new file mode 100644 index 000000000..aaa4e0175 --- /dev/null +++ b/src/components/voting/votingRow.test.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import PropTypes from 'prop-types'; +import store from '../../store'; +import VotinRow from './votingRow'; + +describe('VotinRow', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(, + { + context: { store }, + childContextTypes: { store: PropTypes.object.isRequired }, + }, + ); + }); + + it('should TableRow has class name of "pendingRow" when props.data.pending is true', () => { + wrapper.setProps({ + data: { pending: true, dirty: true }, + }); + const expectedClass = '_pendingRow'; + const className = wrapper.find('tr').prop('className'); + expect(className).to.contain(expectedClass); + }); + + it(`should TableRow has class name of "votedRow" when props.data.selected + and props.data.voted are true`, () => { + wrapper.setProps({ + data: { selected: true, voted: true, dirty: true }, + }); + const expectedClass = '_votedRow'; + const className = wrapper.find('tr').prop('className'); + expect(className).to.contain(expectedClass); + }); + + it(`should TableRow has class name of "downVoteRow" when props.data.selected + is false and props.data.voted is true`, () => { + wrapper.setProps({ + data: { selected: false, voted: true, dirty: true }, + }); + const expectedClass = '_downVoteRow'; + const className = wrapper.find('tr').prop('className'); + expect(className).to.contain(expectedClass); + }); + + it(`should TableRow has class name of "upVoteRow" when props.data.selected + is true and props.data.voted is false`, () => { + wrapper.setProps({ + data: { selected: true, voted: false, dirty: true }, + }); + const expectedClass = '_upVoteRow'; + const className = wrapper.find('tr').prop('className'); + expect(className).to.contain(expectedClass); + }); +}); diff --git a/src/constants/actions.js b/src/constants/actions.js new file mode 100644 index 000000000..3b240d885 --- /dev/null +++ b/src/constants/actions.js @@ -0,0 +1,29 @@ +const actionTypes = { + metronomeBeat: 'METRONOME_BEAT', + accountUpdated: 'ACCOUNT_UPDATED', + accountLoggedOut: 'ACCOUNT_LOGGED_OUT', + accountLoggedIn: 'ACCOUNT_LOGGED_IN', + activePeerSet: 'ACTIVE_PEER_SET', + activePeerUpdate: 'ACTIVE_PEER_UPDATE', + activePeerReset: 'ACTIVE_PEER_RESET', + dialogDisplayed: 'DIALOG_DISPLAYED', + dialogHidden: 'DIALOG_HIDDEN', + forgedBlocksUpdated: 'FORGED_BLOCKS_UPDATED', + forgingStatsUpdated: 'FORGING_STATS_UPDATED', + forgingReset: 'FORGING_RESET', + VotePlaced: 'VOTE_PLACED', + addedToVoteList: 'ADDED_TO_VOTE_LIST', + removedFromVoteList: 'REMOVEd_FROM_VOTE_LIST', + votesCleared: 'VOTES_CLEARED', + pendingVotesAdded: 'PENDING_VOTES_ADDED', + toastDisplayed: 'TOAST_DISPLAYED', + toastHidden: 'TOAST_HIDDEN', + loadingStarted: 'LOADING_STARTED', + loadingFinished: 'LOADING_FINISHED', + transactionAdded: 'TRANSACTION_ADDED', + transactionsUpdated: 'TRANSACTIONS_UPDATED', + transactionsLoaded: 'TRANSACTIONS_LOADED', + transactionsReset: 'TRANSACTIONS_RESET', +}; + +export default actionTypes; diff --git a/src/constants/api.js b/src/constants/api.js new file mode 100644 index 000000000..f11dc7ef4 --- /dev/null +++ b/src/constants/api.js @@ -0,0 +1,5 @@ +/** + * The interval of syncTick event + */ +export const SYNC_ACTIVE_INTERVAL = 10000; +export const SYNC_INACTIVE_INTERVAL = 120000; diff --git a/src/constants/env.js b/src/constants/env.js new file mode 100644 index 000000000..e306993df --- /dev/null +++ b/src/constants/env.js @@ -0,0 +1,7 @@ +const env = { + production: PRODUCTION, + test: TEST, + development: (!PRODUCTION && !TEST), +}; + +export default env; diff --git a/src/constants/fees.js b/src/constants/fees.js new file mode 100644 index 000000000..8343a7b16 --- /dev/null +++ b/src/constants/fees.js @@ -0,0 +1,6 @@ +export default { + setSecondPassphrase: 5e8, + send: 0.1e8, + registerDelegate: 25e8, + vote: 1e8, +}; diff --git a/src/constants/transactionTypes.js b/src/constants/transactionTypes.js new file mode 100644 index 000000000..ea2ac9f9a --- /dev/null +++ b/src/constants/transactionTypes.js @@ -0,0 +1,7 @@ +export default { + send: 0, + setSecondPassphrase: 1, + registerDelegate: 2, + vote: 3, +}; + diff --git a/src/filters/fundsInsufficiency.js b/src/filters/fundsInsufficiency.js deleted file mode 100644 index bfe5b8071..000000000 --- a/src/filters/fundsInsufficiency.js +++ /dev/null @@ -1,8 +0,0 @@ - -/** - * This filter returns bool value which is true if account balance is less then input value - * - * @module app - * @submodule fundsInsufficiency - */ -app.filter('fundsInsufficiency', (Account, lsk) => amount => lsk.normalize(Account.get().balance) < amount); diff --git a/src/filters/liskNumber.js b/src/filters/liskNumber.js deleted file mode 100644 index a9c0e325f..000000000 --- a/src/filters/liskNumber.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * This filter format numbers and add comma sperator to them - * - * @module app - */ -app.filter('liskNumber', ($filter) => { - const numberFilter = $filter('number'); - return (input) => { - const temp = input.toString().split('.'); - if (temp.length === 1) { - return numberFilter(temp[0]); - } - return `${numberFilter(temp[0])}.${temp[1]}`; - }; -}); diff --git a/src/filters/lsk.js b/src/filters/lsk.js deleted file mode 100644 index 31dc8d837..000000000 --- a/src/filters/lsk.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * This filter uses lsk factory to normalize the raw value in LSK - * - * @module app - * @submodule lsk - */ -app.filter('lsk', lsk => lsk.normalize); diff --git a/src/index.html b/src/index.html new file mode 100644 index 000000000..205c9bd7f --- /dev/null +++ b/src/index.html @@ -0,0 +1,14 @@ + + + + + Lisk Nano + + + +
+ + + + + diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 1ca3000a9..000000000 --- a/src/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import './libs'; -import './liskNano'; - -angular.element(document).ready(() => { - angular.bootstrap(document, ['app']); -}); diff --git a/src/index.less b/src/index.less deleted file mode 100644 index 71be6d58f..000000000 --- a/src/index.less +++ /dev/null @@ -1,124 +0,0 @@ - -@import './assets/fonts/roboto/style.less'; -@import './assets/fonts/roboto-mono/style.less'; -@import './assets/fonts/material-design-icons/style.less'; - -body { - min-width: 320px; - & > main, & > main > md-content { - height: 100%; - } -} - -.body-wrapper { - width: 100%; - background-color: #eee; -} - -md-card { - margin-bottom: 20px; -} - -login, login md-card { - display: block; - height: auto; -} - -body { - .logout { - margin-right: 0; - } - - md-tabs-wrapper { - margin: 0 10px; - } - - md-content { - background-color: #eee; - } - - md-card md-content { - background-color: #fff; - } - - md-tabs md-ink-bar { - color: rgb(2,136,209); - background-color: rgb(2,136,209); - height: 4px; - } - - .offline .offline-hide { - opacity: 0.5; - pointer-events: none; - } - - md-input-container { - overflow-x: initial; - label:not(.md-no-float):not(.md-container-ignore), .md-placeholder { - box-sizing: content-box; - } - } - - md-dialog { - md-dialog-actions { - border: 0; - padding: 24px; - .md-button { - margin: 0px; - } - } - } - - .md-toolbar-tools { - padding: 0 24px; - } -} - -md-toast.lsk-toast-success { - .md-toast-content { - background-color: #7cb342; - } -} - -md-toast.lsk-toast-error { - .md-toast-content { - background-color: #c62828; - } -} - -md-menu-item md-checkbox { - margin: 0; - padding: 8px; -} -md-menu-content { - border-radius: 2px; -} - -md-tabs.main-tabs { - margin: 0 0 -8px 0; - md-tabs-wrapper { - margin: 0 8px; - - md-tabs-canvas { - margin-left: -2px; - } - - &.md-stretch-tabs md-pagination-wrapper { - width: auto; - display: block; - padding-left: 2px; - } - md-ink-bar { - bottom: auto; - top: 0; - } - } - .md-tab { - background: #f4f4f4; - box-shadow: inset 0px -1px 1px -1px rgba(0, 0, 0, 0.12); - &.md-active { - background: white; - box-shadow: 0px 1px 1px 1px rgba(0, 0, 0, 0.12); - } - } -} diff --git a/src/index.pug b/src/index.pug deleted file mode 100644 index 7f1a2b4ec..000000000 --- a/src/index.pug +++ /dev/null @@ -1,16 +0,0 @@ -doctype html -html - head - meta(name="viewport" content="width=device-width, user-scalable=no") - title Lisk Nano - style(type='text/css'). - body { - background-color: #eee !important; - } - body - div.body-wrapper - md-content(id="main", flex='100', flex-gt-sm='80', flex-offset-gt-sm='10') - header - div(ng-class='{ online: $root.peers.online, offline: !$root.peers.online }') - div(data-ui-view) - loading-bar diff --git a/src/libs.js b/src/libs.js deleted file mode 100644 index 30f6016ef..000000000 --- a/src/libs.js +++ /dev/null @@ -1,17 +0,0 @@ -import 'jquery'; - -import 'angular'; -import 'angular-animate'; -import 'angular-cookies'; -import 'angular-aria'; -import 'angular-messages'; -import 'angular-material'; -import 'angular-ui-router'; -import 'angular-material/angular-material.css'; -import 'angular-material-data-table/dist/md-data-table'; -import 'angular-material-data-table/dist/md-data-table.css'; -import 'ng-infinite-scroll'; -import 'angular-svg-round-progressbar'; -import 'ngclipboard'; - -import 'babel-polyfill'; diff --git a/src/liskNano.js b/src/liskNano.js deleted file mode 100644 index 2a68871ca..000000000 --- a/src/liskNano.js +++ /dev/null @@ -1,44 +0,0 @@ -import './index.less'; - -import './components/delegateRegistration/delegateRegistration'; -import './components/delegates/delegates'; -import './components/delegates/vote'; -import './components/fee/fee'; -import './components/forging/forging'; -import './components/header/header'; -import './components/loadingBar/loadingBar'; -import './components/login/login'; -import './components/login/newAccount'; -import './components/lsk/lsk'; -import './components/main/main'; -import './components/main/secondPass'; -import './components/spinner/spinner'; -import './components/openDialog/openDialog'; -import './components/passphrase/passphrase'; -import './components/passphrase/passphraseService'; -import './components/passphrase/savePassphrase'; -import './components/send/send'; -import './components/signVerify/signMessage'; -import './components/signVerify/verifyMessage'; -import './components/timestamp/timestamp'; -import './components/top/top'; -import './components/transactions/transactions'; -import './theme/theme'; -import './util/animateOnChange/animateOnChange'; - -import './services/account'; -import './services/api/accountApi'; -import './services/api/delegateApi'; -import './services/api/forgingApi'; -import './services/api/peers'; -import './services/dialog'; -import './services/lsk'; -import './services/sync'; -import './services/notification'; - -import './filters/lsk'; -import './filters/liskNumber'; -import './filters/fundsInsufficiency'; - -import './run'; -import './states'; diff --git a/src/main.js b/src/main.js new file mode 100644 index 000000000..acf338c8c --- /dev/null +++ b/src/main.js @@ -0,0 +1,24 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { HashRouter as Router } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import App from './components/app'; +import store from './store'; + +const rootElement = document.getElementById('app'); + +const renderWithRouter = Component => + + + + + ; + +ReactDOM.render(renderWithRouter(App), rootElement); + +if (module.hot) { + module.hot.accept('./components/app', () => { + const NextRootContainer = require('./components/app').default; + ReactDOM.render(renderWithRouter(NextRootContainer), rootElement); + }); +} diff --git a/src/run.js b/src/run.js deleted file mode 100644 index 206f500cb..000000000 --- a/src/run.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @function run - * - * @description The application state method. - */ -app.run(($rootScope, $timeout, $state, $transitions, $mdDialog, Peers, Account, Sync, $window) => { - $rootScope.peers = Peers; - Sync.init(); - - $transitions.onStart({ to: '*' }, () => { - $mdDialog.cancel(); - }); - - $rootScope.reset = () => { - $timeout.cancel($rootScope.timeout); - }; - - $rootScope.logout = () => { - $rootScope.reset(); - Peers.reset(true); - - $rootScope.logged = false; - $rootScope.$emit('hideLoadingBar'); - Account.reset(); - if (PRODUCTION) { - const { ipc } = $window; - ipc.removeAllListeners('blur'); - ipc.removeAllListeners('focus'); - } - - $state.go('login'); - }; -}); diff --git a/src/services/account.js b/src/services/account.js deleted file mode 100644 index 7a7978aa1..000000000 --- a/src/services/account.js +++ /dev/null @@ -1,132 +0,0 @@ -import lisk from 'lisk-js'; - -/** - * @description This factory provides methods to get and set basic information and - * statistics of the current account - * - * @memberOf app - * @function Account - */ -app.factory('Account', function ($rootScope) { - /** - * @type Object - */ - this.account = {}; - - /** - * Deep compare any two parameter for equality. if not a primary value, - * compares all the members recursively checking if all primary value members are equal - * - * @private - * @method equals - * @param {any} ref1 - Value to compare equality - * @param {any} ref2 - Value to compare equality - * @returns {Boolean} Whether two parameters are equal or not - */ - const equals = (ref1, ref2) => { - /* eslint-disable eqeqeq */ - - if (ref1 == undefined && ref2 == undefined) { - return true; - } - if (typeof ref1 !== typeof ref2 || (typeof ref1 !== 'object' && ref1 != ref2)) { - return false; - } - - const props1 = (ref1 instanceof Array) ? ref1.map((val, idx) => idx) : Object.keys(ref1).sort(); - const props2 = (ref2 instanceof Array) ? ref2.map((val, idx) => idx) : Object.keys(ref2).sort(); - - let isEqual = true; - - props1.forEach((value1, index) => { - if (typeof ref1[value1] === 'object' && typeof ref2[props2[index]] === 'object') { - if (!equals(ref1[value1], ref2[props2[index]])) { - isEqual = false; - } - } else if (ref1[value1] != ref2[props2[index]]) { - isEqual = false; - } - }); - return isEqual; - }; - - /** - * If the new value of the given property on the account is changed, - * it sets the changed property with the values on a dictionary - * - * @private - * @method setChangedItem - * @param {Object} changes - The object to collect a dictionary of all the changes - * @param {String} property - The name of the property to check if changed - * @param {any} value - The new value of the property - */ - const setChangedItem = (changes, property, value) => { - if (!equals(this.account[property], value)) { - changes[property] = [this.account[property], value]; - } - }; - - const merge = (obj) => { - const keys = Object.keys(obj); - let changes = {}; - - keys.forEach((key) => { - setChangedItem(changes, key, obj[key]); - - this.account[key] = obj[key]; - - if (key === 'passphrase') { - const kp = lisk.crypto.getKeys(obj[key]); - setChangedItem(changes, 'publicKey', kp.publicKey); - this.account.publicKey = kp.publicKey; - - const address = lisk.crypto.getAddress(kp.publicKey); - setChangedItem(changes, 'address', address); - this.account.address = address; - } - }); - - // Calling listeners with the list of changes - if (Object.keys(changes).length) { - $rootScope.$broadcast('accountChange', changes); - changes = {}; - } - }; - - /** - * Merged the existing account object with the given changes object. - * For a given passphrase, it also sets address and publicKey. - * Broadcasts an event from rootScope downwards containing changes. - * - * @method set - * @param {Object} config - Changes to be applied to account object. - * @returns {object} the account object after changes applied. - * for each key in changes: {key: [newValue, oldValue]} - */ - this.set = (config) => { - merge(config); - return this.account; - }; - - /** - * Returns the dictionary of the account basic statistics - * - * @method get - * @returns {object} The account dictionary - */ - this.get = () => this.account; - - /** - * Removes all the keys from the account but keeps the reference - * - * @method reset - */ - this.reset = () => { - const keys = Object.keys(this.account); - keys.forEach((key) => { - delete this.account[key]; - }); - }; - - return this; -}); diff --git a/src/services/api/accountApi.js b/src/services/api/accountApi.js deleted file mode 100644 index 49bb60423..000000000 --- a/src/services/api/accountApi.js +++ /dev/null @@ -1,76 +0,0 @@ -/** - * This factory provides methods for requesting the information of - * the current account. it's using Account factory to access account - * publicKey and address - * - * @module app - * @submodule AccountApi - */ -app.factory('AccountApi', function ($q, Peers, Account) { - /** - * Uses Peers service to fetch the account stats for a given address. - * - * @param {String} address - the address(wallet Id) of the account. - * @returns {promise} Api call promise - */ - this.get = (address) => { - const deferred = $q.defer(); - Peers.active.getAccount(Account.get().address, (data) => { - if (data.success) { - deferred.resolve(data.account); - } else { - deferred.resolve({ - address, - balance: 0, - }); - } - }); - return deferred.promise; - }; - - /** - * Uses Peers service to set second passphrase for a given account - * - * @param {String} secondSecret - Chosen passphrase - * @param {String} publicKey - Account publicKey - * @param {String} secret - Account primary passphrase - * @returns {promise} Api call promise - * @param {Number} [timestamp = 10] - The time offset to compensate time setting issues - */ - this.setSecondSecret = (secondSecret, publicKey, - secret, timestamp = 10) => Peers.sendRequestPromise( - 'signatures', { secondSecret, publicKey, secret, timestamp }); - - this.transactions = {}; - - /** - * Uses Peers service to send a given amount of LSK to a given account - * - * @param {String} recipientId - The address(wallet Id) of the recipient - * @param {Number} amount - A floating point value in LSK - * @param {String} secret - account's primary passphrase - * @param {String} [secondSecret = null] - The second passphrase of the account (if enabled). - * @param {Number} [timestamp = 10] - The time offset to compensate time setting issues - */ - this.transactions.create = (recipientId, amount, secret, - secondSecret = null, timestamp = 10) => Peers.sendRequestPromise('transactions', - { recipientId, amount, secret, secondSecret, timestamp }); - - /** - * Uses Peers service to get the list of transactions for a specific address - * - * @param {String} address - The address of the account to get transactions list for - * @param {Number} [limit = 20] - The maximum number of items in list - * @param {Number} [offset = 0] - The offset index - * @param {String} [orderBy = 'timestamp:desc'] - How is the list ordered - */ - this.transactions.get = (address, limit = 20, offset = 0, orderBy = 'timestamp:desc') => Peers.sendRequestPromise('transactions', { - senderId: address, - recipientId: address, - limit, - offset, - orderBy, - }); - - return this; -}); diff --git a/src/services/api/delegateApi.js b/src/services/api/delegateApi.js deleted file mode 100644 index 89f2ea519..000000000 --- a/src/services/api/delegateApi.js +++ /dev/null @@ -1,94 +0,0 @@ -/** - * This factory provides methods for requesting and updating the information of - * the current account. it's using Account factory to access to account - * publicKey and address and it's only used for accounts registered as delegate. - * - * @module app - * @submodule delegateApi - */ -app.factory('delegateApi', Peers => ({ - /** - * gets the list of delegates for whom the given address has voted - * - * @param {object|string} address - The account address in string or in {address} format - * @returns {promise} Api call promise - */ - listAccountDelegates(address) { - return Peers.sendRequestPromise('accounts/delegates', { address }); - }, - - listDelegates(options) { - return Peers.sendRequestPromise(`delegates/${options.q ? 'search' : ''}`, options); - }, - - getDelegate(options) { - return Peers.sendRequestPromise('delegates/get', options); - }, - - /** - * (un)votes delegates based on voteList and unvoteList. - * The lists of the delegates contain plain addresses (without +-) - * - * @param {String} secret - Account primary passphrase - * @param {String} publicKey - Account publicKey - * @param {array} voteList - The list of the delegates for whom we're voting - * @param {array} unvoteList - The list of the delegates from whom we're removing our votes - * @param {String} [secondSecret=null] - * @returns {promise} Api call promise - * @param {Number} [timestamp = 10] - The time offset to compensate time setting issues - */ - vote(secret, publicKey, voteList, unvoteList, secondSecret = null, timestamp = 10) { - return Peers.sendRequestPromise('accounts/delegates', { - secret, - publicKey, - timestamp, - delegates: voteList.map(delegate => `+${delegate.publicKey}`).concat( - unvoteList.map(delegate => `-${delegate.publicKey}`), - ), - secondSecret, - }); - }, - - /** - * Searches between delegates with the given username, then filters the voteDict - * from the results and only shows the delegates for which we haven't voted. - * - * @param {String} username - username to search for - * @param {Object} votedDict - The delegate list to filter from the results - * @returns {array} The list of delegates whose username contains the given username - */ - voteAutocomplete(username, votedDict) { - return this.listDelegates({ q: username }).then( - response => response.delegates.filter(d => !votedDict[d.username]), - ); - }, - - /** - * Filters the list of voted delegates with the given username - * - * @param {String} username - username to search for - * @param {array} votedList - The list of the delegates for which we have voted - * @returns {array} The list of delegates whose username contains the given username - */ - unvoteAutocomplete(username, votedList) { - return votedList.filter(delegate => delegate.username.indexOf(username) !== -1); - }, - - /** - * Uses Peers service to register the account as delegate. - * - * @param {String} username - * @param {String} secret - Account primary passphrase - * @param {String} [secondSecret = null] - The second passphrase of the account (if enabled). - * @returns {promise} Api call promise - * @param {Number} [timestamp = 10] - The time offset to compensate time setting issues - */ - registerDelegate(username, secret, secondSecret = null, timestamp = 10) { - const data = { username, secret, timestamp }; - if (secondSecret) { - data.secondSecret = secondSecret; - } - return Peers.sendRequestPromise('delegates', data); - }, -})); - diff --git a/src/services/api/forgingApi.js b/src/services/api/forgingApi.js deleted file mode 100644 index da7561ea3..000000000 --- a/src/services/api/forgingApi.js +++ /dev/null @@ -1,51 +0,0 @@ -import moment from 'moment'; - -/** - * This factory provides methods for requesting the information of - * the blocks forged by the account. it's using Account factory to access to account - * publicKey and address and it's only used for accounts registered as delegate. - * - * @module app - * @submodule forgingApi - */ -app.factory('forgingApi', (Peers, Account) => ({ - /** - * Fetches the list of the delegates - * - * @returns {promise} Api call promise - */ - getDelegate() { - return Peers.sendRequestPromise('delegates/get', { - publicKey: Account.get().publicKey, - }); - }, - - /** - * fetches the list of forged blocks for the current account - * - * @param {Number} [limit=10] The maximum number of delegates - * @param {Number} [offset=0] The offset for pagination - * @returns {promise} Api call promise - */ - getForgedBlocks(limit = 10, offset = 0) { - return Peers.sendRequestPromise('blocks', { - limit, - offset, - generatorPublicKey: Account.get().publicKey, - }); - }, - - /** - * Fetches the statistics of forged blocks from the given date-time - * - * @param {Object} startMoment The moment.js date object - */ - getForgedStats(startMoment) { - return Peers.sendRequestPromise('delegates/forging/getForgedByAccount', { - generatorPublicKey: Account.get().publicKey, - start: moment(startMoment).unix(), - end: moment().unix(), - }); - }, -})); - diff --git a/src/services/api/peers.js b/src/services/api/peers.js deleted file mode 100644 index 996a9472c..000000000 --- a/src/services/api/peers.js +++ /dev/null @@ -1,135 +0,0 @@ -import lisk from 'lisk-js'; - -/** - * This factory provides methods for communicating with peers. It exposes - * sendRequestPromise method for requesting to available endpoint to the active peer, - * so we need to set the active peer using `setActive` method before using other methods - * - * @module app - * @submodule Peers - */ -app.factory('Peers', ($timeout, $cookies, $location, $q, $rootScope, dialog) => { - /** - * The Peers factory constructor class - * - * @class Peers - * @constructor - */ - class Peers { - constructor() { - $rootScope.$on('syncTick', () => { - if (this.active) this.check(); - }); - } - - /** - * Delegates the active peer - * - * @param {Boolean} active - defines if the function should delete the active peer - * - * @memberOf Peers - * @method reset - * @todo Since the usage of this function without passing active parameter - * doesn't perform any action, this function and its use-cases must be revised. - */ - reset(active) { - if (active) { - this.active = undefined; - } - } - - /** - * User Lisk.js to set the active peer. if network is not passed - * a peer will be selected in random base. - * Also checks the status of the network - * - * @param {Object} [network] - The network to be set as active - * - * @memberOf Peers - * @method setActive - */ - setActive(network) { - const addHttp = (url) => { - const reg = /^(?:f|ht)tps?:\/\//i; - return reg.test(url) ? url : `http://${url}`; - }; - - this.network = network; - let conf = { }; - if (network) { - conf = network; - if (network.address) { - const normalizedUrl = new URL(addHttp(network.address)); - - conf.node = normalizedUrl.hostname; - conf.port = normalizedUrl.port; - conf.ssl = normalizedUrl.protocol === 'https:'; - } - if (conf.testnet === undefined && conf.port !== undefined) { - conf.testnet = conf.port === '7000'; - } - } - - this.active = lisk.api(conf); - this.wasOffline = false; - return this.check(); - } - - /** - * Converts the callback-based peer.active.sendRequest to promise - * - * @param {String} api - The relative path of the endpoint - * @param {any} [urlParams] - The parameters of the request - * @returns {promise} Api call promise - * - * @memberOf Peer - * @method sendRequestPromise - */ - sendRequestPromise(api, urlParams) { - const deferred = $q.defer(); - this.active.sendRequest(api, urlParams, (data) => { - if (data.success) { - return deferred.resolve(data); - } - return deferred.reject(data); - }); - return deferred.promise; - } - - /** - * Gets the basic status of the account. and sets the online/offline status - * - * @private - * @memberOf Peer - * @method check - */ - check() { - return this.sendRequestPromise('loader/status', {}) - .then(() => { - this.online = true; - if (this.wasOffline) { - dialog.successToast('Connection re-established'); - $rootScope.$emit('hideLoadingBar', 'connection'); - } - this.wasOffline = false; - }) - .catch((data) => { - this.online = false; - if (!this.wasOffline) { - const address = `${this.active.currentPeer}:${this.active.port}`; - let message = `Failed to connect to node ${address}. `; - if (data && data.error && data.error.code === 'EUNAVAILABLE') { - message = `Failed to connect: Node ${address} is not active`; - } else if (!(data && data.error && data.error.code)) { - message += ' Make sure that you are using the latest version of Lisk Nano.'; - } - dialog.errorToast(message); - $rootScope.$emit('showLoadingBar', 'connection'); - } - this.wasOffline = true; - }); - } - } - - return new Peers(); -}); diff --git a/src/services/dialog.js b/src/services/dialog.js deleted file mode 100644 index bed6c218b..000000000 --- a/src/services/dialog.js +++ /dev/null @@ -1,128 +0,0 @@ -/** - * This factory exposes methods for showing custom dialogs, alerts and teasers. - * - * @module app - * @submodule dialog - */ -app.factory('dialog', ($mdDialog, $mdToast, $mdMedia) => ({ - - /** - * Uses mdToast to show a toast with error theme - * - * @param {String} text - The message of the toast - * @returns {promise} The mdToast promise - */ - errorToast(text) { - return this.toast({ success: false, text }); - }, - - /** - * Uses mdToast to show a toast with success theme - * - * @param {String} text - The message of the toast - * @returns {promise} The mdToast promise - */ - successToast(text) { - return this.toast({ success: true, text }); - }, - - /** - * Uses mdToast to show a toast with possibility - * to define custom theme using toastClass and success - * - * @param {Object} config - * @param {Boolean} config.success - Defines if the toast is shown with - * success or error theme - * @param {string} config.text - The message of the toast - * @param {string} config.toastClass - The class name(s) to be assigned to - * toast outermost tag - */ - toast({ success = false, text, toastClass }) { - toastClass = toastClass || (success ? 'lsk-toast-success' : 'lsk-toast-error'); - $mdToast.show( - $mdToast.simple() - .textContent(text) - .toastClass(toastClass) - .position('bottom right'), - ); - }, - - /** - * Shows alert dialog with error theme using mdDialog - * - * @param {Object} config - * @param {steing} config.title - The title of the alert box - * @param {steing} config.test - The message of the alert box - * @param {steing} config.button - The label of the button of the alert box - * @returns {promise} The mdDialog promise - */ - errorAlert({ title, text, button }) { - return this.alert({ success: false, title, text, button }); - }, - - /** - * Shows alert dialog with success theme using mdDialog - * - * @param {Object} config - * @param {string} config.title - The title of the alert box - * @param {string} config.test - The message of the alert box - * @param {string} config.button - The label of the button of the alert box - * @returns {promise} The mdDialog promise - */ - successAlert({ title, text, button }) { - return this.alert({ success: true, title, text, button }); - }, - - /** - * Shows custom alert modal using mdDialog - * - * @param {Object} config - * @param {string} [config.title = ''] - The title of the alert - * @param {Boolean} [config.success = false] - Defines the theme of the alert - * @param {string} config.text - The main message of the alert - * @param {string} [config.button = 'OK'] - The label of the confirmation button - */ - alert({ title = '', success = false, text, button = 'OK' }) { - title = title || (success ? 'Success' : 'Error'); - return $mdDialog.show( - $mdDialog.alert() - .title(title) - .textContent(text) - .ok(button), - ); - }, - - /** - * A general dialog to use with any directive or component - * - * @param {string} component - name of the component that we want to open it inside a dialog - * @param {object} options - */ - modal(component, options) { - function modalController($scope, option) { - $scope.option = option; - $scope.closeDialog = function () { - $mdDialog.hide(); - }; - } - let attrs = ''; - if (options) { - Object.keys(options).forEach((item) => { - attrs += `data-${item}="option['${item}']" `; - }); - } - return $mdDialog.show({ - parent: angular.element(document.body), - template: ` - - <${component} ${attrs} close-dialog="closeDialog()" > - - `, - locals: { - option: options, - }, - fullscreen: $mdMedia('xs'), - controller: modalController, - }); - }, -})); diff --git a/src/services/lsk.js b/src/services/lsk.js deleted file mode 100644 index 8ec6942f3..000000000 --- a/src/services/lsk.js +++ /dev/null @@ -1,12 +0,0 @@ -import BigNumber from 'bignumber.js'; - -BigNumber.config({ ERRORS: false }); - -app.factory('lsk', () => ({ - normalize(value) { - return new BigNumber(value || 0).dividedBy(new BigNumber(10).pow(8)).toFixed(); - }, - from(value) { - return new BigNumber(value * new BigNumber(10).pow(8)).round(0).toNumber(); - }, -})); diff --git a/src/services/notification.js b/src/services/notification.js deleted file mode 100644 index 0cd9e00ca..000000000 --- a/src/services/notification.js +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @description This factory provides methods to call Notification - * - * @module app - * @submodule Notify - */ -app.factory('Notification', ($window, lsk) => { - /** - * The Notify factory constructor class - * @class Notify - * @constructor - */ - class Notification { - constructor() { - this.isFocused = true; - } - - /** - * Initialize event listeners - * - * @returns {this} - * @method init - * @memberof Notify - */ - init() { - if (PRODUCTION) { - const { ipc } = $window; - ipc.on('blur', () => this.isFocused = false); - ipc.on('focus', () => this.isFocused = true); - } - return this; - } - - /** - * Routing to specific Notification creator based on type param - * @param {string} type - * @param {any} data - * - * @method about - * @public - * @memberof Notify - */ - about(type, data) { - if (this.isFocused) return; - switch (type) { - case 'deposit': - this._deposit(data); - break; - default: break; - } - } - - /** - * Creating notification about deposit - * - * @param {number} amount - * @private - * @memberof Notify - */ - _deposit(amount) { // eslint-disable-line - const body = `You've received ${lsk.normalize(amount)} LSK.`; - new $window.Notification('LSK received', { body }); // eslint-disable-line - } - } - - return new Notification(); -}); diff --git a/src/services/sync.js b/src/services/sync.js deleted file mode 100644 index 72cf5f07e..000000000 --- a/src/services/sync.js +++ /dev/null @@ -1,77 +0,0 @@ -const intervals = { - activeApp: 10000, - inactiveApp: 60000, -}; - -app.factory('Sync', ($rootScope, $window) => { - const config = { - updateInterval: intervals.activeApp, - freeze: false, - }; - let lastTick = new Date(); - let factor = 0; - const running = false; - - /** - * Broadcast an event from rootScope downwards - * - * @param {Date} timeStamp - */ - const broadcast = (timeStamp) => { - $rootScope.$broadcast('syncTick', { - factor, lastTick, timeStamp, - }); - }; - - /** - * We're calling this in framerate. call broadcast every config.updateInterval and - * sends a numeric factor for ease of use as multiples of updateInterval. - */ - const step = () => { - const now = new Date(); - if (now - lastTick >= config.updateInterval) { - broadcast(lastTick, now, factor); - lastTick = now; - factor += factor < 9 ? 1 : -9; - } - if (!config.freeze) { - $window.requestAnimationFrame(step); - } - }; - - const toggleSyncTimer = (inFocus) => { - config.updateInterval = (inFocus) ? - intervals.activeApp : - intervals.inactiveApp; - }; - - const initIntervalToggler = () => { - const { ipc } = $window; - ipc.on('blur', () => toggleSyncTimer(false)); - ipc.on('focus', () => toggleSyncTimer(true)); - }; - - /** - * Starts the first frame by calling requestAnimationFrame. - * This will be - */ - const init = () => { - if (!running) { - $window.requestAnimationFrame(step); - } - if (PRODUCTION) { - initIntervalToggler(); - } - }; - - /** - * Stops animation by preventing the next frame to fire - */ - const end = () => { - config.freeze = false; - }; - - return { - init, config, end, - }; -}); diff --git a/src/states.js b/src/states.js deleted file mode 100644 index fe4ded537..000000000 --- a/src/states.js +++ /dev/null @@ -1,32 +0,0 @@ -import './components/main/main'; -import './components/login/login'; - -/** - * @function states - * - * @description Uses stateProvider to configure the routing of the application - */ -app.config(($stateProvider, $urlRouterProvider) => { - $stateProvider - .state('login', { - url: '/', - component: 'login', - }) - .state('main', { - url: '/main', - component: 'main', - }) - .state('main.transactions', { - url: '/transactions', - component: 'transactions', - }) - .state('main.voting', { - url: '/voting', - component: 'delegates', - }) - .state('main.forging', { - url: '/forging', - component: 'forging', - }); - $urlRouterProvider.otherwise('/'); -}); diff --git a/src/store/index.js b/src/store/index.js new file mode 100644 index 000000000..31c2bbda2 --- /dev/null +++ b/src/store/index.js @@ -0,0 +1,20 @@ +import { createStore, combineReducers, applyMiddleware, compose } from 'redux'; +import * as reducers from './reducers'; +import middleWares from './middlewares'; + +const App = combineReducers(reducers); + +const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + +const store = createStore(App, composeEnhancers(applyMiddleware(...middleWares))); + +// ignore this in coverage as it is hard to test and does not run in production +/* istanbul ignore if */ +if (module.hot) { + module.hot.accept('./reducers', () => { + const nextReducer = combineReducers(require('./reducers')); + store.replaceReducer(nextReducer); + }); +} + +export default store; diff --git a/src/store/middlewares/account.js b/src/store/middlewares/account.js new file mode 100644 index 000000000..21f480a45 --- /dev/null +++ b/src/store/middlewares/account.js @@ -0,0 +1,102 @@ +import { getAccountStatus, getAccount, transactions } from '../../utils/api/account'; +import { accountUpdated, accountLoggedIn } from '../../actions/account'; +import { transactionsUpdated } from '../../actions/transactions'; +import { activePeerUpdate } from '../../actions/peers'; +import { clearVoteLists } from '../../actions/voting'; +import actionTypes from '../../constants/actions'; +import { fetchAndUpdateForgedBlocks } from '../../actions/forging'; +import { getDelegate } from '../../utils/api/delegate'; +import transactionTypes from '../../constants/transactionTypes'; +import { SYNC_ACTIVE_INTERVAL, SYNC_INACTIVE_INTERVAL } from '../../constants/api'; + +const updateTransactions = (store, peers, account) => { + const maxBlockSize = 25; + transactions(peers.data, account.address, maxBlockSize) + .then(response => store.dispatch(transactionsUpdated({ + confirmed: response.transactions, + count: parseInt(response.count, 10), + }))); +}; + +const hasRecentTransactions = state => ( + state.transactions.confirmed.filter(tx => tx.confirmations < 1000).length !== 0 || + state.transactions.pending.length !== 0 +); + +const updateAccountData = (store, action) => { // eslint-disable-line + const state = store.getState(); + const { peers, account } = state; + + getAccount(peers.data, account.address).then((result) => { + if (action.data.interval === SYNC_ACTIVE_INTERVAL && hasRecentTransactions(state)) { + updateTransactions(store, peers, account); + } + if (result.balance !== account.balance) { + if (action.data.interval === SYNC_INACTIVE_INTERVAL) { + updateTransactions(store, peers, account); + } + if (account.isDelegate) { + store.dispatch(fetchAndUpdateForgedBlocks({ + activePeer: peers.data, + limit: 10, + offset: 0, + generatorPublicKey: account.publicKey, + })); + } + } + store.dispatch(accountUpdated(result)); + }); + + return getAccountStatus(peers.data).then(() => { + store.dispatch(activePeerUpdate({ online: true })); + }).catch((res) => { + store.dispatch(activePeerUpdate({ online: false, code: res.error.code })); + }); +}; + +const getRecentTransactionOfType = (transactionsList, type) => ( + transactionsList.filter(transaction => ( + transaction.type === type && + // limit the number of confirmations to 5 to not fire each time there is another new transaction + // theoretically even less then 5, but just to be on the safe side + transaction.confirmations < 5))[0] +); + +const delegateRegistration = (store, action) => { + const delegateRegistrationTx = getRecentTransactionOfType( + action.data.confirmed, transactionTypes.registerDelegate); + const state = store.getState(); + + if (delegateRegistrationTx) { + getDelegate(state.peers.data, state.account.publicKey) + .then((delegateData) => { + store.dispatch(accountLoggedIn(Object.assign({}, + { delegate: delegateData.delegate, isDelegate: true }))); + }); + } +}; + +const votePlaced = (store, action) => { + const voteTransaction = getRecentTransactionOfType( + action.data.confirmed, transactionTypes.vote); + + if (voteTransaction) { + store.dispatch(clearVoteLists()); + } +}; + +const accountMiddleware = store => next => (action) => { + next(action); + switch (action.type) { + case actionTypes.metronomeBeat: + updateAccountData(store, action); + break; + case actionTypes.transactionsUpdated: + delegateRegistration(store, action); + votePlaced(store, action); + break; + default: break; + } +}; + +export default accountMiddleware; diff --git a/src/store/middlewares/account.test.js b/src/store/middlewares/account.test.js new file mode 100644 index 000000000..99676625f --- /dev/null +++ b/src/store/middlewares/account.test.js @@ -0,0 +1,173 @@ +import { expect } from 'chai'; +import { spy, stub } from 'sinon'; +import middleware from './account'; +import * as accountApi from '../../utils/api/account'; +import * as delegateApi from '../../utils/api/delegate'; +import actionTypes from '../../constants/actions'; +import transactionTypes from '../../constants/transactionTypes'; +import { SYNC_ACTIVE_INTERVAL, SYNC_INACTIVE_INTERVAL } from '../../constants/api'; +import { clearVoteLists } from '../../actions/voting'; + +describe('Account middleware', () => { + let store; + let next; + let state; + let stubGetAccount; + let stubGetAccountStatus; + let stubTransactions; + + const transactionsUpdatedAction = { + type: actionTypes.transactionsUpdated, + data: { + confirmed: [{ + type: transactionTypes.registerDelegate, + confirmations: 1, + }], + }, + }; + + const activeBeatAction = { + type: actionTypes.metronomeBeat, + data: { + interval: SYNC_ACTIVE_INTERVAL, + }, + }; + + const inactiveBeatAction = { + type: actionTypes.metronomeBeat, + data: { + interval: SYNC_INACTIVE_INTERVAL, + }, + }; + + beforeEach(() => { + store = stub(); + store.dispatch = spy(); + state = { + peers: { + data: {}, + }, + account: { + balance: 0, + }, + transactions: { + pending: [{ + id: 12498250891724098, + }], + confirmed: [], + }, + }; + store.getState = () => (state); + + next = spy(); + stubGetAccount = stub(accountApi, 'getAccount').returnsPromise(); + stubGetAccountStatus = stub(accountApi, 'getAccountStatus').returnsPromise(); + stubTransactions = stub(accountApi, 'transactions').returnsPromise().resolves(true); + }); + + afterEach(() => { + stubGetAccount.restore(); + stubGetAccountStatus.restore(); + stubTransactions.restore(); + }); + + it('should pass the action to next middleware', () => { + const expectedAction = { + type: 'TEST_ACTION', + }; + + middleware(store)(next)(expectedAction); + expect(next).to.have.been.calledWith(expectedAction); + }); + + it(`should call account API methods on ${actionTypes.metronomeBeat} action`, () => { + stubGetAccount.resolves({ balance: 0 }); + + middleware(store)(next)(activeBeatAction); + + expect(stubGetAccount).to.have.been.calledWith(); + expect(stubGetAccountStatus).to.have.been.calledWith(); + }); + + it(`should call transactions API methods on ${actionTypes.metronomeBeat} action if account.balance changes`, () => { + stubGetAccount.resolves({ balance: 10e8 }); + stubGetAccountStatus.resolves(true); + + middleware(store)(next)(activeBeatAction); + + expect(stubGetAccount).to.have.been.calledWith(); + // TODO why next expect doesn't work despite it being called according to test coverage? + // expect(stubTransactions).to.have.been.calledWith(); + }); + + it(`should call transactions API methods on ${actionTypes.metronomeBeat} action if account.balance changes and action.data.interval is SYNC_INACTIVE_INTERVAL`, () => { + stubGetAccount.resolves({ balance: 10e8 }); + stubGetAccountStatus.rejects(false); + + middleware(store)(next)(inactiveBeatAction); + + expect(stubGetAccount).to.have.been.calledWith(); + // TODO why next expect doesn't work despite it being called according to test coverage? + // expect(stubTransactions).to.have.been.calledWith(); + }); + + it(`should call transactions API methods on ${actionTypes.metronomeBeat} action if action.data.interval is SYNC_ACTIVE_INTERVAL and there are recent transactions`, () => { + stubGetAccount.resolves({ balance: 0 }); + + middleware(store)(next)(activeBeatAction); + + expect(stubGetAccount).to.have.been.calledWith(); + // TODO why next expect doesn't work despite it being called according to test coverage? + // expect(stubTransactions).to.have.been.calledWith(); + }); + + it(`should fetch delegate info on ${actionTypes.metronomeBeat} action if account.balance changes and account.isDelegate`, () => { + const delegateApiMock = stub(delegateApi, 'getDelegate').returnsPromise().resolves({ success: true, delegate: {} }); + stubGetAccount.resolves({ balance: 10e8 }); + state.account.isDelegate = true; + store.getState = () => (state); + + middleware(store)(next)(activeBeatAction); + expect(store.dispatch).to.have.been.calledWith(); + + delegateApiMock.restore(); + }); + + it(`should call fetchAndUpdateForgedBlocks(...) on ${actionTypes.metronomeBeat} action if account.balance changes and account.isDelegate`, () => { + state.account.isDelegate = true; + store.getState = () => (state); + stubGetAccount.resolves({ balance: 10e8 }); + // const fetchAndUpdateForgedBlocksSpy = spy(forgingActions, 'fetchAndUpdateForgedBlocks'); + + middleware(store)(next)({ type: actionTypes.metronomeBeat }); + + // TODO why next expect doesn't work despite it being called according to test coverage? + // expect(fetchAndUpdateForgedBlocksSpy).to.have.been.calledWith(); + }); + + it(`should fetch delegate info on ${actionTypes.transactionsUpdated} action if action.data.confirmed contains delegateRegistration transactions`, () => { + const delegateApiMock = stub(delegateApi, 'getDelegate').returnsPromise().resolves({ success: true, delegate: {} }); + + middleware(store)(next)(transactionsUpdatedAction); + expect(store.dispatch).to.have.been.calledWith(); + + delegateApiMock.restore(); + }); + + it(`should not fetch delegate info on ${actionTypes.transactionsUpdated} action if action.data.confirmed does not contain delegateRegistration transactions`, () => { + const delegateApiMock = stub(delegateApi, 'getDelegate').returnsPromise().resolves({ success: true, delegate: {} }); + transactionsUpdatedAction.data.confirmed[0].type = transactionTypes.send; + + middleware(store)(next)(transactionsUpdatedAction); + expect(store.dispatch).to.not.have.been.calledWith(); + + delegateApiMock.restore(); + }); + + it(`should dispatch clearVoteLists action on ${actionTypes.transactionsUpdated} action if action.data.confirmed contains delegateRegistration transactions`, () => { + transactionsUpdatedAction.data.confirmed[0].type = transactionTypes.vote; + middleware(store)(next)(transactionsUpdatedAction); + expect(store.dispatch).to.have.been.calledWith(clearVoteLists()); + }); +}); + diff --git a/src/store/middlewares/addedTransaction.js b/src/store/middlewares/addedTransaction.js new file mode 100644 index 000000000..9baf3d2bd --- /dev/null +++ b/src/store/middlewares/addedTransaction.js @@ -0,0 +1,33 @@ +import actionTypes from '../../constants/actions'; +import { successAlertDialogDisplayed } from '../../actions/dialog'; +import { fromRawLsk } from '../../utils/lsk'; + +const addedTransactionMiddleware = store => next => (action) => { + next(action); + if (action.type === actionTypes.transactionAdded) { + let text; + switch (action.data.type) { + case 1: + // second signature: 1 + text = 'Second passphrase registration was successfully submitted. It can take several seconds before it is processed.'; + break; + case 2: + // register as delegate: 2 + text = `Delegate registration was successfully submitted with username: "${action.data.username}". It can take several seconds before it is processed.`; + break; + case 3: + // Vote: 3 + text = 'Your votes were successfully submitted. It can take several seconds before they are processed.'; + break; + default: + // send: undefined + text = `Your transaction of ${fromRawLsk(action.data.amount)} LSK to ${action.data.recipientId} was accepted and will be processed in a few seconds.`; + break; + } + + const newAction = successAlertDialogDisplayed({ text }); + store.dispatch(newAction); + } +}; + +export default addedTransactionMiddleware; diff --git a/src/store/middlewares/addedTransaction.test.js b/src/store/middlewares/addedTransaction.test.js new file mode 100644 index 000000000..273056afd --- /dev/null +++ b/src/store/middlewares/addedTransaction.test.js @@ -0,0 +1,57 @@ +import { expect } from 'chai'; +import { spy, stub } from 'sinon'; +import { successAlertDialogDisplayed } from '../../actions/dialog'; +import middleware from './addedTransaction'; +import actionTypes from '../../constants/actions'; + +describe('addedTransaction middleware', () => { + let store; + let next; + + beforeEach(() => { + store = stub(); + store.getState = () => ({ + peers: { + data: {}, + }, + account: {}, + }); + store.dispatch = spy(); + next = spy(); + }); + + it('should passes the action to next middleware', () => { + const givenAction = { + type: 'TEST_ACTION', + }; + + middleware(store)(next)(givenAction); + expect(next).to.have.been.calledWith(givenAction); + }); + + it('fire success dialog action with appropriate text ', () => { + const givenAction = { + type: actionTypes.transactionAdded, + data: { + username: 'test', + amount: 1e8, + recipientId: '16313739661670634666L', + }, + }; + + const expectedMessages = [ + 'Your transaction of 1 LSK to 16313739661670634666L was accepted and will be processed in a few seconds.', + 'Second passphrase registration was successfully submitted. It can take several seconds before it is processed.', + 'Delegate registration was successfully submitted with username: "test". It can take several seconds before it is processed.', + 'Your votes were successfully submitted. It can take several seconds before they are processed.', + ]; + + for (let i = 0; i < 4; i++) { + givenAction.data.type = i; + middleware(store)(next)(givenAction); + const expectedAction = successAlertDialogDisplayed({ text: expectedMessages[i] }); + expect(store.dispatch).to.have.been.calledWith(expectedAction); + } + }); +}); + diff --git a/src/store/middlewares/index.js b/src/store/middlewares/index.js new file mode 100644 index 000000000..6899dc017 --- /dev/null +++ b/src/store/middlewares/index.js @@ -0,0 +1,19 @@ +import thunk from 'redux-thunk'; +import metronomeMiddleware from './metronome'; +import accountMiddleware from './account'; +import loginMiddleware from './login'; +import addedTransactionMiddleware from './addedTransaction'; +import loadingBarMiddleware from './loadingBar'; +import offlineMiddleware from './offline'; +import notificationMiddleware from './notification'; + +export default [ + thunk, + addedTransactionMiddleware, + loginMiddleware, + metronomeMiddleware, + accountMiddleware, + loadingBarMiddleware, + offlineMiddleware, + notificationMiddleware, +]; diff --git a/src/store/middlewares/loadingBar.js b/src/store/middlewares/loadingBar.js new file mode 100644 index 000000000..9357a83e5 --- /dev/null +++ b/src/store/middlewares/loadingBar.js @@ -0,0 +1,20 @@ +import actionsType from '../../constants/actions'; + +const ignoredLoadingActionKeys = ['loader/status']; + +const loadingBarMiddleware = () => next => (action) => { + switch (action.type) { + case actionsType.loadingStarted: + case actionsType.loadingFinished: + if (ignoredLoadingActionKeys.indexOf(action.data) === -1) { + next(action); + } + break; + default: + next(action); + break; + } +}; + +export default loadingBarMiddleware; + diff --git a/src/store/middlewares/loadingBar.test.js b/src/store/middlewares/loadingBar.test.js new file mode 100644 index 000000000..3f0610e12 --- /dev/null +++ b/src/store/middlewares/loadingBar.test.js @@ -0,0 +1,67 @@ +import { expect } from 'chai'; +import { spy, stub } from 'sinon'; +import middleware from './loadingBar'; +import actionType from '../../constants/actions'; + + +describe('LoadingBar middleware', () => { + let store; + let next; + const ignoredLoadingActionKeys = ['loader/status']; + + beforeEach(() => { + store = stub(); + store.dispatch = spy(); + next = spy(); + }); + + it('should pass the action to next middleware on some random action', () => { + const randomAction = { + type: 'TEST_ACTION', + }; + + middleware(store)(next)(randomAction); + expect(next).to.have.been.calledWith(randomAction); + }); + + it(`should not call next on ${actionType.loadingStarted} action if action.data == '${ignoredLoadingActionKeys[0]}'`, () => { + const action = { + type: actionType.loadingStarted, + data: ignoredLoadingActionKeys[0], + }; + + middleware(store)(next)(action); + expect(next).not.to.have.been.calledWith(action); + }); + + it(`should not call next on ${actionType.loadingFinished} action if action.data == '${ignoredLoadingActionKeys[0]}'`, () => { + const action = { + type: actionType.loadingFinished, + data: ignoredLoadingActionKeys[0], + }; + + middleware(store)(next)(action); + expect(next).not.to.have.been.calledWith(action); + }); + + it(`should call next on ${actionType.loadingStarted} action if action.data != '${ignoredLoadingActionKeys[0]}'`, () => { + const action = { + type: actionType.loadingStarted, + data: 'something/else', + }; + + middleware(store)(next)(action); + expect(next).to.have.been.calledWith(action); + }); + + it(`should call next on ${actionType.loadingFinished} action if action.data != '${ignoredLoadingActionKeys[0]}'`, () => { + const action = { + type: actionType.loadingFinished, + data: 'something/else', + }; + + middleware(store)(next)(action); + expect(next).to.have.been.calledWith(action); + }); +}); + diff --git a/src/store/middlewares/login.js b/src/store/middlewares/login.js new file mode 100644 index 000000000..1f71084db --- /dev/null +++ b/src/store/middlewares/login.js @@ -0,0 +1,37 @@ +import { getAccount, extractAddress, extractPublicKey } from '../../utils/api/account'; +import { getDelegate } from '../../utils/api/delegate'; +import { accountLoggedIn } from '../../actions/account'; +import actionTypes from '../../constants/actions'; +import { errorToastDisplayed } from '../../actions/toaster'; + +const loginMiddleware = store => next => (action) => { + if (action.type !== actionTypes.activePeerSet) { + return next(action); + } + + next(Object.assign({}, action, { data: action.data.activePeer })); + + const { passphrase } = action.data; + const publicKey = extractPublicKey(passphrase); + const address = extractAddress(passphrase); + const accountBasics = { + passphrase, + publicKey, + address, + }; + const { activePeer } = action.data; + + // redirect to main/transactions + return getAccount(activePeer, address).then(accountData => + getDelegate(activePeer, publicKey) + .then((delegateData) => { + store.dispatch(accountLoggedIn(Object.assign({}, accountData, accountBasics, + { delegate: delegateData.delegate, isDelegate: true }))); + }).catch(() => { + store.dispatch(accountLoggedIn(Object.assign({}, accountData, accountBasics, + { delegate: {}, isDelegate: false }))); + }), + ).catch(() => store.dispatch(errorToastDisplayed({ label: 'Unable to connect to the node' }))); +}; + +export default loginMiddleware; diff --git a/src/store/middlewares/login.test.js b/src/store/middlewares/login.test.js new file mode 100644 index 000000000..3c98d9d9c --- /dev/null +++ b/src/store/middlewares/login.test.js @@ -0,0 +1,83 @@ +import Lisk from 'lisk-js'; +import { expect } from 'chai'; +import { spy, stub } from 'sinon'; +import middleware from './login'; +import actionTypes from '../../constants/actions'; +import * as accountApi from '../../utils/api/account'; +import * as delegateApi from '../../utils/api/delegate'; + +describe('Login middleware', () => { + let store; + let next; + const passphrase = 'wagon stock borrow episode laundry kitten salute link globe zero feed marble'; + const activePeer = Lisk.api({ + name: 'Custom Node', + custom: true, + address: 'http://localhost:4000', + testnet: true, + nethash: '198f2b61a8eb95fbeed58b8216780b68f697f26b849acf00c8c93bb9b24f783d', + }); + const activePeerSetAction = { + type: actionTypes.activePeerSet, + data: { + passphrase, + activePeer, + }, + }; + + beforeEach(() => { + next = spy(); + store = stub(); + store.getState = () => ({ + peers: { + data: {}, + }, + account: {}, + }); + store.dispatch = spy(); + }); + + it(`should just pass action along for all actions except ${actionTypes.activePeerSet}`, () => { + const sampleAction = { + type: 'SAMPLE_TYPE', + data: 'SAMPLE_DATA', + }; + middleware(store)(next)(sampleAction); + expect(next).to.have.been.calledWith(sampleAction); + }); + + it(`should action data to only have activePeer on ${actionTypes.activePeerSet} action`, () => { + middleware(store)(next)(activePeerSetAction); + expect(next).to.have.been.calledWith({ + type: actionTypes.activePeerSet, + data: activePeer, + }); + }); + + it(`should fetch account and delegate info on ${actionTypes.activePeerSet} action (non delegate)`, () => { + const accountApiMock = stub(accountApi, 'getAccount').returnsPromise().resolves({ success: true, balance: 0 }); + const delegateApiMock = stub(delegateApi, 'getDelegate').returnsPromise().rejects({ success: false }); + + middleware(store)(next)(activePeerSetAction); + expect(store.dispatch).to.have.been.calledWith(); + + accountApiMock.restore(); + delegateApiMock.restore(); + }); + + it.skip(`should fetch account and delegate info on ${actionTypes.activePeerSet} action (delegate)`, () => { + const accountApiMock = stub(accountApi, 'getAccount').returnsPromise().resolves({ success: true, balance: 0 }); + const delegateApiMock = stub(delegateApi, 'getDelegate').returnsPromise().resolves({ + success: true, + delegate: { username: 'TEST' }, + username: 'TEST', + }); + + middleware(store)(next)(activePeerSetAction); + expect(store.dispatch).to.have.been.calledWith(); + + accountApiMock.restore(); + delegateApiMock.restore(); + }); +}); + diff --git a/src/store/middlewares/metronome.js b/src/store/middlewares/metronome.js new file mode 100644 index 000000000..f292ae6f8 --- /dev/null +++ b/src/store/middlewares/metronome.js @@ -0,0 +1,20 @@ +import MetronomeService from '../../utils/metronome'; +import actionTypes from '../../constants/actions'; + +const metronomeMiddleware = (store) => { + const metronome = new MetronomeService(store.dispatch); + return next => (action) => { + switch (action.type) { + case actionTypes.accountLoggedIn: + metronome.init(); + break; + case actionTypes.accountLoggedOut: + metronome.terminate(); + break; + default: break; + } + next(action); + }; +}; + +export default metronomeMiddleware; diff --git a/src/store/middlewares/metronome.test.js b/src/store/middlewares/metronome.test.js new file mode 100644 index 000000000..0d32a0116 --- /dev/null +++ b/src/store/middlewares/metronome.test.js @@ -0,0 +1,45 @@ +import { expect } from 'chai'; +import { spy, stub } from 'sinon'; +import middleware from './metronome'; +import actionTypes from '../../constants/actions'; +import * as MetronomeService from '../../utils/metronome'; + +describe('Metronome middleware', () => { + let store; + let next; + + beforeEach(() => { + next = spy(); + store = stub(); + store.dispatch = spy(); + }); + + it(`should call Metronome constructor on ${actionTypes.accountLoggedIn} action`, () => { + const metronome = spy(MetronomeService, 'default'); + middleware(store); + expect(metronome.calledWithNew()).to.equal(true); + expect(metronome).to.have.been.calledWith(store.dispatch); + metronome.restore(); + }); + + it('should call Metronome init method', () => { + const spyFn = spy(MetronomeService.default.prototype, 'init'); + middleware(store)(next)({ type: actionTypes.accountLoggedIn }); + expect(spyFn).to.have.been.calledWith(); + }); + + it(`should call metronome.terminate on ${actionTypes.accountLoggedOut} action`, () => { + const spyFn = spy(MetronomeService.default.prototype, 'terminate'); + middleware(store)(next)({ type: actionTypes.accountLoggedOut }); + expect(spyFn).to.have.been.calledWith(); + }); + + it('should passes the action to next middleware', () => { + const expectedAction = { + type: 'TEST_ACTION', + }; + middleware(store)(next)(expectedAction); + expect(next).to.have.been.calledWith(expectedAction); + }); +}); + diff --git a/src/store/middlewares/notification.js b/src/store/middlewares/notification.js new file mode 100644 index 000000000..8197bc5f6 --- /dev/null +++ b/src/store/middlewares/notification.js @@ -0,0 +1,23 @@ +import actionTypes from '../../constants/actions'; +import Notification from '../../utils/notification'; + +const notificationMiddleware = (store) => { + const notify = Notification.init(); + return next => (action) => { + const { account } = store.getState(); + next(action); + + switch (action.type) { + case actionTypes.accountUpdated: { + const amount = action.data.balance - account.balance; + if (amount > 0) { + notify.about('deposit', amount); + } + break; + } + default: break; + } + }; +}; + +export default notificationMiddleware; diff --git a/src/store/middlewares/notification.test.js b/src/store/middlewares/notification.test.js new file mode 100644 index 000000000..942781c60 --- /dev/null +++ b/src/store/middlewares/notification.test.js @@ -0,0 +1,59 @@ +import { expect } from 'chai'; +import { spy, stub } from 'sinon'; +import middleware from './notification'; +import actionTypes from '../../constants/actions'; +import Notification from '../../utils/notification'; + +describe('Notification middleware', () => { + let store; + let next; + const accountUpdatedAction = balance => ({ + type: actionTypes.accountUpdated, + data: { + balance, + }, + }); + + beforeEach(() => { + next = spy(); + store = stub(); + store.getState = () => ({ + account: { + balance: 100, + }, + }); + store.dispatch = spy(); + }); + + it('should init Notification service', () => { + const spyFn = spy(Notification, 'init'); + middleware(store); + expect(spyFn).to.have.been.calledWith(); + spyFn.restore(); + }); + + it('should just pass action along for all actions', () => { + const sampleAction = { + type: 'SAMPLE_TYPE', + data: 'SAMPLE_DATA', + }; + middleware(store)(next)(sampleAction); + expect(next).to.have.been.calledWith(sampleAction); + }); + + it(`should handle notify.about method on ${actionTypes.accountUpdated} action`, () => { + const spyFn = spy(Notification, 'about'); + middleware(store)(next)(accountUpdatedAction(1000)); + expect(spyFn).to.have.been.calledWith('deposit', 900); + spyFn.restore(); + }); + + it(`should not handle notify.about method on ${actionTypes.accountUpdated} action if balance the same or lower than current`, () => { + const spyFn = spy(Notification, 'about'); + middleware(store)(next)(accountUpdatedAction(100)); + middleware(store)(next)(accountUpdatedAction(50)); + expect(spyFn.called).to.be.equal(false); + spyFn.restore(); + }); +}); + diff --git a/src/store/middlewares/offline.js b/src/store/middlewares/offline.js new file mode 100644 index 000000000..28c784399 --- /dev/null +++ b/src/store/middlewares/offline.js @@ -0,0 +1,42 @@ +import actionsType from '../../constants/actions'; +import { successToastDisplayed, errorToastDisplayed } from '../../actions/toaster'; +import { loadingStarted, loadingFinished } from '../../utils/loading'; + +const getErrorMessage = (errorCode, address) => { + let message = `Failed to connect to node ${address}`; + switch (errorCode) { + case 'EUNAVAILABLE': + message = `Failed to connect: Node ${address} is not active`; + break; + case 'EPARSE': + message += ' Make sure that you are using the latest version of Lisk Nano.'; + break; + default: break; + } + return message; +}; + +const offlineMiddleware = store => next => (action) => { + const state = store.getState(); + switch (action.type) { + case actionsType.activePeerUpdate: + if (action.data.online === false && state.peers.status.online === true) { + const address = `${state.peers.data.currentPeer}:${state.peers.data.port}`; + const label = getErrorMessage(action.data.code, address); + store.dispatch(errorToastDisplayed({ label })); + loadingStarted('offline'); + } else if (action.data.online === true && state.peers.status.online === false) { + store.dispatch(successToastDisplayed({ label: 'Connection re-established' })); + loadingFinished('offline'); + } + if (action.data.online !== state.peers.status.online) { + next(action); + } + break; + default: + next(action); + break; + } +}; + +export default offlineMiddleware; diff --git a/src/store/middlewares/offline.test.js b/src/store/middlewares/offline.test.js new file mode 100644 index 000000000..6e6de2fc1 --- /dev/null +++ b/src/store/middlewares/offline.test.js @@ -0,0 +1,107 @@ +import { expect } from 'chai'; +import { spy, stub } from 'sinon'; +import middleware from './offline'; +import { successToastDisplayed, errorToastDisplayed } from '../../actions/toaster'; +import actionType from '../../constants/actions'; + + +describe('Offline middleware', () => { + let store; + let next; + let action; + let peers; + + beforeEach(() => { + store = stub(); + store.dispatch = spy(); + next = spy(); + action = { + type: actionType.activePeerUpdate, + data: {}, + }; + peers = { + data: { + port: 4000, + currentPeer: 'localhost', + }, + status: {}, + }; + store.getState = () => ({ peers }); + }); + + it('should pass the action to next middleware on some random action', () => { + const randomAction = { + type: 'TEST_ACTION', + }; + + middleware(store)(next)(randomAction); + expect(next).to.have.been.calledWith(randomAction); + }); + + it(`should dispatch errorToastDisplayed on ${actionType.activePeerUpdate} action if !action.data.online and state.peer.status.online and action.data.code`, () => { + peers.status.online = true; + action.data = { + online: false, + code: 'ANY OTHER CODE', + }; + + middleware(store)(next)(action); + expect(store.dispatch).to.have.been.calledWith(errorToastDisplayed({ + label: `Failed to connect to node ${peers.data.currentPeer}:${peers.data.port}`, + })); + }); + + it(`should dispatch errorToastDisplayed on ${actionType.activePeerUpdate} action if !action.data.online and state.peer.status.online and action.data.code = "EUNAVAILABLE"`, () => { + peers.status.online = true; + action.data = { + online: false, + code: 'EUNAVAILABLE', + }; + + middleware(store)(next)(action); + expect(store.dispatch).to.have.been.calledWith(errorToastDisplayed({ + label: `Failed to connect: Node ${peers.data.currentPeer}:${peers.data.port} is not active`, + })); + }); + + it(`should dispatch errorToastDisplayed on ${actionType.activePeerUpdate} action if !action.data.online and state.peer.status.online and action.data.code = "EPARSE"`, () => { + peers.status.online = true; + action.data = { + online: false, + code: 'EPARSE', + }; + + const expectedResult = `Failed to connect to node ${peers.data.currentPeer}:${peers.data.port} Make sure that you are using the latest version of Lisk Nano.`; + middleware(store)(next)(action); + expect(store.dispatch).to.have.been.calledWith(errorToastDisplayed({ + label: expectedResult, + })); + }); + + it(`should dispatch successToastDisplayed on ${actionType.activePeerUpdate} action if action.data.online and !state.peer.status.online`, () => { + peers.status.online = false; + action.data.online = true; + + middleware(store)(next)(action); + expect(store.dispatch).to.have.been.calledWith(successToastDisplayed({ + label: 'Connection re-established', + })); + }); + + it(`should not call next() on ${actionType.activePeerUpdate} action if action.data.online === state.peer.status.online`, () => { + peers.status.online = false; + action.data.online = false; + + middleware(store)(next)(action); + expect(next).not.to.have.been.calledWith(); + }); + + it(`should call next() on ${actionType.activePeerUpdate} action if action.data.online !== state.peer.status.online`, () => { + peers.status.online = true; + action.data.online = false; + + middleware(store)(next)(action); + expect(next).to.have.been.calledWith(action); + }); +}); + diff --git a/src/store/reducers/account.js b/src/store/reducers/account.js new file mode 100644 index 000000000..a6c704d76 --- /dev/null +++ b/src/store/reducers/account.js @@ -0,0 +1,68 @@ +import { deepEquals } from '../../utils/polyfills'; +import actionTypes from '../../constants/actions'; + +/** + * If the new value of the given property on the account is changed, + * it sets the changed property with the values on a dictionary + * + * @private + * @method setChangedItem + * @param {Object} changes - The object to collect a dictionary of all the changes + * @param {String} property - The name of the property to check if changed + * @param {any} value - The new value of the property + */ +const setChangedItem = (account, changes, property, value) => + Object.assign({}, changes, (() => { + const obj = {}; + + if (!deepEquals(account[property], value)) { + obj[property] = [account[property], value]; + } + return obj; + })()); + +/** + * Merges account object with given info object + * and if info contains passphrase, it also sets + * the values of address and publicKey + * + * @param {Object} account - Account object + * @param {Object} info - New changes + * + * @returns {Object} the updated account object + */ +const merge = (account, info) => { + const keys = Object.keys(info); + let changes = {}; + const updatedAccount = Object.assign({}, account); + + keys.forEach((key) => { + if (info[key]) { + changes = setChangedItem(account, changes, key, info[key]); + updatedAccount[key] = info[key]; + } + }); + + return updatedAccount; +}; + +/** + * + * @param {Array} state + * @param {Object} action + */ +const account = (state = {}, action) => { + switch (action.type) { + case actionTypes.accountUpdated: + case actionTypes.accountLoggedIn: + return merge(state, action.data); + case actionTypes.accountLoggedOut: + return { + afterLogout: true, + }; + default: + return state; + } +}; + +export default account; diff --git a/src/store/reducers/account.test.js b/src/store/reducers/account.test.js new file mode 100644 index 000000000..ff0cce46e --- /dev/null +++ b/src/store/reducers/account.test.js @@ -0,0 +1,51 @@ +import { expect } from 'chai'; +import account from './account'; +import actionTypes from '../../constants/actions'; + + +describe('Reducer: account(state, action)', () => { + let state; + + beforeEach(() => { + state = { + balance: 0, + passphrase: 'wagon stock borrow episode laundry kitten salute link globe zero feed marble', + publicKey: 'c094ebee7ec0c50ebee32918655e089f6e1a604b83bcaa760293c61e0f18ab6f', + address: '16313739661670634666L', + }; + }); + + it('should return account obejct with changes if action.type = actionTypes.accountUpdated', () => { + const action = { + type: actionTypes.accountUpdated, + data: { + passphrase: state.passphrase, + balance: 100000000, + }, + }; + const changedAccount = account(state, action); + expect(changedAccount).to.deep.equal({ + balance: action.data.balance, + passphrase: state.passphrase, + publicKey: state.publicKey, + address: state.address, + }); + }); + + it('should return empty account obejct if action.type = actionTypes.accountLoggedOut', () => { + const action = { + type: actionTypes.accountLoggedOut, + }; + const changedAccount = account(state, action); + expect(changedAccount).to.deep.equal({ afterLogout: true }); + }); + + it('should return state if action.type is none of the above', () => { + const action = { + type: 'UNKNOWN', + }; + const changedAccount = account(state, action); + expect(changedAccount).to.deep.equal(state); + }); +}); + diff --git a/src/store/reducers/dialog.js b/src/store/reducers/dialog.js new file mode 100644 index 000000000..f00717a91 --- /dev/null +++ b/src/store/reducers/dialog.js @@ -0,0 +1,19 @@ +import actionTypes from '../../constants/actions'; + +/** + * + * @param {Array} state + * @param {Object} action + */ +const dialog = (state = {}, action) => { + switch (action.type) { + case actionTypes.dialogDisplayed: + return Object.assign({}, state, action.data); + case actionTypes.dialogHidden: + return {}; + default: + return state; + } +}; + +export default dialog; diff --git a/src/store/reducers/dialog.test.js b/src/store/reducers/dialog.test.js new file mode 100644 index 000000000..5e48ed1a5 --- /dev/null +++ b/src/store/reducers/dialog.test.js @@ -0,0 +1,34 @@ +import { expect } from 'chai'; +import dialogs from './dialog'; +import actionTypes from '../../constants/actions'; + + +describe('Reducer: dialogs(state, action)', () => { + let state; + + beforeEach(() => { + state = { key: 'sign-message' }; + }); + + it('should return dialogs array with the new dialog if action.type = actionTypes.dialogDisplayed', () => { + const action = { + type: actionTypes.dialogDisplayed, + data: { + key: 'verify-message', + }, + }; + const changedState = dialogs(state, action); + expect(changedState).to.deep.equal({ + key: 'verify-message', + }); + }); + + it('should return empty account obejct if action.type = actionTypes.accountLoggedOut', () => { + const action = { + type: actionTypes.dialogHidden, + }; + const changedState = dialogs(state, action); + expect(changedState).to.deep.equal({}); + }); +}); + diff --git a/src/store/reducers/forging.js b/src/store/reducers/forging.js new file mode 100644 index 000000000..44e2b76ac --- /dev/null +++ b/src/store/reducers/forging.js @@ -0,0 +1,38 @@ +import actionTypes from '../../constants/actions'; + +/** + * + * @param {Array} state + * @param {Object} action + */ +const forging = (state = { forgedBlocks: [], statistics: {} }, action) => { + let startTimestamp; + let endTimestamp; + + switch (action.type) { + case actionTypes.forgedBlocksUpdated: + startTimestamp = state.forgedBlocks && state.forgedBlocks.length ? + state.forgedBlocks[0].timestamp : + 0; + endTimestamp = state.forgedBlocks && state.forgedBlocks.length ? + state.forgedBlocks[state.forgedBlocks.length - 1].timestamp : + 0; + return Object.assign({}, state, { + forgedBlocks: [ + ...action.data.filter(block => block.timestamp > startTimestamp), + ...state.forgedBlocks, + ...action.data.filter(block => block.timestamp < endTimestamp), + ], + }); + case actionTypes.forgingStatsUpdated: + return Object.assign({}, state, { + statistics: Object.assign({}, state.statistics, action.data), + }); + case actionTypes.accountLoggedOut: + return { forgedBlocks: [], statistics: {} }; + default: + return state; + } +}; + +export default forging; diff --git a/src/store/reducers/forging.test.js b/src/store/reducers/forging.test.js new file mode 100644 index 000000000..2cbb20643 --- /dev/null +++ b/src/store/reducers/forging.test.js @@ -0,0 +1,82 @@ +import { expect } from 'chai'; +import forging from './forging'; +import actionTypes from '../../constants/actions'; + +describe('Reducer: forging(state, action)', () => { + let state; + const blocks = [{ + id: '16113150790072764126', + timestamp: 36280810, + height: 29394, + totalFee: 0, + reward: 0, + }, + { + id: '13838471839278892195', + version: 0, + timestamp: 36280700, + height: 29383, + totalFee: 0, + reward: 0, + }, + { + id: '5654150596698663763', + version: 0, + timestamp: 36279700, + height: 29283, + totalFee: 0, + reward: 0, + }]; + + it('should set forgedBlocks if action.type = actionTypes.forgedBlocksUpdate and state.forgedBlocks is []', () => { + state = { + statistics: {}, + forgedBlocks: [], + }; + const action = { + type: actionTypes.forgedBlocksUpdated, + data: [blocks[0], blocks[1]], + }; + const changedState = forging(state, action); + expect(changedState.forgedBlocks).to.deep.equal(action.data); + }); + + it('should prepend forgedBlocks with newer blocks if action.type = actionTypes.forgedBlocksUpdated', () => { + state = { + statistics: {}, + forgedBlocks: [blocks[2]], + }; + const action = { + type: actionTypes.forgedBlocksUpdated, + data: [blocks[0], blocks[1]], + }; + const changedState = forging(state, action); + expect(changedState.forgedBlocks).to.deep.equal(blocks); + }); + + it('should append forgedBlocks with older blocks if action.type = actionTypes.forgedBlocksUpdated', () => { + state = { + statistics: {}, + forgedBlocks: [blocks[0]], + }; + const action = { + type: actionTypes.forgedBlocksUpdated, + data: [blocks[1], blocks[2]], + }; + const changedState = forging(state, action); + expect(changedState.forgedBlocks).to.deep.equal(blocks); + }); + + it('should update statistics if action.type = actionTypes.forgingStatsUpdated', () => { + state = { + statistics: { last7d: 1000 }, + }; + const action = { + type: actionTypes.forgingStatsUpdated, + data: { last24h: 100 }, + }; + const changedState = forging(state, action); + expect(changedState.statistics).to.deep.equal({ last7d: 1000, last24h: 100 }); + }); +}); + diff --git a/src/store/reducers/index.js b/src/store/reducers/index.js new file mode 100644 index 000000000..e41eb9934 --- /dev/null +++ b/src/store/reducers/index.js @@ -0,0 +1,8 @@ +export { default as account } from './account'; +export { default as peers } from './peers'; +export { default as dialog } from './dialog'; +export { default as forging } from './forging'; +export { default as voting } from './voting'; +export { default as loading } from './loading'; +export { default as toaster } from './toaster'; +export { default as transactions } from './transactions'; diff --git a/src/store/reducers/loading.js b/src/store/reducers/loading.js new file mode 100644 index 000000000..2d3ab5bdd --- /dev/null +++ b/src/store/reducers/loading.js @@ -0,0 +1,19 @@ +import actionTypes from '../../constants/actions'; + +/** + * + * @param {Array} state + * @param {Object} action + */ +const dialog = (state = [], action) => { + switch (action.type) { + case actionTypes.loadingStarted: + return [...state, action.data]; + case actionTypes.loadingFinished: + return state.filter(item => item !== action.data); + default: + return state; + } +}; + +export default dialog; diff --git a/src/store/reducers/loding.test.js b/src/store/reducers/loding.test.js new file mode 100644 index 000000000..6a8335d2f --- /dev/null +++ b/src/store/reducers/loding.test.js @@ -0,0 +1,31 @@ +import { expect } from 'chai'; +import loading from './loading'; +import actionTypes from '../../constants/actions'; + + +describe('Reducer: loading(state, action)', () => { + let state; + + beforeEach(() => { + state = ['test1', 'test2']; + }); + + it('should return loading array with the new loading if action.type = actionTypes.loadingStarted', () => { + const action = { + type: actionTypes.loadingStarted, + data: 'test3', + }; + const changedState = loading(state, action); + expect(changedState).to.deep.equal([...state, action.data]); + }); + + it('should return loading array without action.data if action.type = actionTypes.loadingFinished', () => { + const action = { + type: actionTypes.loadingFinished, + data: 'test1', + }; + const changedState = loading(state, action); + expect(changedState).to.deep.equal(['test2']); + }); +}); + diff --git a/src/store/reducers/peers.js b/src/store/reducers/peers.js new file mode 100644 index 000000000..108647fd8 --- /dev/null +++ b/src/store/reducers/peers.js @@ -0,0 +1,24 @@ +import actionTypes from '../../constants/actions'; + +/** + * The reducer for maintaining active peer + * + * @param {Array} state - the current state object + * @param {Object} action - The action containing type and data + * + * @returns {Object} - Next state object + */ +const peers = (state = { status: {} }, action) => { + switch (action.type) { + case actionTypes.activePeerSet: + return Object.assign({}, state, { data: action.data }); + case actionTypes.activePeerUpdate: + return Object.assign({}, state, { status: action.data }); + case actionTypes.accountLoggedOut: + return Object.assign({}, state, { data: {}, status: {} }); + default: + return state; + } +}; + +export default peers; diff --git a/src/store/reducers/peers.test.js b/src/store/reducers/peers.test.js new file mode 100644 index 000000000..b32505d2d --- /dev/null +++ b/src/store/reducers/peers.test.js @@ -0,0 +1,57 @@ +import { expect } from 'chai'; +import peers from './peers'; +import actionTypes from '../../constants/actions'; + + +describe('Reducer: peers(state, action)', () => { + it('should return state object with data of active peer in state.data if action is activePeerSet', () => { + const state = {}; + const action = { + type: actionTypes.activePeerSet, + data: { + currentPeer: 'localhost', + port: 4000, + options: { + name: 'Custom Node', + }, + }, + }; + + const newState = { data: action.data }; + const changedState = peers(state, action); + expect(changedState).to.deep.equal(newState); + }); + + it('should return state object with updated status of active peer if action is activePeerUpdate', () => { + const state = {}; + const action = { + type: actionTypes.activePeerUpdate, + data: { online: true }, + }; + + const newState = { status: action.data }; + const changedState = peers(state, action); + expect(changedState).to.deep.equal(newState); + }); + + it('should return and empty state object if action is accountLoggedOut', () => { + const state = { + data: { + currentPeer: 'localhost', + port: 4000, + options: { + name: 'Custom Node', + }, + }, + status: { online: true }, + }; + const action = { + type: actionTypes.accountLoggedOut, + }; + + const newState = { status: {}, data: {} }; + const changedState = peers(state, action); + expect(changedState).to.deep.equal(newState); + }); +}); + diff --git a/src/store/reducers/toaster.js b/src/store/reducers/toaster.js new file mode 100644 index 000000000..ac180ddb2 --- /dev/null +++ b/src/store/reducers/toaster.js @@ -0,0 +1,25 @@ +import actionTypes from '../../constants/actions'; + +/** + * + * @param {Array} state + * @param {Object} action + */ +const toaster = (state = [], action) => { + switch (action.type) { + case actionTypes.toastDisplayed: + return [ + ...state, + { + ...action.data, + index: state.length ? state[state.length - 1].index + 1 : 0, + }, + ]; + case actionTypes.toastHidden: + return state.filter(toast => toast.index !== action.data.index); + default: + return state; + } +}; + +export default toaster; diff --git a/src/store/reducers/toater.test.js b/src/store/reducers/toater.test.js new file mode 100644 index 000000000..a9cc6e4ab --- /dev/null +++ b/src/store/reducers/toater.test.js @@ -0,0 +1,29 @@ +import { expect } from 'chai'; +import toaster from './toaster'; +import actionTypes from '../../constants/actions'; + +describe('Reducer: toaster(state, action)', () => { + it('should return action.data with index if action.type = actionTypes.toastDisplayed', () => { + const state = []; + const action = { + type: actionTypes.toastDisplayed, + data: { + label: 'test toast', + }, + }; + const changedState = toaster(state, action); + expect(changedState).to.deep.equal([{ ...action.data, index: 0 }]); + }); + + it('should return array without given toast if action.type = actionTypes.toastHidden', () => { + const toast = { label: 'test toast', index: 0 }; + const state = [toast]; + const action = { + type: actionTypes.toastHidden, + data: toast, + }; + const changedState = toaster(state, action); + expect(changedState).to.deep.equal([]); + }); +}); + diff --git a/src/store/reducers/transactions.js b/src/store/reducers/transactions.js new file mode 100644 index 000000000..01d3ba7a1 --- /dev/null +++ b/src/store/reducers/transactions.js @@ -0,0 +1,44 @@ +import actionTypes from '../../constants/actions'; + +/** + * + * @param {Array} state + * @param {Object} action + */ +const transactions = (state = { pending: [], confirmed: [], count: 0 }, action) => { + switch (action.type) { + case actionTypes.transactionAdded: + return Object.assign({}, state, { + pending: [action.data, ...state.pending], + }); + case actionTypes.transactionsLoaded: + return Object.assign({}, state, { + confirmed: [ + ...state.confirmed, + ...action.data.confirmed, + ], + count: action.data.count, + }); + case actionTypes.transactionsUpdated: + return Object.assign({}, state, { + // Filter any newly confirmed transaction from pending + pending: state.pending.filter( + pendingTransaction => action.data.confirmed.filter( + transaction => transaction.id === pendingTransaction.id).length === 0), + // Add any newly confirmed transaction to confirmed + confirmed: [ + ...action.data.confirmed, + ...state.confirmed.filter( + confirmedTransaction => action.data.confirmed.filter( + transaction => transaction.id === confirmedTransaction.id).length === 0), + ], + count: action.data.count, + }); + case actionTypes.accountLoggedOut: + return { pending: [], confirmed: [], count: 0 }; + default: + return state; + } +}; + +export default transactions; diff --git a/src/store/reducers/transactions.test.js b/src/store/reducers/transactions.test.js new file mode 100644 index 000000000..8aa4e43df --- /dev/null +++ b/src/store/reducers/transactions.test.js @@ -0,0 +1,112 @@ +import { expect } from 'chai'; +import transactions from './transactions'; +import actionTypes from '../../constants/actions'; + +describe('Reducer: transactions(state, action)', () => { + const mockTransactions = [{ + amount: 100000000000, + id: '16295820046284152875', + timestamp: 33505748, + }, { + amount: 200000000000, + id: '8504241460062789191', + timestamp: 33505746, + }, { + amount: 300000000000, + id: '18310904473760006068', + timestamp: 33505743, + }]; + + it('should prepend action.data to state.pending if action.type = actionTypes.transactionAdded', () => { + const state = { + pending: [mockTransactions[1]], + confirmed: [], + }; + const action = { + type: actionTypes.transactionAdded, + data: mockTransactions[0], + }; + const changedState = transactions(state, action); + expect(changedState).to.deep.equal({ ...state, pending: [action.data, ...state.pending] }); + }); + + it('should concat action.data to state.confirmed if action.type = actionTypes.transactionsLoaded', () => { + const state = { + pending: [], + confirmed: [], + }; + const action = { + type: actionTypes.transactionsLoaded, + data: { + confirmed: mockTransactions, + count: mockTransactions.length, + }, + }; + const expectedState = { + pending: [], + confirmed: action.data.confirmed, + count: action.data.count, + }; + const changedState = transactions(state, action); + expect(changedState).to.deep.equal(expectedState); + }); + + it('should prepend newer transactions from action.data to state.confirmed and remove from state.pending if action.type = actionTypes.transactionsUpdated', () => { + const state = { + pending: [mockTransactions[0]], + confirmed: [mockTransactions[1], mockTransactions[2]], + count: mockTransactions[1].length + mockTransactions[2].length, + }; + const action = { + type: actionTypes.transactionsUpdated, + data: { + confirmed: mockTransactions, + count: mockTransactions.length, + }, + }; + const changedState = transactions(state, action); + expect(changedState).to.deep.equal({ + pending: [], + confirmed: mockTransactions, + count: mockTransactions.length, + }); + }); + + it('should action.data to state.confirmed if state.confirmed is empty and action.type = actionTypes.transactionsUpdated', () => { + const state = { + pending: [], + confirmed: [], + }; + const action = { + type: actionTypes.transactionsUpdated, + data: { + confirmed: mockTransactions, + count: 3, + }, + }; + const changedState = transactions(state, action); + expect(changedState).to.deep.equal({ + pending: [], + confirmed: mockTransactions, + count: mockTransactions.length, + }); + }); + + it('should reset all data if action.type = actionTypes.accountLoggedOut', () => { + const state = { + pending: [{ + amount: 110000000000, + id: '16295820046284152275', + timestamp: 33506748, + }], + confirmed: mockTransactions, + }; + const action = { type: actionTypes.accountLoggedOut }; + const changedState = transactions(state, action); + expect(changedState).to.deep.equal({ + pending: [], + confirmed: [], + count: 0, + }); + }); +}); diff --git a/src/store/reducers/voting.js b/src/store/reducers/voting.js new file mode 100644 index 000000000..4894e6e8f --- /dev/null +++ b/src/store/reducers/voting.js @@ -0,0 +1,92 @@ +import actionTypes from '../../constants/actions'; +/** + * remove a gelegate from list of delegates + * + * @param {array} list - list for delegates + * @param {object} item - a delegates that we want to remove it + */ +const removeFromList = (list, item) => { + const address = item.address; + return list.filter(delegate => delegate.address !== address); +}; +/** + * find index of a gelegate in list of delegates + * + * @param {array} list - list for delegates + * @param {object} item - a delegates that we want to find its index + */ +const findItemInList = (list, item) => { + const address = item.address; + let idx = -1; + list.forEach((delegate, index) => { + if (delegate.address === address) { + idx = index; + } + }); + return idx; +}; +/** + * voting reducer + * + * @param {Object} state + * @param {Object} action + */ +const voting = (state = { votedList: [], unvotedList: [] }, action) => { + switch (action.type) { + case actionTypes.addedToVoteList: + if (action.data.voted) { + return Object.assign({}, state, { + refresh: false, + unvotedList: [...removeFromList(state.unvotedList, action.data)], + }); + } + if (findItemInList(state.votedList, action.data) > -1) { + return state; + } + return Object.assign({}, state, { + refresh: false, + votedList: [ + ...state.votedList, + Object.assign(action.data, { selected: true, dirty: true }), + ], + }); + case actionTypes.removedFromVoteList: + if (!action.data.voted) { + return Object.assign({}, state, { + refresh: false, + votedList: [...removeFromList(state.votedList, action.data)], + }); + } + if (findItemInList(state.unvotedList, action.data) > -1) { + return state; + } + return Object.assign({}, state, { + refresh: false, + unvotedList: [ + ...state.unvotedList, + Object.assign(action.data, { selected: false, dirty: true }), + ], + }); + case actionTypes.accountLoggedOut: + return Object.assign({}, state, { + votedList: [], + unvotedList: [], + refresh: true, + }); + case actionTypes.votesCleared: + return Object.assign({}, state, { + votedList: state.votedList.filter(item => !item.pending), + unvotedList: state.unvotedList.filter(item => !item.pending), + refresh: true, + }); + case actionTypes.pendingVotesAdded: + return Object.assign({}, state, { + votedList: state.votedList.map(item => Object.assign(item, { pending: true })), + unvotedList: state.unvotedList.map(item => Object.assign(item, { pending: true })), + }); + default: + return state; + } +}; + +export default voting; diff --git a/src/store/reducers/voting.test.js b/src/store/reducers/voting.test.js new file mode 100644 index 000000000..57a63abb5 --- /dev/null +++ b/src/store/reducers/voting.test.js @@ -0,0 +1,140 @@ +import { expect } from 'chai'; +import actionTypes from '../../constants/actions'; +import voting from './voting'; + +describe('Reducer: voting(state, action)', () => { + const state = { + votedList: [ + { + address: 'voted address1', + }, + { + address: 'voted address2', + }, + ], + unvotedList: [ + { + address: 'unvoted address1', + }, + { + address: 'unvoted address2', + }, + ], + }; + it('should render default state', () => { + const action = { + type: '', + }; + const changedState = voting(state, action); + expect(changedState).to.be.equal(state); + }); + it('should be 1 items in state.unvotedList', () => { + const action = { + type: actionTypes.addedToVoteList, + data: { + voted: true, + address: 'unvoted address1', + }, + }; + const changedState = voting(state, action); + expect(changedState.unvotedList).to.have.lengthOf(1); + }); + + it('should return state if action.data existed in votedList before', () => { + const action = { + type: actionTypes.addedToVoteList, + data: { + address: 'voted address1', + }, + }; + const changedState = voting(state, action); + expect(changedState).to.be.deep.equal(state); + }); + + it('should be 3 items in state.votedList', () => { + const action = { + type: actionTypes.addedToVoteList, + data: { + address: 'voted address3', + }, + }; + const changedState = voting(state, action); + expect(changedState.votedList).to.have.lengthOf(3); + }); + + it('should be 1 items in state.votedList', () => { + const action = { + type: actionTypes.removedFromVoteList, + data: { + voted: false, + address: 'voted address1', + }, + }; + const changedState = voting(state, action); + expect(changedState.votedList).to.have.lengthOf(1); + }); + + it('should return state if action.data existed in unvotedList before', () => { + const action = { + type: actionTypes.removedFromVoteList, + data: { + voted: true, + address: 'unvoted address2', + }, + }; + const changedState = voting(state, action); + expect(changedState).to.be.deep.equal(state); + }); + + it('should be 3 items in state.unvotedList', () => { + const action = { + type: actionTypes.removedFromVoteList, + data: { + voted: true, + address: 'unvoted address3', + }, + }; + const changedState = voting(state, action); + expect(changedState.unvotedList).to.have.lengthOf(3); + }); + + it('should add pending to all items in votedList and unvotedList', () => { + const action = { + type: actionTypes.pendingVotesAdded, + }; + const expectedState = { + votedList: [ + { + address: 'voted address1', + pending: true, + }, + { + address: 'voted address2', + pending: true, + }, + ], + unvotedList: [ + { + address: 'unvoted address1', + pending: true, + }, + { + address: 'unvoted address2', + pending: true, + }, + ], + }; + const changedState = voting(state, action); + expect(changedState).to.be.deep.equal(expectedState); + }); + + it('should remove all pending in votedList and unvotedList', () => { + const action = { + type: actionTypes.votesCleared, + }; + const changedState = voting(state, action); + expect(changedState.unvotedList).to.have.lengthOf(0); + expect(changedState.votedList).to.have.lengthOf(0); + expect(changedState.refresh).to.be.equal(true); + }); +}); diff --git a/src/tests.js b/src/tests.js new file mode 100644 index 000000000..7947673f2 --- /dev/null +++ b/src/tests.js @@ -0,0 +1,15 @@ +import chai from 'chai'; +import sinonChai from 'sinon-chai'; +import chaiEnzyme from 'chai-enzyme'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; +import sinonStubPromise from 'sinon-stub-promise'; + +chai.use(sinonChai); +chai.use(chaiEnzyme()); +chai.use(chaiAsPromised); +sinonStubPromise(sinon); + +// load all tests into one bundle +const testsContext = require.context('.', true, /\.test\.js$/); +testsContext.keys().forEach(testsContext); diff --git a/src/theme/theme.js b/src/theme/theme.js deleted file mode 100644 index 5c0f2574c..000000000 --- a/src/theme/theme.js +++ /dev/null @@ -1,43 +0,0 @@ -// https://angular-md-color.com - -app.config(($mdThemingProvider) => { - $mdThemingProvider.definePalette('customPrimary', { - 50: '#55c2fd', - 100: '#3cb9fd', - 200: '#23b0fd', - 300: '#09a7fd', - 400: '#0298ea', - 500: '#0288d1', - 600: '#0278b8', - 700: '#02679e', - 800: '#015785', - 900: '#01466c', - A100: '#6ecbfe', - A200: '#88d4fe', - A400: '#a1ddfe', - A700: '#013653', - contrastDefaultColor: 'light', - }); - - $mdThemingProvider.definePalette('customAccent', { - 50: '#181b1c', - 100: '#24282a', - 200: '#303537', - 300: '#3c4245', - 400: '#474f53', - 500: '#535c60', - 600: '#6b767c', - 700: '#778389', - 800: '#849095', - 900: '#929ca1', - A100: '#6b767c', - A200: '#5f696e', - A400: '#535c60', - A700: '#a0a8ad', - }); - - $mdThemingProvider - .theme('default') - .primaryPalette('customPrimary') - .accentPalette('customAccent'); -}); diff --git a/src/util/animateOnChange/animateOnChange.js b/src/util/animateOnChange/animateOnChange.js deleted file mode 100644 index b3fd19500..000000000 --- a/src/util/animateOnChange/animateOnChange.js +++ /dev/null @@ -1,9 +0,0 @@ -app.directive('animateOnChange', ($animate, $timeout) => (scope, elem, attr) => { - scope.$watch(attr.animateOnChange, (nv, ov) => { - if (nv !== ov) { - $animate.addClass(elem, 'change').then(() => { - $timeout(() => $animate.removeClass(elem, 'change')); - }); - } - }); -}); diff --git a/src/utils/api/account.js b/src/utils/api/account.js new file mode 100644 index 000000000..889c05b99 --- /dev/null +++ b/src/utils/api/account.js @@ -0,0 +1,53 @@ +import Lisk from 'lisk-js'; +import { requestToActivePeer } from './peers'; + +export const getAccount = (activePeer, address) => + new Promise((resolve, reject) => { + activePeer.getAccount(address, (data) => { + if (data.success) { + resolve(data.account); + } else if (!data.success && data.error === 'Account not found') { + // when the account has no transactions yet (therefore is not saved on the blockchain) + // this endpoint returns { success: false } + resolve({ + address, + balance: 0, + }); + } else { + reject(data); + } + }); + }); + +export const setSecondPassphrase = (activePeer, secondSecret, publicKey, secret) => + requestToActivePeer(activePeer, 'signatures', { secondSecret, publicKey, secret }); + +export const send = (activePeer, recipientId, amount, secret, secondSecret = null) => + requestToActivePeer(activePeer, 'transactions', + { recipientId, amount, secret, secondSecret }); + +export const transactions = (activePeer, address, limit = 20, offset = 0, orderBy = 'timestamp:desc') => + requestToActivePeer(activePeer, 'transactions', { + senderId: address, + recipientId: address, + limit, + offset, + orderBy, + }); + +export const getAccountStatus = activePeer => + requestToActivePeer(activePeer, 'loader/status', {}); + +export const extractPublicKey = passphrase => + Lisk.crypto.getKeys(passphrase).publicKey; + +/** + * @param {String} data - passphrase or public key + */ +export const extractAddress = (data) => { + if (data.indexOf(' ') < 0) { + return Lisk.crypto.getAddress(data); + } + const { publicKey } = Lisk.crypto.getKeys(data); + return Lisk.crypto.getAddress(publicKey); +}; diff --git a/src/utils/api/account.test.js b/src/utils/api/account.test.js new file mode 100644 index 000000000..24de50fb6 --- /dev/null +++ b/src/utils/api/account.test.js @@ -0,0 +1,106 @@ +import { expect } from 'chai'; +import { mock } from 'sinon'; +import { getAccount, setSecondPassphrase, send, transactions, + extractPublicKey, extractAddress } from './account'; +import { activePeerSet } from '../../actions/peers'; + +describe('Utils: Account', () => { + const address = '1449310910991872227L'; + + describe('getAccount', () => { + let activePeerMock; + const activePeer = { + getAccount: () => { }, + }; + + beforeEach(() => { + activePeerMock = mock(activePeer); + }); + + afterEach(() => { + activePeerMock.verify(); + activePeerMock.restore(); + }); + + it('should return a promise that is resolved when activePeer.getAccount() calls its callback with data.success == true', () => { + const response = { + success: true, + balance: 0, + }; + activePeerMock.expects('getAccount').withArgs(address).callsArgWith(1, response); + const requestPromise = getAccount(activePeer, address); + expect(requestPromise).to.eventually.deep.equal(response); + }); + + it('should return a promise that is resolved even when activePeer.getAccount() calls its callback with data.success == false', () => { + const response = { + success: false, + message: 'account doesn\'t exist', + }; + const account = { + address, + balance: 0, + }; + activePeerMock.expects('getAccount').withArgs(address).callsArgWith(1, response); + const requestPromise = getAccount(activePeer, address); + expect(requestPromise).to.eventually.deep.equal(account); + }); + + it('it should resolve account info if available', () => { + const network = { + address: 'http://localhost:8000', + testnet: true, + name: 'Testnet', + nethash: '198f2b61a8eb95fbeed58b8216780b68f697f26b849acf00c8c93bb9b24f783d', + }; + + const { data } = activePeerSet(network); + getAccount(data, address).then((result) => { + expect(result.balance).to.be.equal(0); + }); + }); + }); + + describe('setSecondPassphrase', () => { + it('should return a promise', () => { + const promise = setSecondPassphrase(); + expect(typeof promise.then).to.be.equal('function'); + }); + }); + + describe('send', () => { + it('should return a promise', () => { + const promise = send(); + expect(typeof promise.then).to.be.equal('function'); + }); + }); + + describe('transactions', () => { + it('should return a promise', () => { + const promise = transactions(); + expect(typeof promise.then).to.be.equal('function'); + }); + }); + + describe('extractPublicKey', () => { + it('should return a Hex string from any given string', () => { + const passphrase = 'field organ country moon fancy glare pencil combine derive fringe security pave'; + const publicKey = 'a89751689c446067cc2107ec2690f612eb47b5939d5570d0d54b81eafaf328de'; + expect(extractPublicKey(passphrase)).to.be.equal(publicKey); + }); + }); + + describe('extractAddress', () => { + it('should return the account address from given passphrase', () => { + const passphrase = 'field organ country moon fancy glare pencil combine derive fringe security pave'; + const derivedAddress = '440670704090200331L'; + expect(extractAddress(passphrase)).to.be.equal(derivedAddress); + }); + + it('should return the account address from given public key', () => { + const publicKey = 'a89751689c446067cc2107ec2690f612eb47b5939d5570d0d54b81eafaf328de'; + const derivedAddress = '440670704090200331L'; + expect(extractAddress(publicKey)).to.be.equal(derivedAddress); + }); + }); +}); diff --git a/src/utils/api/delegate.js b/src/utils/api/delegate.js new file mode 100644 index 000000000..5cc5325e3 --- /dev/null +++ b/src/utils/api/delegate.js @@ -0,0 +1,48 @@ +import { requestToActivePeer } from './peers'; + +export const listAccountDelegates = (activePeer, address) => + requestToActivePeer(activePeer, 'accounts/delegates', { address }); + + +export const listDelegates = (activePeer, options) => + requestToActivePeer(activePeer, `delegates/${options.q ? 'search' : ''}`, options); + +export const getDelegate = (activePeer, publicKey) => + requestToActivePeer(activePeer, 'delegates/get', { publicKey }); + +export const vote = (activePeer, secret, publicKey, voteList, unvoteList, secondSecret = null) => + requestToActivePeer(activePeer, 'accounts/delegates', { + secret, + publicKey, + delegates: voteList.map(delegate => `+${delegate.publicKey}`).concat( + unvoteList.map(delegate => `-${delegate.publicKey}`), + ), + secondSecret, + }); + +export const voteAutocomplete = (activePeer, username, votedList) => { + const options = { q: username }; + + return new Promise((resolve, reject) => + listDelegates(activePeer, options) + .then((response) => { + resolve(response.delegates.filter(delegate => + votedList.filter(item => item.username === delegate.username).length === 0, + )); + }) + .catch(reject), + ); +}; + +export const unvoteAutocomplete = (username, votedList) => + new Promise((resolve) => { + resolve(votedList.filter(delegate => delegate.username.indexOf(username) !== -1)); + }); + +export const registerDelegate = (activePeer, username, secret, secondSecret = null) => { + const data = { username, secret }; + if (secondSecret) { + data.secondSecret = secondSecret; + } + return requestToActivePeer(activePeer, 'delegates', data); +}; diff --git a/src/utils/api/delegate.test.js b/src/utils/api/delegate.test.js new file mode 100644 index 000000000..c86980ac1 --- /dev/null +++ b/src/utils/api/delegate.test.js @@ -0,0 +1,143 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { listAccountDelegates, + listDelegates, + getDelegate, + vote, + voteAutocomplete, + unvoteAutocomplete, + registerDelegate } from './delegate'; +import * as peers from './peers'; + +const username = 'genesis_1'; +const secret = 'sample_secret'; +const secondSecret = 'samepl_second_secret'; +const publicKey = ''; + +describe('Utils: Delegate', () => { + let peersMock; + let activePeer; + + beforeEach(() => { + peersMock = sinon.mock(peers); + activePeer = {}; + }); + + afterEach(() => { + peersMock.verify(); + peersMock.restore(); + }); + + describe('listAccountDelegates', () => { + it('should return a promise', () => { + const promise = listAccountDelegates(); + expect(typeof promise.then).to.be.equal('function'); + }); + }); + + describe('listDelegates', () => { + it('should return requestToActivePeer(activePeer, `delegates/`, options) if options = {}', () => { + const options = {}; + const mockedPromise = new Promise((resolve) => { resolve(); }); + peersMock.expects('requestToActivePeer').withArgs(activePeer, 'delegates/', options).returns(mockedPromise); + + const returnedPromise = listDelegates(activePeer, options); + expect(returnedPromise).to.equal(mockedPromise); + }); + + it('should return requestToActivePeer(activePeer, `delegates/search`, options) if options.q is set', () => { + const options = { + q: 'genesis_1', + }; + const mockedPromise = new Promise((resolve) => { resolve(); }); + peersMock.expects('requestToActivePeer').withArgs(activePeer, 'delegates/search', options).returns(mockedPromise); + + const returnedPromise = listDelegates(activePeer, options); + expect(returnedPromise).to.equal(mockedPromise); + }); + }); + + describe('getDelegate', () => { + it('should return requestToActivePeer(activePeer, `delegates/get`, options)', () => { + const options = { publicKey: '"86499879448d1b0215d59cbf078836e3d7d9d2782d56a2274a568761bff36f19"' }; + const mockedPromise = new Promise((resolve) => { resolve(); }); + peersMock.expects('requestToActivePeer').withArgs(activePeer, 'delegates/get', options).returns(mockedPromise); + + const returnedPromise = getDelegate(activePeer, options.publicKey); + expect(returnedPromise).to.equal(mockedPromise); + }); + }); + + describe('unvoteAutocomplete', () => { + it('should return a promise', () => { + const votedList = ['genesis_1', 'genesis_2', 'genesis_3']; + const nonExistingUsername = 'genesis_4'; + const promise = unvoteAutocomplete(username, votedList); + expect(typeof promise.then).to.be.equal('function'); + promise.then((result) => { + expect(result).to.be.equal(true); + }); + + unvoteAutocomplete(nonExistingUsername, votedList).then((result) => { + expect(result).to.be.equal(false); + }); + }); + }); + + describe('registerDelegate', () => { + it('should return requestToActivePeer(activePeer, `delegates`, data)', () => { + const data = { + username: 'test', + secret: 'wagon dens', + secondSecret: 'wagon dens', + }; + const mockedPromise = new Promise((resolve) => { resolve(); }); + peersMock.expects('requestToActivePeer').withArgs(activePeer, 'delegates', data).returns(mockedPromise); + + const returnedPromise = registerDelegate( + activePeer, data.username, data.secret, data.secondSecret); + expect(returnedPromise).to.equal(mockedPromise); + }); + + it('should return requestToActivePeer(activePeer, `delegates`, data) even if no secondSecret specified', () => { + const data = { + username: 'test', + secret: 'wagon dens', + }; + const mockedPromise = new Promise((resolve) => { resolve(); }); + peersMock.expects('requestToActivePeer').withArgs(activePeer, 'delegates', data).returns(mockedPromise); + + const returnedPromise = registerDelegate(activePeer, data.username, data.secret); + expect(returnedPromise).to.equal(mockedPromise); + }); + }); + + describe('vote', () => { + it('should return a promise', () => { + const voteList = [{ + username: 'genesis_1', + publicKey: 'sample_publicKey_1', + }]; + const unvoteList = [{ + username: 'genesis_2', + publicKey: 'sample_publicKey_2', + }]; + const promise = vote(null, secret, publicKey, voteList, unvoteList, secondSecret); + expect(typeof promise.then).to.be.equal('function'); + }); + }); + + describe('voteAutocomplete', () => { + it('should return requestToActivePeer(activePeer, `delegates/`, data)', () => { + const delegates = [ + { username: 'genesis_42' }, + { username: 'genesis_44' }, + ]; + const mockedPromise = new Promise((resolve) => { resolve({ success: true, delegates }); }); + peersMock.expects('requestToActivePeer').withArgs(activePeer, 'delegates/search', { q: username }).returns(Promise.resolve({ success: true, delegates })); + + const returnedPromise = voteAutocomplete(activePeer, username, {}); + expect(returnedPromise).to.deep.equal(mockedPromise); + }); + }); +}); diff --git a/src/utils/api/forging.js b/src/utils/api/forging.js new file mode 100644 index 000000000..bcd798d60 --- /dev/null +++ b/src/utils/api/forging.js @@ -0,0 +1,16 @@ +import moment from 'moment'; +import { requestToActivePeer } from './peers'; + +export const getForgedBlocks = (activePeer, limit = 10, offset = 0, generatorPublicKey) => + requestToActivePeer(activePeer, 'blocks', { + limit, + offset, + generatorPublicKey, + }); + +export const getForgedStats = (activePeer, startMoment, generatorPublicKey) => + requestToActivePeer(activePeer, 'delegates/forging/getForgedByAccount', { + generatorPublicKey, + start: moment(startMoment).unix(), + end: moment().unix(), + }); diff --git a/src/utils/api/forging.test.js b/src/utils/api/forging.test.js new file mode 100644 index 000000000..6a0d0a068 --- /dev/null +++ b/src/utils/api/forging.test.js @@ -0,0 +1,19 @@ +import { expect } from 'chai'; +import { getForgedBlocks, getForgedStats } from './forging'; + + +describe('Utils: Forging', () => { + describe('getForgedBlocks', () => { + it('should return a promise', () => { + const promise = getForgedBlocks(); + expect(typeof promise.then).to.be.equal('function'); + }); + }); + + describe('getForgedStats', () => { + it('should return a promise', () => { + const promise = getForgedStats(); + expect(typeof promise.then).to.be.equal('function'); + }); + }); +}); diff --git a/src/utils/api/peers.js b/src/utils/api/peers.js new file mode 100644 index 000000000..76d32f0bb --- /dev/null +++ b/src/utils/api/peers.js @@ -0,0 +1,16 @@ +import { loadingStarted, loadingFinished } from '../../utils/loading'; + +/* eslint-disable */ +export const requestToActivePeer = (activePeer, path, urlParams) => + new Promise((resolve, reject) => { + loadingStarted(path); + activePeer.sendRequest(path, urlParams, (data) => { + if (data.success) { + resolve(data); + } else { + reject(data); + } + loadingFinished(path); + }); + }); +/* eslint-enable */ diff --git a/src/utils/api/peers.test.js b/src/utils/api/peers.test.js new file mode 100644 index 000000000..04916de47 --- /dev/null +++ b/src/utils/api/peers.test.js @@ -0,0 +1,43 @@ +import { expect } from 'chai'; +import { mock } from 'sinon'; +import { requestToActivePeer } from './peers'; + + +describe('Utils: Peers', () => { + describe('requestToActivePeer', () => { + let activePeerMock; + const path = '/test/'; + const urlParams = {}; + const activePeer = { + sendRequest: () => { }, + }; + + beforeEach(() => { + activePeerMock = mock(activePeer); + }); + + afterEach(() => { + activePeerMock.restore(); + }); + + it('should return a promise that is resolved when activePeer.sendRequest() calls its callback with data.success == true', () => { + const response = { + success: true, + data: [], + }; + activePeerMock.expects('sendRequest').withArgs(path, urlParams).callsArgWith(2, response); + const requestPromise = requestToActivePeer(activePeer, path, urlParams); + expect(requestPromise).to.eventually.deep.equal(response); + }); + + it('should return a promise that is resolved when activePeer.sendRequest() calls its callback with data.success == true', () => { + const response = { + success: false, + message: 'some error message', + }; + activePeerMock.expects('sendRequest').withArgs(path, urlParams).callsArgWith(2, response); + const requestPromise = requestToActivePeer(activePeer, path, urlParams); + expect(requestPromise).to.be.rejectedWith(response); + }); + }); +}); diff --git a/src/utils/loading.js b/src/utils/loading.js new file mode 100644 index 000000000..793699a2e --- /dev/null +++ b/src/utils/loading.js @@ -0,0 +1,7 @@ +import { loadingStarted as loadingStartedAction, loadingFinished as loadingFinishedAction } from '../actions/loading'; +import store from '../store'; + + +export const loadingStarted = data => store.dispatch(loadingStartedAction(data)); + +export const loadingFinished = data => store.dispatch(loadingFinishedAction(data)); diff --git a/src/utils/lsk.js b/src/utils/lsk.js new file mode 100644 index 000000000..3cfbc7217 --- /dev/null +++ b/src/utils/lsk.js @@ -0,0 +1,11 @@ +import BigNumber from 'bignumber.js'; + +BigNumber.config({ ERRORS: false }); + +export const fromRawLsk = value => ( + new BigNumber(value || 0).dividedBy(new BigNumber(10).pow(8)).toFixed() +); + +export const toRawLsk = value => ( + new BigNumber(value * new BigNumber(10).pow(8)).round(0).toNumber() +); diff --git a/src/utils/lsk.test.js b/src/utils/lsk.test.js new file mode 100644 index 000000000..7e2dfcc1e --- /dev/null +++ b/src/utils/lsk.test.js @@ -0,0 +1,24 @@ +import { expect } from 'chai'; +import { fromRawLsk, toRawLsk } from './lsk'; + +describe('lsk', () => { + describe('fromRawLsk', () => { + it('should convert 100000000 to "1"', () => { + expect(fromRawLsk(100000000)).to.be.equal('1'); + }); + + it('should convert 0 to "0"', () => { + expect(fromRawLsk(0)).to.be.equal('0'); + }); + }); + + describe('toRawLsk', () => { + it('should convert 1 to 100000000', () => { + expect(toRawLsk(1)).to.be.equal(100000000); + }); + + it('should convert 0 to 0', () => { + expect(toRawLsk(0)).to.be.equal(0); + }); + }); +}); diff --git a/src/utils/metronome.js b/src/utils/metronome.js new file mode 100644 index 000000000..83ff4199b --- /dev/null +++ b/src/utils/metronome.js @@ -0,0 +1,89 @@ +// import { ipcMain as ipc, BrowserWindow } from 'electron'; +import { SYNC_ACTIVE_INTERVAL, SYNC_INACTIVE_INTERVAL } from '../constants/api'; +import env from '../constants/env'; +import actionsType from '../constants/actions'; + +class Metronome { + constructor(dispatchFn) { + this.interval = SYNC_ACTIVE_INTERVAL; + this.factor = 0; + this.running = false; + this.dispatchFn = dispatchFn; + } + + /** + * Broadcast an event from rootScope downwards + * + * @param {Date} lastBeat + * @param {Date} now + * @param {Number} factor + * @param {Number} interval + * @memberOf Metronome + * @private + */ + _dispatch(lastBeat, now, factor, interval) { + this.dispatchFn({ + type: actionsType.metronomeBeat, + data: { lastBeat, now, factor, interval }, + }); + } + + /** + * We're calling this in framerate. + * calls broadcast method every SYNC_(IN)ACTIVE_INTERVAL and + * sends a numeric factor for ease of use as multiples of updateInterval. + * + * @memberOf Metronome + * @private + */ + _step() { + const now = new Date(); + if (!this.lastBeat || (now - this.lastBeat >= this.interval)) { + this._dispatch(this.lastBeat, now, this.factor, this.interval); + this.lastBeat = now; + this.factor += this.factor < 9 ? 1 : -9; + } + if (this.running) { + window.requestAnimationFrame(this._step.bind(this)); + } + } + + /** + * Changes the duration of intervals when sending application + * to tray or activating it again. + * + * @memberOf Metronome + * @private + */ + _initIntervalToggler() { + const { ipc } = window; + ipc.on('blur', () => { this.interval = SYNC_INACTIVE_INTERVAL; }); + ipc.on('focus', () => { this.interval = SYNC_ACTIVE_INTERVAL; }); + } + + /** + * Terminates the intervals + * + * @memberOf Metronome + */ + terminate() { + this.running = false; + } + + /** + * Starts the first frame by calling requestAnimationFrame. + * + * @memberOf Metronome + */ + init() { + if (!this.running) { + window.requestAnimationFrame(this._step.bind(this)); + } + if (env.production) { + this._initIntervalToggler(); + } + this.running = true; + } +} + +export default Metronome; diff --git a/src/utils/metronome.test.js b/src/utils/metronome.test.js new file mode 100644 index 000000000..f2f1d7658 --- /dev/null +++ b/src/utils/metronome.test.js @@ -0,0 +1,135 @@ +import { expect } from 'chai'; +import { spy } from 'sinon'; +import Metronome from './metronome'; +import { SYNC_ACTIVE_INTERVAL, SYNC_INACTIVE_INTERVAL } from '../constants/api'; +import env from '../constants/env'; + + +describe('Metronome', () => { + let metronome; + const spyDispatch = spy(); + + beforeEach(() => { + metronome = new Metronome(spyDispatch); + }); + + afterEach(() => { + metronome.terminate(); + }); + + it('defines initial settings', () => { + expect(metronome.interval).to.be.equal(SYNC_ACTIVE_INTERVAL); + expect(metronome.running).to.be.equal(false); + expect(metronome.factor).to.be.equal(0); + expect(metronome.dispatchFn).to.be.equal(spyDispatch); + }); + + describe('init', () => { + it('should call requestAnimationFrame if !this.running', () => { + const reqSpy = spy(window, 'requestAnimationFrame'); + metronome.init(); + expect(reqSpy).to.have.been.calledWith(); + window.requestAnimationFrame.restore(); + }); + + it('should not call requestAnimationFrame if this.running', () => { + const reqSpy = spy(window, 'requestAnimationFrame'); + metronome.running = true; + metronome.init(); + expect(reqSpy).to.not.have.been.calledWith(); + window.requestAnimationFrame.restore(); + }); + + it('should call window.ipc.on(\'blur\') and window.ipc.on(\'focus\')', () => { + window.ipc = { + on: spy(), + }; + env.production = true; + metronome.init(); + expect(window.ipc.on).to.have.been.calledWith('blur'); + expect(window.ipc.on).to.have.been.calledWith('focus'); + }); + + it('should set window.ipc to set this.interval to SYNC_INACTIVE_INTERVAL on blur', () => { + const callbacks = {}; + window.ipc = { + on: (type, callback) => { + callbacks[type] = callback; + }, + }; + env.production = true; + metronome.init(); + callbacks.blur(); + expect(metronome.interval).to.equal(SYNC_INACTIVE_INTERVAL); + }); + + it('should set window.ipc to set this.interval to SYNC_ACTIVE_INTERVAL on focus', () => { + const callbacks = {}; + window.ipc = { + on: (type, callback) => { + callbacks[type] = callback; + }, + }; + env.production = true; + metronome.init(); + callbacks.blur(); + expect(metronome.interval).to.equal(SYNC_INACTIVE_INTERVAL); + callbacks.focus(); + expect(metronome.interval).to.equal(SYNC_ACTIVE_INTERVAL); + }); + }); + + describe('terminate', () => { + it('should reset running flag', () => { + metronome.terminate(); + expect(metronome.running).to.be.equal(false); + }); + }); + + describe('_dispatch', () => { + it('should dispatch a Vanilla JS event', () => { + metronome._dispatch(); + expect(spyDispatch).to.have.been.calledWith(); + }); + }); + + describe('_step', () => { + it('should call requestAnimationFrame every 10 sec', () => { + const reqSpy = spy(window, 'requestAnimationFrame'); + metronome.running = true; + metronome._step(); + expect(reqSpy).to.have.been.calledWith(); + window.requestAnimationFrame.restore(); + }); + + it('should never call requestAnimationFrame if running is false', () => { + const reqSpy = spy(window, 'requestAnimationFrame'); + metronome._step(); + expect(reqSpy).not.have.been.calledWith(); + window.requestAnimationFrame.restore(); + }); + + it('should reset the factor after 10 times', () => { + for (let i = 1; i < 12; i++) { + metronome.lastBeat -= 10001; + metronome._step(); + if (i < 10) { + expect(metronome.factor).to.be.equal(i); + } else { + expect(metronome.factor).to.be.equal(i - 10); + } + } + }); + + it('should call _dispatch if lastBeat is older that 10sec', () => { + const reqSpy = spy(metronome, '_dispatch'); + metronome.running = true; + + const now = new Date(); + metronome.lastBeat = now - 20000; + metronome._step(); + expect(reqSpy).to.have.been.calledWith(); + metronome._dispatch.restore(); + }); + }); +}); diff --git a/src/utils/notification.js b/src/utils/notification.js new file mode 100644 index 000000000..353ed870d --- /dev/null +++ b/src/utils/notification.js @@ -0,0 +1,61 @@ +import { fromRawLsk } from './lsk'; +/** + * The Notify factory constructor class + * @class Notify + * @constructor + */ +class Notification { + constructor() { + this.isFocused = true; + } + + /** + * Initialize event listeners + * + * @returns {this} + * @method init + * @memberof Notify + */ + init() { + if (PRODUCTION) { + const { ipc } = window; + ipc.on('blur', () => { this.isFocused = false; }); + ipc.on('focus', () => { this.isFocused = true; }); + } + return this; + } + + /** + * Routing to specific Notification creator based on type param + * @param {string} type + * @param {any} data + * + * @method about + * @public + * @memberof Notify + */ + about(type, data) { + if (this.isFocused) return; + switch (type) { + case 'deposit': + this._deposit(data); + break; + default: break; + } + } + + /** + * Creating notification about deposit + * + * @param {number} amount + * @private + * @memberof Notify + */ + _deposit(amount) { // eslint-disable-line + const body = `You've received ${fromRawLsk(amount)} LSK.`; + new window.Notification('LSK received', { body }); // eslint-disable-line + } +} + +export default new Notification(); + diff --git a/src/utils/notification.test.js b/src/utils/notification.test.js new file mode 100644 index 000000000..7d85e79c0 --- /dev/null +++ b/src/utils/notification.test.js @@ -0,0 +1,43 @@ +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { fromRawLsk } from './lsk'; +import Notification from './notification'; + +describe('Notification', () => { + let notify; + + beforeEach(() => { + notify = Notification.init(); + }); + + describe('about(data)', () => { + const amount = 100000000; + const mockNotification = spy(); + + it('should call this._deposit', () => { + const spyFn = spy(notify, '_deposit'); + notify.isFocused = false; + notify.about('deposit', amount); + expect(spyFn).to.have.been.calledWith(amount); + }); + + it('should call window.Notification', () => { + window.Notification = mockNotification; + const msg = `You've received ${fromRawLsk(amount)} LSK.`; + + notify.isFocused = false; + notify.about('deposit', amount); + expect(mockNotification).to.have.been.calledWith( + 'LSK received', { body: msg }, + ); + mockNotification.reset(); + }); + + it('should not call window.Notification if app is focused', () => { + notify.isFocused = true; + notify.about('deposit', amount); + expect(mockNotification).to.have.been.not.calledWith(); + mockNotification.reset(); + }); + }); +}); diff --git a/src/utils/passphrase.js b/src/utils/passphrase.js new file mode 100644 index 000000000..7e1c046d9 --- /dev/null +++ b/src/utils/passphrase.js @@ -0,0 +1,89 @@ +import crypto from 'crypto'; + +if (global._bitcore) delete global._bitcore; +const mnemonic = require('bitcore-mnemonic'); + +/** + * Generates an array of 16 members equal to given value + * + * @param {Number|String} value + * @returns {Array} - Array of 16 'value's + */ +export const emptyByte = (value) => Array.apply(null, Array(16)).map(item => value); //eslint-disable-line + +/** + * fills the left side of str with a given padding string to meet the required length + * + * @param {String} str - The string to fill with pad + * @param {String} pad - The string used as padding + * @param {Number} length - The final length of the string after adding padding + * @private + * @returns {string} padded string + */ +const leftPadd = (str, pad, length) => { + let paddedStr = str; + while (paddedStr.length < length) paddedStr = pad + paddedStr; + return paddedStr; +}; + +/** + * Resets previous settings and creates a step with a random length between 1.6% to 3.2% + */ +const init = (rand = Math.random()) => ({ + step: (160 + Math.floor(rand * 160)) / 100, + percentage: 0, + seed: emptyByte('00'), + byte: emptyByte(0), +}); + +/** + * - From a zero byte: + * - Removes all the 1s and replaces all the 1s with their index + * - Creates a random number with the length of resulting array (pos) + * - sets the bit in the pos position + * - creates random byte using crypto and assigns that to seed in the + * position of pos + * - Repeats this until the length of the given byte is zero. + * + * @param {Array} byte - Array of 16 numbers + * @param {Array} seed - Array of 16 hex numbers in String format + * @param {Number} percentage + * @param {Number} step + * + * @returns {number[]} The input array whose member is pos is set + */ +export const generateSeed = ({ byte, seed, percentage, step } = init(), rand = Math.random()) => { + const available = byte.map((bit, index) => (!bit ? index : null)).filter(bit => (bit !== null)); + const seedIndex = (available.length > 0) ? + available[parseInt(rand * available.length, 10)] : + parseInt(rand * byte.length, 10); + + const content = leftPadd(crypto.randomBytes(1)[0].toString(16), '0', 2); + + return { + seed: seed.map((item, idx) => ((idx === seedIndex) ? content : item)), + byte: available.length > 0 ? byte.map((item, idx) => + ((idx === seedIndex) ? 1 : item)) : emptyByte(0), + percentage: (percentage + step), + step, + }; +}; + + /** + * Generates a passphrase from a given seed array using mnemonic + * + * @param {string[]} seed - An array of 16 hex numbers in string format + * @returns {string} The generated passphrase + */ +export const generatePassphrase = ({ seed }) => (new mnemonic(new Buffer(seed.join(''), 'hex'))).toString(); + + /** + * Checks if passphrase is valid using mnemonic + * + * @param {string} passphrase + * @returns {bool} isValidPassphrase + */ +export const isValidPassphrase = (passphrase) => { + const normalizedValue = passphrase.replace(/ +/g, ' ').trim().toLowerCase(); + return normalizedValue.split(' ').length >= 12 && mnemonic.isValid(normalizedValue); +}; diff --git a/src/utils/passphrase.test.js b/src/utils/passphrase.test.js new file mode 100644 index 000000000..b7fe6cd77 --- /dev/null +++ b/src/utils/passphrase.test.js @@ -0,0 +1,133 @@ +import { expect } from 'chai'; +import { generateSeed, generatePassphrase, isValidPassphrase } from './passphrase'; + +if (global._bitcore) delete global._bitcore; +const mnemonic = require('bitcore-mnemonic'); + +const randoms = [ + 0.35125316992864564, 0.6836880327771695, 0.05720201294124072, 0.7136064360838184, + 0.7655709865481362, 0.9670469669099078, 0.6699998930954159, 0.4377283727720742, + 0.4520746683154777, 0.32483170399964156, 0.4176417086143116, 0.07485544616959183, + 0.5864838724752106, 0.992458166265721, 0.2953356626104806, 0.9253299970794635, + 0.8315835772346538, 0.22814980738815094, 0.8816817378085378, 0.04130993200534738, + 0.5620806959233753, 0.6783347082550804, 0.6582754298111972, 0.9407265080520071, + 0.2992502442749252, 0.446331305340699, 0.7475413720669093, 0.7148112168330099, + 0.6788473409981837, 0.30739905372746334, 0.9298657315997332, 0.8201760978951984, + 0.6764618475481385, 0.5691464854512147, 0.5313376750438739, 0.5237600303660543, + 0.6401198419347358, 0.8468681870031731, 0.6879250413863383, 0.9022445733593758, + 0.6840680274208077, 0.43845305327425543, 0.3536761452812316, 0.6880204375727299, + 0.0031374923265699017, 0.358253951306601, 0.42538677883450493, 0.2302610361700177, + 0.8629233919556387, 0.12440329885721546, 0.2570612143448776, 0.6229293361878305, + 0.20181966897105164, 0.9033813245036659, 0.6185390814896223, 0.5838114441897022, + 0.4286790452862015, 0.9228213760352748, 0.16078938960879063, 0.2432043546566549, + 0.9437527202841303, 0.16061288456693723, 0.7563419998061267, 0.40474387363411846, + 0.3570630021881842, 0.7174834892596451, 0.5738603646580553, 0.4816911666908623, + 0.9886525801368591, 0.35845007280863483, 0.394348474116778, 0.8997682191430569, + 0.19395118550133095, 0.6997967408938839, 0.4458043545792023, 0.22871202300931692, + 0.8186473189283325, 0.6697408150930801, 0.8993462696905463, 0.7707387535683512, + 0.9375571099241271, 0.18283746629429265, 0.7672618424464528, 0.9543394877705333, + 0.43815812156490375, 0.21036097696233202, 0.19472050129244556, 0.19569161094514342, + 0.2375539305396075, 0.880419698905385, 0.6007826720223282, 0.5216579498742571, + 0.753594465390701, 0.6525051098186971, 0.6023203664559926, 0.4238157837426728, + 0.13672689203370214, 0.5882714217174292, 0.2472962448966607, 0.39353081489929864, +]; + +describe('Passphrase', () => { + describe('generateSeed', () => { + it('should generate a predictable sequence of bytes for given list of randoms', () => { + const bytes = [ + [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0], + [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0], + [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1], + [1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 0, 1], + [1, 0, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 1, 0, 1], + [1, 0, 0, 0, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1], + [1, 0, 0, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1], + [1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 1], + [1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 1], + [1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1], + [1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0], + [1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0], + [1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 0], + [1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1], + [1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1], + [1, 0, 0, 1, 1, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1], + [1, 0, 0, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1], + [1, 0, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1], + [1, 0, 0, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1], + [1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1], + [1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0], + [0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0], + [0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0], + [0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0], + [0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0], + [1, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0], + [1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0], + [1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0], + [0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1, 0], + ]; + + let data; + randoms.forEach((rand, i) => { + if ((!data || data.percentage < 100) && i < bytes.length) { + data = generateSeed(data, rand); + expect(data.byte).to.deep.equal(bytes[i]); + } + }); + }); + + it('should generate an array of 16 hex numbers as seed', () => { + const { seed } = generateSeed(); + seed.forEach((num) => { + expect(parseInt(`0x${num}`, 10)).to.be.below(256); + }); + }); + }); + + describe('generatePassphrase', () => { + const seed = ['e6', '3c', 'd1', '36', 'e9', '70', '5f', 'c0', '4d', '31', 'ef', 'b8', 'd6', '53', '48', '11']; + + it('generates a valid random passphrase from a given seed', () => { + const passphrase = generatePassphrase({ seed }); + expect(mnemonic.isValid(passphrase)).to.be.equal(true); + }); + }); + + describe('isValidPassphrase', () => { + it('recognises a valid passphrase', () => { + const passphrase = 'wagon stock borrow episode laundry kitten salute link globe zero feed marble'; + expect(isValidPassphrase(passphrase)).to.be.equal(true); + }); + + it('recognises an invalid passphrase', () => { + const passphrase = 'stock borrow episode laundry kitten salute link globe zero feed marble'; + expect(isValidPassphrase(passphrase)).to.be.equal(false); + }); + }); +}); diff --git a/src/utils/polyfills.js b/src/utils/polyfills.js new file mode 100644 index 000000000..b9ef8a1ed --- /dev/null +++ b/src/utils/polyfills.js @@ -0,0 +1,37 @@ +/** + * Deep compare any two parameter for equality. if not a primary value, + * compares all the members recursively checking if all primary value members are equal + * + * @private + * @method equals + * @param {any} ref1 - Value to compare equality + * @param {any} ref2 - Value to compare equality + * @returns {Boolean} Whether two parameters are equal or not + */ + /* eslint-disable import/prefer-default-export */ +export const deepEquals = (ref1, ref2) => { + /* eslint-disable eqeqeq */ + + if (ref1 == undefined && ref2 == undefined) { + return true; + } + if (typeof ref1 !== typeof ref2 || (typeof ref1 !== 'object' && ref1 != ref2)) { + return false; + } + + const props1 = (ref1 instanceof Array) ? ref1.map((val, idx) => idx) : Object.keys(ref1).sort(); + const props2 = (ref2 instanceof Array) ? ref2.map((val, idx) => idx) : Object.keys(ref2).sort(); + + let isEqual = true; + + props1.forEach((value1, index) => { + if (typeof ref1[value1] === 'object' && typeof ref2[props2[index]] === 'object') { + if (!deepEquals(ref1[value1], ref2[props2[index]])) { + isEqual = false; + } + } else if (ref1[value1] != ref2[props2[index]]) { + isEqual = false; + } + }); + return isEqual; +}; diff --git a/src/utils/polyfills.test.js b/src/utils/polyfills.test.js new file mode 100644 index 000000000..1621ae614 --- /dev/null +++ b/src/utils/polyfills.test.js @@ -0,0 +1,42 @@ +import { expect } from 'chai'; +import { deepEquals } from './polyfills'; + +describe('Polyfills', () => { + describe('deepEquals', () => { + it('should return True if both parameters are undefined', () => { + const ref1 = undefined; + const ref2 = undefined; + expect(deepEquals(ref1, ref2)).to.be.equal(true); + }); + + it('should return false for any none equal primary type pair of values', () => { + // same type different values + let ref1 = 1; + let ref2 = 2; + expect(deepEquals(ref1, ref2)).to.be.equal(false); + + // different types + ref1 = '1'; + ref2 = 1; + expect(deepEquals(ref1, ref2)).to.be.equal(false); + }); + + it('should return false for reference values with different primary members', () => { + // different arrays + let ref1 = [2, 3, 4, 5]; + let ref2 = [2, 3, 4, 6]; + expect(deepEquals(ref1, ref2)).to.be.equal(false); + + // different objects + ref1 = { key1: { inner1: 'value 1' } }; + ref2 = { key2: { inner2: 'value 2' } }; + expect(deepEquals(ref1, ref2)).to.be.equal(false); + }); + it('should return true for reference values with equal primary members', () => { + // same objects + const ref1 = { key1: [1, 2, 3] }; + const ref2 = { key1: [1, 2, 3] }; + expect(deepEquals(ref1, ref2)).to.be.equal(true); + }); + }); +}); diff --git a/test/.eslintrc b/test/.eslintrc deleted file mode 100644 index 7282ada3d..000000000 --- a/test/.eslintrc +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "airbnb-base", - "plugins": [ - "import" - ], - "globals": { - "inject": true - }, - "env": { - "mocha": true - }, - "rules": { - } -} diff --git a/test/components/delegateRegistration/delegateRegistration.spec.js b/test/components/delegateRegistration/delegateRegistration.spec.js deleted file mode 100644 index b76215c0f..000000000 --- a/test/components/delegateRegistration/delegateRegistration.spec.js +++ /dev/null @@ -1,52 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe('Delegate registration component', () => { - let $scope; - const template = ''; - const form = { - $setPristine: () => {}, - $setUntouched: () => {}, - valid: true, - }; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject(($compile, $rootScope) => { - const scope = $rootScope.$new(); - $compile(template)(scope); - scope.$digest(); - $scope = scope.$$childTail; - })); - - it('defines a cancel method to hide modal and reset the form', () => { - const spyReset = sinon.spy($scope, 'reset'); - - expect($scope.cancel).to.not.equal(undefined); - $scope.cancel(form); - $scope.$digest(); - - expect(spyReset).to.have.been.calledWith(); - }); - - it('defines a reset method to reset the form and form values', () => { - const spyPristine = sinon.spy(form, '$setPristine'); - const spyUntouched = sinon.spy(form, '$setUntouched'); - - expect($scope.reset).to.not.equal(undefined); - - $scope.form.name = 'TEST_NAME'; - $scope.form.error = 'TEST_ERROR'; - $scope.reset(form); - $scope.$digest(); - - expect($scope.form.name).to.equal(''); - expect($scope.form.error).to.equal(''); - expect(spyPristine).to.have.been.calledWith(); - expect(spyUntouched).to.have.been.calledWith(); - }); -}); diff --git a/test/components/delegates/delegates.spec.js b/test/components/delegates/delegates.spec.js deleted file mode 100644 index 555388731..000000000 --- a/test/components/delegates/delegates.spec.js +++ /dev/null @@ -1,306 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe('Delegates component', () => { - let $compile; - let $rootScope; - let element; - let $scope; - let Peers; - let lsk; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_$compile_, _$rootScope_, _Peers_, _lsk_) => { - $compile = _$compile_; - $rootScope = _$rootScope_; - Peers = _Peers_; - lsk = _lsk_; - })); - - beforeEach(() => { - Peers.active = { sendRequest() {} }; - const mock = sinon.mock(Peers.active); - mock.expects('sendRequest').withArgs('accounts/delegates').callsArgWith(2, { - success: true, - delegates: Array.from({ length: 10 }, (v, k) => ({ - username: `genesis_${k}`, - })), - }); - mock.expects('sendRequest').withArgs('delegates/').callsArgWith(2, { - success: true, - delegates: Array.from({ length: 100 }, (v, k) => ({ - username: `genesis_${k}`, - })), - }); - - $scope = $rootScope.$new(); - $scope.passphrase = 'robust swift grocery peasant forget share enable convince deputy road keep cheap'; - $scope.account = { - address: '8273455169423958419L', - balance: lsk.from(100), - }; - element = $compile('')($scope); - $scope.$digest(); - }); - - const BUTTON_LABEL = 'Vote'; - it(`should contain button saying "${BUTTON_LABEL}"`, () => { - expect(element.find('md-card-title button').text()).to.contain(BUTTON_LABEL); - }); -}); - -describe('delegates component controller', () => { - beforeEach(angular.mock.module('app')); - - let $rootScope; - let $scope; - let controller; - let $componentController; - let activePeerMock; - let Peers; - let delegates; - let $q; - let $timeout; - - beforeEach(inject((_$componentController_, _$rootScope_, _$q_, _Peers_, _$timeout_) => { - $componentController = _$componentController_; - $rootScope = _$rootScope_; - Peers = _Peers_; - $q = _$q_; - $timeout = _$timeout_; - })); - - beforeEach(() => { - delegates = Array.from({ length: 100 }, (v, k) => ({ - username: `genesis_${k}`, - status: {}, - })); - - Peers.active = { sendRequest() {} }; - activePeerMock = sinon.mock(Peers.active); - - $scope = $rootScope.$new(); - controller = $componentController('delegates', $scope, { - account: { - address: '8273455169423958419L', - balance: '10000', - }, - }); - controller.delegates = delegates; - controller.voteList = delegates.slice(1, 3); - controller.delegatesTotalCount = delegates.length + 100; - }); - - describe('constructor()', () => { - it('sets $watch on $scope.search that fetches delegates matching the search term', () => { - activePeerMock.expects('sendRequest').withArgs('delegates/search').callsArgWith(2, { - success: true, - delegates, - }); - controller.$scope.$digest(); - controller.$scope.search = 'genesis_42'; - controller.$scope.$digest(); - }); - - it('sets to run this.updateALl() $on "peerUpdate" is $emited', () => { - const mock = sinon.mock(controller); - mock.expects('updateAll').withArgs(); - controller.$scope.$emit('peerUpdate'); - mock.verify(); - mock.restore(); - }); - }); - - describe('showMore()', () => { - it('increases this.delegatesDisplayedCount by 20 if this.delegatesDisplayedCount < this.delegates.length', () => { - const initialCount = controller.delegatesDisplayedCount; - controller.showMore(); - expect(controller.delegatesDisplayedCount).to.equal(initialCount + 20); - }); - - it('fetches more delegates if this.delegatesDisplayedCount - this.delegates.length <= 20', () => { - activePeerMock.expects('sendRequest').withArgs('delegates/').callsArgWith(2, { - success: true, - delegates, - }); - - controller.delegatesDisplayedCount = 100; - controller.loading = false; - const initialCount = controller.delegatesDisplayedCount; - controller.showMore(); - expect(controller.delegatesDisplayedCount).to.equal(initialCount); - }); - }); - - describe('selectionChange(delegate)', () => { - it('pushes delegate to this.unvoteList if delegate.status.voted && !delegate.status.selected', () => { - const delegate = { - status: { - voted: true, - selected: false, - }, - }; - controller.selectionChange(delegate); - expect(controller.unvoteList).to.contain(delegate); - }); - - it('pushes delegate to this.voteList if !delegate.status.voted && delegate.status.selected', () => { - const delegate = { - status: { - voted: false, - selected: true, - }, - }; - controller.selectionChange(delegate); - expect(controller.voteList).to.contain(delegate); - }); - - it('removes delegate from this.unvoteList if delegate.status.voted && delegate.status.selected', () => { - const delegate = { - status: { - voted: true, - selected: true, - }, - }; - controller.unvoteList = [delegate]; - controller.selectionChange(delegate); - expect(controller.unvoteList).to.not.contain(delegate); - }); - - it('removes delegate from this.voteList if !delegate.status.voted && !delegate.status.selected', () => { - const delegate = { - status: { - voted: false, - selected: false, - }, - }; - controller.voteList = [delegate]; - controller.selectionChange(delegate); - expect(controller.voteList).to.not.contain(delegate); - }); - }); - - describe('clearSearch()', () => { - it('sets this.$scope.search to empty string', () => { - controller.$scope.search = 'non-empty string'; - controller.clearSearch(); - expect(controller.$scope.search).to.equal(''); - }); - }); - - describe('openVoteDialog()', () => { - it('opens vote dialog', () => { - const spy = sinon.spy(controller.dialog, 'modal'); - controller.openVoteDialog(); - expect(spy).to.have.been.calledWith(); - }); - }); - - describe('addToUnvoteList()', () => { - it('adds delegate to unvoteList', () => { - const delegate = { - username: 'test', - status: { - voted: true, - selected: true, - }, - }; - controller.addToUnvoteList(delegate); - expect(controller.unvoteList.length).to.equal(1); - expect(controller.unvoteList[0]).to.deep.equal(delegate); - }); - - it('does not add delegate to unvoteList if already there', () => { - const delegate = { - username: 'genesis_42', - status: { - voted: true, - selected: false, - }, - }; - controller.unvoteList = [delegate]; - controller.addToUnvoteList(delegate); - expect(controller.unvoteList.length).to.equal(1); - }); - }); - - describe('setPendingVotes()', () => { - it('clears this.voteList and this.unvoteList', () => { - controller.unvoteList = controller.delegates.slice(10, 13); - expect(controller.voteList.length).to.not.equal(0); - expect(controller.unvoteList.length).to.not.equal(0); - - controller.setPendingVotes(); - - expect(controller.voteList.length).to.equal(0); - expect(controller.unvoteList.length).to.equal(0); - }); - }); - - describe('checkPendingVotes()', () => { - let delegateApiMock; - let accountDelegtatesDeferred; - let delegate41; - let delegate42; - - beforeEach(() => { - accountDelegtatesDeferred = $q.defer(); - delegateApiMock = sinon.mock(controller.delegateApi); - delegateApiMock.expects('listAccountDelegates').returns(accountDelegtatesDeferred.promise); - delegate41 = { username: 'genesis_41', status: {} }; - delegate42 = { username: 'genesis_42', status: {} }; - }); - - afterEach(() => { - delegateApiMock.verify(); - delegateApiMock.restore(); - }); - - it('calls delegateApi.listAccountDelegates and then removes all returned delegates from this.votePendingList', () => { - controller.votePendingList = [delegate41, delegate42]; - controller.unvotePendingList = []; - - controller.checkPendingVotes(); - - $timeout.flush(); - accountDelegtatesDeferred.resolve({ success: true, delegates: [delegate42] }); - $scope.$apply(); - - expect(controller.votePendingList.length).to.equal(1); - expect(controller.votePendingList[0]).to.deep.equal(delegate41); - }); - - it('calls delegateApi.listAccountDelegates and then removes all NOT returned delegates from this.unvotePendingList', () => { - controller.votePendingList = []; - controller.unvotePendingList = [delegate41, delegate42]; - - controller.checkPendingVotes(); - - $timeout.flush(); - accountDelegtatesDeferred.resolve({ success: true, delegates: [delegate42] }); - $scope.$apply(); - - expect(controller.unvotePendingList.length).to.equal(1); - expect(controller.unvotePendingList[0]).to.deep.equal(delegate42); - }); - - it('calls delegateApi.listAccountDelegates and if in the end there are still some votes pending calls itself again', () => { - controller.votePendingList = []; - controller.unvotePendingList = [delegate41, delegate42]; - - controller.checkPendingVotes(); - - $timeout.flush(); - const selfMock = sinon.mock(controller); - selfMock.expects('checkPendingVotes'); - accountDelegtatesDeferred.resolve({ success: true, delegates: [] }); - - $scope.$apply(); - }); - }); -}); diff --git a/test/components/delegates/vote.spec.js b/test/components/delegates/vote.spec.js deleted file mode 100644 index c518b2c59..000000000 --- a/test/components/delegates/vote.spec.js +++ /dev/null @@ -1,153 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe('Vote component', () => { - let $compile; - let $rootScope; - let element; - let $scope; - let Peers; - let lsk; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_$compile_, _$rootScope_, _Peers_, _lsk_) => { - $compile = _$compile_; - $rootScope = _$rootScope_; - Peers = _Peers_; - lsk = _lsk_; - })); - - beforeEach(() => { - Peers.active = { sendRequest() {} }; - - $scope = $rootScope.$new(); - $scope.passphrase = 'robust swift grocery peasant forget share enable convince deputy road keep cheap'; - $scope.account = { - address: '8273455169423958419L', - balance: lsk.from(100), - }; - $scope.voteList = Array.from({ length: 10 }, (v, k) => ({ - username: `genesis_${k}`, - })); - $scope.unvoteList = Array.from({ length: 3 }, (v, k) => ({ - username: `genesis_${k}`, - })); - element = $compile('')($scope); - $scope.$digest(); - }); - - const DIALOG_TITLE = 'Vote for delegates'; - it(`should contain a title saying "${DIALOG_TITLE}"`, () => { - expect(element.find('h2').text()).to.equal(DIALOG_TITLE); - }); -}); - -describe('Vote component controller', () => { - beforeEach(angular.mock.module('app')); - - let $rootScope; - let $scope; - let controller; - let $componentController; - let delegateApiMock; - let delegateApi; - let $q; - let accountDelegtatesDeferred; - - beforeEach(inject((_$componentController_, _$rootScope_, _delegateApi_, _$q_) => { - $componentController = _$componentController_; - $rootScope = _$rootScope_; - delegateApi = _delegateApi_; - $q = _$q_; - })); - - beforeEach(() => { - accountDelegtatesDeferred = $q.defer(); - delegateApiMock = sinon.mock(delegateApi); - delegateApiMock.expects('listAccountDelegates').returns(accountDelegtatesDeferred.promise); - - $scope = $rootScope.$new(); - controller = $componentController('vote', $scope, { - account: { - address: '8273455169423958419L', - balance: '10000', - }, - }); - controller.voteList = Array.from({ length: 10 }, (v, k) => ({ - username: `genesis_${k}`, - status: { - selected: true, - voted: false, - changed: true, - }, - })); - controller.unvoteList = Array.from({ length: 3 }, (v, k) => ({ - username: `genesis_${k}`, - status: { - selected: true, - voted: true, - changed: true, - }, - })); - }); - - describe('constructor()', () => { - it('calls delegateApi.listAccountDelegates and then sets result to this.votedList', () => { - const delegates = [{ username: 'genesis_42' }]; - accountDelegtatesDeferred.resolve({ success: true, delegates }); - $scope.$apply(); - expect(controller.votedList).to.deep.equal(delegates); - }); - - it('calls delegateApi.listAccountDelegates and if result.delegates is not defined then sets [] to this.votedList', () => { - const delegates = undefined; - accountDelegtatesDeferred.resolve({ success: true, delegates }); - $scope.$apply(); - expect(controller.votedList).to.deep.equal([]); - }); - - it('calls delegateApi.listAccountDelegates and then sets result to this.votedDict', () => { - const delegates = [{ username: 'genesis_42' }]; - accountDelegtatesDeferred.resolve({ success: true, delegates }); - $scope.$apply(); - expect(controller.votedDict[delegates[0].username]).to.deep.equal(delegates[0]); - }); - }); - - describe('vote()', () => { - let deffered; - let dilaogServiceMock; - - beforeEach(() => { - deffered = $q.defer(); - delegateApiMock.expects('vote').returns(deffered.promise); - dilaogServiceMock = sinon.mock(controller.dialog); - }); - - afterEach(() => { - dilaogServiceMock.verify(); - delegateApiMock.verify(); - }); - - it('shows an error toast if request fails', () => { - dilaogServiceMock.expects('errorToast'); - controller.vote(); - deffered.reject({ success: false }); - $scope.$apply(); - }); - - it('shows a success alert if request succeeds', () => { - dilaogServiceMock.expects('successAlert'); - controller.vote(); - deffered.resolve({ success: true }); - $scope.$apply(); - }); - }); -}); - diff --git a/test/components/forging/forging.spec.js b/test/components/forging/forging.spec.js deleted file mode 100644 index 3ad84db93..000000000 --- a/test/components/forging/forging.spec.js +++ /dev/null @@ -1,301 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); -const moment = require('moment'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe('Forging component', () => { - let $compile; - let $rootScope; - let element; - let $scope; - let lsk; - let delegate; - let account; - let forgingApiMock; - let $q; - let peers; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_$compile_, _$rootScope_, _lsk_, _Account_, _$q_, _Peers_) => { - $compile = _$compile_; - $rootScope = _$rootScope_; - lsk = _lsk_; - account = _Account_; - $q = _$q_; - peers = _Peers_; - })); - - beforeEach(() => { - delegate = { - passphrase: 'recipe bomb asset salon coil symbol tiger engine assist pact pumpkin visit', - address: '537318935439898807L', - approval: 90, - missedblocks: 10, - producedblocks: 304, - productivity: 96.82, - publicKey: '3ff32442bb6da7d60c1b7752b24e6467813c9b698e0f278d48c43580da972135', - rate: 20, - username: 'genesis_42', - vote: '9999982470000000', - }; - - const network = { - address: 'http://localhost:4000', - custom: true, - name: 'Custom Node', - nethash: '198f2b61a8eb95fbeed58b8216780b68f697f26b849acf00c8c93bb9b24f783d', - node: 'localhost', - port: '4000', - ssl: false, - testnet: true, - }; - const testAcount = { - passphrase: delegate.passphrase, - balance: lsk.from(100), - network, - delegate, - isDelegate: true, - }; - - account.set(testAcount); - peers.setActive(network); - - $scope = $rootScope.$new(); - element = $compile('')($scope); - - const controller = element.controller('forging'); - forgingApiMock = sinon.mock(controller.forgingApi); - - let deferred = $q.defer(); - deferred = $q.defer(); - forgingApiMock.expects('getForgedBlocks').returns(deferred.promise); - deferred.resolve({ - success: true, - blocks: [], - }); - - deferred = $q.defer(); - forgingApiMock.expects('getForgedStats').returns(deferred.promise).exactly(5); - deferred.resolve({ }); - - controller.$scope.$emit('accountChange', testAcount); - $scope.$digest(); - }); - - afterEach(() => { - forgingApiMock.verify(); - forgingApiMock.restore(); - }); - - it('should contain a card with delegate name', () => { - expect(element.find('.delegate-name').text()).to.contain(delegate.username); - }); - - it('should contain a card with rank ', () => { - expect(element.find('md-card').text()).to.contain(`Rank${delegate.rate}`); - }); - - it('should contain a card with productivity ', () => { - expect(element.find('md-card').text()).to.contain(`Productivity${delegate.productivity}%`); - }); - - it('should contain a card with approval ', () => { - expect(element.find('md-card').text()).to.contain(`Approval${delegate.approval}%`); - }); - - const FORGED_BLOCKS_TITLE = 'Forged Blocks'; - it(`should contain a card with title ${FORGED_BLOCKS_TITLE}`, () => { - expect(element.find('md-card.forged-blocks .md-title').text()).to.equal(FORGED_BLOCKS_TITLE); - }); -}); - -describe('forging component controller', () => { - beforeEach(angular.mock.module('app')); - - let $rootScope; - let $scope; - let controller; - let $componentController; - let forgingApiMock; - let forgingApi; - let delegate; - let blocks; - let account; - let $q; - let peers; - - beforeEach(inject((_$componentController_, _$rootScope_, - _forgingApi_, _Account_, _$q_, _Peers_) => { - $componentController = _$componentController_; - $rootScope = _$rootScope_; - forgingApi = _forgingApi_; - account = _Account_; - $q = _$q_; - peers = _Peers_; - })); - - beforeEach(() => { - blocks = Array.from({ length: 10 }, (v, k) => ({ - id: 10 - k, - timestamp: 10 - k, - })); - - delegate = { - passphrase: 'recipe bomb asset salon coil symbol tiger engine assist pact pumpkin visit', - address: '537318935439898807L', - approval: 90, - missedblocks: 10, - producedblocks: 304, - productivity: 96.82, - publicKey: '86499879448d1b0215d59cbf078836e3d7d9d2782d56a2274a568761bff36f19', - rate: 20, - username: 'genesis_42', - vote: '9999982470000000', - }; - - forgingApiMock = sinon.mock(forgingApi); - - $scope = $rootScope.$new(); - account.set({ - passphrase: delegate.passphrase, - balance: '10000', - }); - peers.setActive({ nane: 'mainnet' }); - controller = $componentController('forging', $scope, { }); - }); - - afterEach(() => { - forgingApiMock.verify(); - forgingApiMock.restore(); - }); - - describe('updateForgedBlocks(limit, offset)', () => { - let deferred; - - beforeEach(() => { - deferred = $q.defer(); - forgingApiMock.expects('getForgedBlocks').returns(deferred.promise); - }); - - it('does nothing if request fails', () => { - controller.updateForgedBlocks(10); - deferred.reject(); - $scope.$apply(); - expect(controller.blocks).to.deep.equal([]); - }); - - it('updates this.blocks with what was returned', () => { - controller.updateForgedBlocks(10); - deferred.resolve({ - success: true, - blocks, - }); - $scope.$apply(); - expect(controller.blocks.length).to.equal(10); - expect(controller.blocks).to.deep.equal(blocks); - }); - - it('appends returned blocks to this.blocks if offset is set', () => { - const extraBlocks = Array.from({ length: 20 }, (v, k) => ({ - id: 0 - k, - timestamp: 0 - k, - })); - - controller.blocks = blocks; - controller.updateForgedBlocks(20, 10); - deferred.resolve({ - success: true, - blocks: extraBlocks, - }); - $scope.$apply(); - expect(controller.blocks.length).to.equal(30); - expect(controller.blocks).to.deep.equal(blocks); - }); - - it('does not change this.blocks when returned blocks values are unchanged', () => { - controller.blocks = blocks; - controller.updateForgedBlocks(10); - deferred.resolve({ - success: true, - blocks, - }); - $scope.$apply(); - expect(controller.blocks).to.deep.equal(blocks); - }); - - it('prepends to this.blocks when returned blocks contains a new value', () => { - const newBlock = { id: 11, timestamp: 11 }; - controller.blocks = blocks; - controller.updateForgedBlocks(10); - deferred.resolve({ - success: true, - blocks: [newBlock].concat(blocks), - }); - $scope.$apply(); - expect(controller.blocks.length).to.equal(11); - expect(controller.blocks[0]).to.deep.equal(newBlock); - }); - }); - - describe('loadMoreBlocks()', () => { - it('fetches and appends 20 more blocks to this.blocks', () => { - const extraBlocks = Array.from({ length: 20 }, (v, k) => ({ - id: 0 - k, - timestamp: 0 - k, - })); - const deferred = $q.defer(); - forgingApiMock.expects('getForgedBlocks').returns(deferred.promise); - controller.blocks = blocks; - controller.blocksLoaded = true; - controller.moreBlocksExist = true; - - controller.loadMoreBlocks(); - deferred.resolve({ - success: true, - blocks: extraBlocks, - }); - $scope.$apply(); - expect(controller.blocks.length).to.equal(30); - expect(controller.blocks).to.deep.equal(blocks); - }); - }); - - describe('updateForgingStats(key, startMoment)', () => { - let deferred; - - beforeEach(() => { - deferred = $q.defer(); - forgingApiMock.expects('getForgedStats').returns(deferred.promise); - }); - - it('fetches forged by account since startMoment and sets it to this.statistics[key]', () => { - const forged = 42; - const key = 'testStat'; - const startMoment = moment().subtract(1, 'days'); - - expect(controller.statistics[key]).to.equal(undefined); - controller.updateForgingStats(key, startMoment); - deferred.resolve({ - success: true, - forged, - }); - $scope.$apply(); - expect(controller.statistics[key]).to.equal(forged); - }); - - it('does nothing after failing to fetch forged by account since startMoment', () => { - const key = 'testStat'; - const startMoment = moment().subtract(1, 'days'); - - expect(controller.statistics[key]).to.equal(undefined); - controller.updateForgingStats(key, startMoment); - deferred.reject(); - $scope.$apply(); - expect(controller.statistics[key]).to.equal(undefined); - }); - }); -}); diff --git a/test/components/header/header.spec.js b/test/components/header/header.spec.js deleted file mode 100644 index 5170f0c53..000000000 --- a/test/components/header/header.spec.js +++ /dev/null @@ -1,40 +0,0 @@ -const chai = require('chai'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe('Header component', () => { - let $compile; - let $rootScope; - let element; - let $scope; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_$compile_, _$rootScope_) => { - $compile = _$compile_; - $rootScope = _$rootScope_; - })); - - beforeEach(() => { - $scope = $rootScope.$new(); - - element = $compile('
')($scope); - $rootScope.logged = true; - $scope.$digest(); - }); - - const TRANSFER_BUTTON_TEXT = 'Send'; - it(`should contain "${TRANSFER_BUTTON_TEXT}" button if $root.logged`, () => { - $rootScope.logged = true; - $scope.$digest(); - expect(element.find('button.md-primary.send-button').text()).to.equal(TRANSFER_BUTTON_TEXT); - }); - - const LOGOUT_BUTTON_TEXT = 'Logout'; - it(`should contain "${LOGOUT_BUTTON_TEXT}" button if $root.logged`, () => { - expect(element.find('button.logout-button').text()).to.equal(LOGOUT_BUTTON_TEXT); - }); -}); - diff --git a/test/components/login/login.spec.js b/test/components/login/login.spec.js deleted file mode 100644 index 2f5c4a9fa..000000000 --- a/test/components/login/login.spec.js +++ /dev/null @@ -1,145 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); -const VALID_PASSPHRASE = 'illegal symbol search tree deposit youth mixture craft amazing tool soon unit'; -const INVALID_PASSPHRASE = 'INVALID_PASSPHRASE'; - -describe('Login component', () => { - let $compile; - let $rootScope; - let element; - - // Load the myApp module, which contains the directive - beforeEach(angular.mock.module('app')); - - // Store references to $rootScope and $compile - // so they are available to all tests in this describe block - beforeEach(inject((_$compile_, _$rootScope_) => { - // The injector unwraps the underscores (_) from around the parameter names when matching - $compile = _$compile_; - $rootScope = _$rootScope_; - })); - - beforeEach(() => { - // Compile a piece of HTML containing the directive - element = $compile('')($rootScope); - $rootScope.$digest(); - }); - - const PASS_LABEL_TEXT = 'Enter your passphrase'; - it(`should contain a form input with label saying "${PASS_LABEL_TEXT}"`, () => { - expect(element.find('form md-input-container label.pass').text()).to.equal(PASS_LABEL_TEXT); - }); - - const SELECT_LABEL_TEXT = 'Network'; - it(`should contain a select element with label saying "${SELECT_LABEL_TEXT}"`, () => { - expect(element.find('form md-input-container label.select').text()).to.equal(SELECT_LABEL_TEXT); - }); - - it('should contain an input field', () => { - expect(element.find('form input').html()).to.equal(''); - }); - - const LOGIN_BUTTON_TEXT = 'Login'; - it(`should contain a button saying "${LOGIN_BUTTON_TEXT}"`, () => { - expect(element.find('.md-raised').text()).to.equal(LOGIN_BUTTON_TEXT); - }); -}); - -describe('Login controller', () => { - beforeEach(angular.mock.module('app')); - - let $rootScope; - let $scope; - let $state; - let controller; - let $componentController; - let Passphrase; - let testPassphrase; - let account; - let $cookies; - /* eslint-enable no-unused-vars */ - let $q; - - beforeEach(inject((_$componentController_, _$rootScope_, _$state_, - _Passphrase_, _$cookies_, _Account_, _$q_) => { - $componentController = _$componentController_; - $rootScope = _$rootScope_; - $state = _$state_; - Passphrase = _Passphrase_; - account = _Account_; - $cookies = _$cookies_; - /* eslint-enable no-unused-vars */ - $q = _$q_; - })); - - beforeEach(() => { - testPassphrase = 'glow two glimpse camp aware tip brief confirm similar code float defense'; - $scope = $rootScope.$new(); - controller = $componentController('login', $scope, { }); - controller.onLogin = () => {}; - controller.passphrase = ''; - }); - - describe('controller()', () => { - it('should define a watcher for $ctrl.input_passphrase', () => { - $scope.$apply(); - const spy = sinon.spy(Passphrase, 'isValidPassphrase'); - controller.input_passphrase = INVALID_PASSPHRASE; - $scope.$apply(); - expect(controller.valid).to.not.equal(1); - controller.input_passphrase = VALID_PASSPHRASE; - $scope.$apply(); - expect(controller.validity.passphrase).to.equal(1); - expect(spy).to.have.been.calledWith(); - }); - }); - - describe('passConfirmSubmit()', () => { - let peersMock; - let deferred; - - beforeEach(() => { - deferred = $q.defer(); - peersMock = sinon.mock(controller.peers); - peersMock.expects('setActive').returns(deferred.promise); - controller.peers.online = true; - }); - - it('sets account.phassphrase as this.input_passphrase processed by normalizer', () => { - controller.input_passphrase = '\tTEST PassPHrASe '; - controller.passConfirmSubmit(); - deferred.resolve(); - $scope.$apply(); - expect(account.get().passphrase).to.equal('test passphrase'); - }); - - it('calls Passphrase.normalize()', () => { - const spy = sinon.spy(Passphrase, 'normalize'); - controller.passConfirmSubmit(); - deferred.resolve(); - $scope.$apply(); - expect(spy).to.have.been.calledWith(); - }); - - it('redirects to main if passphrase is valid', () => { - controller.input_passphrase = testPassphrase; - const spy = sinon.spy($state, 'go'); - controller.passConfirmSubmit(); - deferred.resolve(); - $scope.$apply(); - expect(spy).to.have.been.calledWith(); - }); - }); - - describe('devTestAccount()', () => { - it('sets the passphrase into passphrase input if it is set in the cookies', () => { - $cookies.put('passphrase', testPassphrase); - controller.devTestAccount(); - expect(controller.input_passphrase).to.equal(testPassphrase); - }); - }); -}); diff --git a/test/components/login/newAccount.spec.js b/test/components/login/newAccount.spec.js deleted file mode 100644 index 21ed9dcb6..000000000 --- a/test/components/login/newAccount.spec.js +++ /dev/null @@ -1,103 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); -const VALID_PASSPHRASE = 'illegal symbol search tree deposit youth mixture craft amazing tool soon unit'; - -describe('newAccount component', () => { - let $compile; - let $rootScope; - let $scope; - let element; - - // Load the myApp module, which contains the directive - beforeEach(angular.mock.module('app')); - - // Store references to $rootScope and $compile - // so they are available to all tests in this describe block - beforeEach(inject((_$compile_, _$rootScope_) => { - // The injector unwraps the underscores (_) from around the parameter names when matching - $compile = _$compile_; - $rootScope = _$rootScope_; - })); - - beforeEach(() => { - $scope = $rootScope.$new(); - $scope.network = { - name: 'Mainnet', - }; - // Compile a piece of HTML containing the directive - element = $compile('')($scope); - $scope.$digest(); - }); - - const MODAL_TITLE_TEXT = 'New Account'; - it(`should contain a heading saying "${MODAL_TITLE_TEXT}"`, () => { - expect(element.find('.dialog-primary md-toolbar h2').text()).to.equal(MODAL_TITLE_TEXT); - }); - - const NEXT_BUTTON_TEXT = 'Next'; - it(`should contain a button titled "${NEXT_BUTTON_TEXT}"`, () => { - expect(element.find('.next-button span.ng-scope').text()).to.equal(NEXT_BUTTON_TEXT); - }); -}); - -describe('newAccount controller', () => { - beforeEach(angular.mock.module('app')); - - let $rootScope; - let $scope; - let $state; - let $componentController; - /* eslint-enable no-unused-vars */ - let $q; - let peers; - - beforeEach(inject((_$componentController_, _$rootScope_, _$state_, _$q_, _Peers_) => { - $componentController = _$componentController_; - $rootScope = _$rootScope_; - /* eslint-enable no-unused-vars */ - $q = _$q_; - peers = _Peers_; - $state = _$state_; - })); - - beforeEach(() => { - const scope = $rootScope.$new(); - $componentController('newAccount', scope, { - network: { name: 'Mainnet' }, - }); - scope.$digest(); - $scope = scope.$scope; - }); - - describe('passConfirmSubmit()', () => { - let peersMock; - let deferred; - - beforeEach(() => { - deferred = $q.defer(); - peersMock = sinon.mock(peers); - peersMock.expects('setActive').returns(deferred.promise); - peers.online = true; - }); - - it('redirects to main if passphrase is valid', () => { - const spy = sinon.spy($state, 'go'); - $scope.passConfirmSubmit(VALID_PASSPHRASE); - deferred.resolve(); - $scope.$apply(); - expect(spy).to.have.been.calledWith(); - }); - }); - - describe('onSave()', () => { - it('calls the passConfirmSubmit with the generated passphrase', () => { - const spy = sinon.spy($scope, 'passConfirmSubmit'); - $scope.onSave(VALID_PASSPHRASE); - expect(spy).to.have.been.calledWith(VALID_PASSPHRASE); - }); - }); -}); diff --git a/test/components/main/main.spec.js b/test/components/main/main.spec.js deleted file mode 100644 index a085cd0b9..000000000 --- a/test/components/main/main.spec.js +++ /dev/null @@ -1,155 +0,0 @@ -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); -const chai = require('chai'); - -const expect = chai.expect; - -const delegateAccount = { - passphrase: 'recipe bomb asset salon coil symbol tiger engine assist pact pumpkin visit', - address: '537318935439898807L', -}; - -chai.use(sinonChai); - -describe('main component controller', () => { - beforeEach(angular.mock.module('app')); - - let $rootScope; - let $scope; - let $q; - let $componentController; - let controller; - let account; - let peers; - let accountApi; - let delegateApi; - - beforeEach(inject((_$componentController_, _$rootScope_, _Peers_, - _$q_, _Account_, _AccountApi_, _delegateApi_) => { - $componentController = _$componentController_; - $rootScope = _$rootScope_; - $q = _$q_; - account = _Account_; - accountApi = _AccountApi_; - delegateApi = _delegateApi_; - peers = _Peers_; - })); - - beforeEach(() => { - $scope = $rootScope.$new(); - account.set({ passphrase: delegateAccount.passphrase }); - peers.setActive({ - name: 'Mainnet', - }); - controller = $componentController('main', $scope, {}); - }); - - describe('init()', () => { - let deffered; - let updateMock; - let peersMock; - - beforeEach(() => { - deffered = $q.defer(); - updateMock = sinon.mock(controller); - updateMock.expects('update').withArgs().returns(deffered.promise); - - peersMock = sinon.mock(controller.peers); - peersMock.expects('setActive').withArgs(); - }); - - afterEach(() => { - updateMock.verify(); - updateMock.restore(); - }); - - it('sets active peer', () => { - controller.init(); - - deffered.resolve(); - $scope.$apply(); - }); - - it('calls this.update() and then sets this.logged = true', () => { - controller.init(); - deffered.resolve(); - $scope.$apply(); - - expect($rootScope.logged).to.equal(true); - }); - - it('calls this.update() and if that fails and attempts < 10, then sets a timeout to try again', () => { - const spy = sinon.spy(controller, '$timeout'); - - controller.init(); - deffered.reject(); - $scope.$apply(); - - expect(spy).to.have.been.calledWith(); - }); - - it('calls this.update() and if that fails and attempts >= 10, then show error alert dialog', () => { - const spy = sinon.spy(controller.dialog, 'errorAlert'); - - controller.init(10); - deffered.reject(); - $scope.$apply(); - - expect(spy).to.have.been.calledWith({ text: 'No peer connection' }); - }); - }); - - describe('checkIfIsDelegate()', () => { - beforeEach(() => { - account.set({ - balance: '0', - passphrase: 'wagon stock borrow episode laundry kitten salute link globe zero feed marble', - }); - }); - - it.skip('calls /api/delegates/get and sets account.isDelegate according to the response.success', () => { - delegateApi.registerDelegate(); - controller.checkIfIsDelegate(); - expect(account.get().isDelegate).to.equal(true); - }); - }); - - describe('update()', () => { - let deffered; - - beforeEach(() => { - deffered = $q.defer(); - account.set({ - balance: '0', - passphrase: 'wagon stock borrow episode laundry kitten salute link globe zero feed marble', - }); - const mock = sinon.mock(accountApi); - mock.expects('get').returns(deffered.promise); - controller.Peers = { - getStatusPromise() { - return $q.defer().promise; - }, - }; - controller.address = account.get().address; - account.reset(); - }); - - it('calls this.accountApi.get(this.address) and then sets balance', () => { - expect(account.get().balance).to.equal(undefined); - controller.update(); - deffered.resolve({ balance: 12345 }); - $scope.$apply(); - expect(account.get().balance).to.equal(12345); - }); - - it('calls this.accountApi.get(this.address) and if it fails, then resets this.account.balance and reject the promise that update() returns', () => { - const spy = sinon.spy(controller.$q, 'reject'); - controller.update(); - deffered.reject(); - $scope.$apply(); - expect(account.get().balance).to.equal(null); - $rootScope.reset(); - expect(spy).to.have.been.calledWith(); - }); - }); -}); diff --git a/test/components/main/secondPass.spec.js b/test/components/main/secondPass.spec.js deleted file mode 100644 index 7085e5560..000000000 --- a/test/components/main/secondPass.spec.js +++ /dev/null @@ -1,101 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe('setSecondPass component', () => { - let $compile; - let $scope; - let $rootScope; - let accountApi; - let $q; - let dialog; - - beforeEach(() => { - // Load the myApp module, which contains the directive - angular.mock.module('app'); - - // Store references to $rootScope and $compile - // so they are available to all tests in this describe block - inject((_$compile_, _$rootScope_, _AccountApi_, _$q_, _dialog_) => { - // The injector unwraps the underscores (_) from around the parameter names when matching - $compile = _$compile_; - $rootScope = _$rootScope_; - accountApi = _AccountApi_; - $q = _$q_; - dialog = _dialog_; - const scope = $rootScope.$new(); - - $compile('')(scope); - scope.$digest(); - - $scope = scope.$$childTail; - }); - }); - - describe('scope.passConfirmSubmit', () => { - it('should call accountApi.setSecondSecret', () => { - const testPassphrase = 'glow two glimpse camp aware tip brief confirm similar code float defense'; - const mock = sinon.mock(accountApi); - const deffered = $q.defer(); - mock.expects('setSecondSecret').returns(deffered.promise); - - const spy = sinon.spy(dialog, 'successAlert'); - $scope.passConfirmSubmit(testPassphrase); - - deffered.resolve({}); - $scope.$apply(); - - expect(spy).to.have.been.calledWith(); - }); - - it('should show error dialog if trying to set second passphrase multiple times', () => { - const mock = sinon.mock(accountApi); - const deffered = $q.defer(); - mock.expects('setSecondSecret').returns(deffered.promise); - - const spy = sinon.spy(dialog, 'errorAlert'); - $scope.passConfirmSubmit(); - - deffered.reject({ message: 'Missing sender second signature' }); - $scope.$apply(); - expect(spy).to.have.been.calledWith(); - - deffered.reject({ message: 'Account does not have enough LSK : TEST_ADDRESS' }); - $scope.$apply(); - expect(spy).to.have.been.calledWith(); - - deffered.reject({ message: 'OTHER MESSAGE' }); - $scope.$apply(); - expect(spy).to.have.been.calledWith(); - }); - - it('should show error dialog if account does not have enough LSK', () => { - const mock = sinon.mock(accountApi); - const deffered = $q.defer(); - mock.expects('setSecondSecret').returns(deffered.promise); - - const spy = sinon.spy(dialog, 'errorAlert'); - $scope.passConfirmSubmit(); - - deffered.reject({ message: 'Missing sender second signature' }); - $scope.$apply(); - expect(spy).to.have.been.calledWith(); - }); - - it('should show error dialog for all the other errors', () => { - const mock = sinon.mock(accountApi); - const deffered = $q.defer(); - mock.expects('setSecondSecret').returns(deffered.promise); - - const spy = sinon.spy(dialog, 'errorAlert'); - $scope.passConfirmSubmit(); - - deffered.reject({ message: 'Other messages' }); - $scope.$apply(); - expect(spy).to.have.been.calledWith(); - }); - }); -}); diff --git a/test/components/openDialog/openDialog.spec.js b/test/components/openDialog/openDialog.spec.js deleted file mode 100644 index 87a4b9257..000000000 --- a/test/components/openDialog/openDialog.spec.js +++ /dev/null @@ -1,35 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe('Open dialog directive', () => { - let $scope; - let dialog; - let compiled; - const template = '
'; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject(($compile, $rootScope, _dialog_) => { - $scope = $rootScope.$new(); - dialog = _dialog_; - compiled = $compile(template)($scope); - - $scope.$digest(); - })); - - it('should render directive', () => { - const el = compiled.find('button'); - expect(el.length).to.equal(1); - }); - - it('should run dialog.modal() when clicked', () => { - const el = compiled.find('button'); - const spy = sinon.spy(dialog, 'modal'); - el.triggerHandler('click'); - expect(spy).to.have.been.calledWith(); - }); -}); diff --git a/test/components/passphrase/passphrase.spec.js b/test/components/passphrase/passphrase.spec.js deleted file mode 100644 index e33fb656f..000000000 --- a/test/components/passphrase/passphrase.spec.js +++ /dev/null @@ -1,61 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe('Passphrase Directive', () => { - let $compile; - let $rootScope; - let $document; - let Passphrase; - let $isolateScope; - - beforeEach(() => { - // Load the myApp module, which contains the directive - angular.mock.module('app'); - - // Store references to $rootScope and $compile - // so they are available to all tests in this describe block - inject((_$compile_, _$rootScope_, _$document_, _Passphrase_) => { - $compile = _$compile_; - $rootScope = _$rootScope_; - $document = _$document_; - Passphrase = _Passphrase_; - }); - - // Compile a piece of HTML containing the directive - const element = angular.element(''); - const e = $compile(element)($rootScope); - e.scope().$digest(); - $isolateScope = e.isolateScope(); - }); - - describe('PassphraseLink', () => { - it('should assign progress to its own $scope', () => { - expect($isolateScope.progress).to.not.equal(undefined); - expect($isolateScope.progress).to.equal(Passphrase.progress); - }); - }); - - describe('$scope.simulateMousemove()', () => { - it('calls $document.mousemove()', () => { - const spy = sinon.spy($document, 'mousemove'); - $isolateScope.simulateMousemove(); - expect(spy).to.have.been.calledWith(); - }); - }); - - describe('$scope.mobileAndTabletcheck()', () => { - it('checks if the useAgent is a device', () => { - const agents = [ - 'Mozilla/5.0 (iPhone; CPU iPhone OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25', - 'Mozilla/5.0 (iPad; CPU OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25', - ]; - let isDevice = true; - agents.forEach(agent => (isDevice = isDevice && $isolateScope.mobileAndTabletcheck(agent))); - expect(isDevice).to.equal(true); - }); - }); -}); diff --git a/test/components/passphrase/savePassphrase.spec.js b/test/components/passphrase/savePassphrase.spec.js deleted file mode 100644 index 81afdf88a..000000000 --- a/test/components/passphrase/savePassphrase.spec.js +++ /dev/null @@ -1,79 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); -const PASSPHRASE = 'illegal symbol search tree deposit youth mixture craft amazing tool soon unit'; - -describe('Save passphrase component', () => { - let $compile; - let $rootScope; - let element; - let $scope; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_$compile_, _$rootScope_) => { - $compile = _$compile_; - $rootScope = _$rootScope_; - })); - - beforeEach(() => { - $scope = $rootScope.$new(); - $scope.passphrase = PASSPHRASE; - $scope.label = 'Save'; - $scope.onSave = () => {}; - element = $compile('')($scope); - $scope.$digest(); - }); - - it('should contain an input field with the passphrase', () => { - expect(element.find('textarea').val()).to.equal(PASSPHRASE); - }); - - it('should ask for a missing word when "yes-its-save-button" clicked', () => { - element.find('.yes-its-save-button').click(); - expect(element.find('label').text()).to.equal('Enter the missing word'); - }); - - describe('Save passphrase component controller', () => { - let controller; - let $componentController; - let dialogMock; - - beforeEach(inject((_$componentController_) => { - $componentController = _$componentController_; - })); - - beforeEach(() => { - $scope = $rootScope.$new(); - $scope.passphrase = PASSPHRASE; - controller = $componentController('savePassphrase', $scope, { - onSave: () => {}, - label: 'Save', - }); - dialogMock = sinon.mock(controller.$mdDialog); - }); - - afterEach(() => { - dialogMock.verify(); - dialogMock.restore(); - }); - - describe('ok()', () => { - it('calls $mdDialog.hide', () => { - dialogMock.expects('hide'); - controller.ok(); - }); - }); - - describe('close()', () => { - it('calls $mdDialog.cancel', () => { - dialogMock.expects('cancel'); - controller.close(); - }); - }); - }); -}); - diff --git a/test/components/send/send.spec.js b/test/components/send/send.spec.js deleted file mode 100644 index fbe1d453b..000000000 --- a/test/components/send/send.spec.js +++ /dev/null @@ -1,191 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe.skip('Send component', () => { - let $compile; - let $rootScope; - let element; - let $scope; - let lsk; - let account; - let accountApi; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_$compile_, _$rootScope_, _lsk_, _Account_, _AccountApi_) => { - $compile = _$compile_; - $rootScope = _$rootScope_; - lsk = _lsk_; - account = _Account_; - accountApi = _AccountApi_; - })); - - beforeEach(() => { - $scope = $rootScope.$new(); - account.set({ - passphrase: 'robust swift grocery peasant forget share enable convince deputy road keep cheap', - balance: lsk.from(10535.77379498), - }); - - element = $compile('')($scope); - $scope.$digest(); - }); - - const HEADER_TEXT = 'Send'; - it(`should contain header saying "${HEADER_TEXT}"`, () => { - expect(element.find('.md-title').text()).to.equal(HEADER_TEXT); - }); - - const RECIPIENT_LABEL_TEXT = 'Recipient Address'; - it(`should contain a form with label saying "${RECIPIENT_LABEL_TEXT}"`, () => { - expect(element.find('form label:first').text()).to.equal(RECIPIENT_LABEL_TEXT); - }); - - const AMOUT_LABEL_TEXT = 'Transaction Amount'; - it(`should contain a form with label saying "${AMOUT_LABEL_TEXT}"`, () => { - expect(element.find('form label:last').text()).to.equal(AMOUT_LABEL_TEXT); - }); - - const TRANSFER_BUTTON_TEXT = 'Send'; - it(`should contain a button saying "${TRANSFER_BUTTON_TEXT}"`, () => { - expect(element.find('button.md-raised.md-primary').text()).to.equal(TRANSFER_BUTTON_TEXT); - }); - - const CANCEL_BUTTON_TEXT = 'Cancel'; - it(`should contain a button saying "${CANCEL_BUTTON_TEXT}"`, () => { - expect(element.find('button.md-raised.md-secondary').text()).to.equal(CANCEL_BUTTON_TEXT); - }); - - describe('create transaction', () => { - let dialog; - let $q; - - beforeEach(inject((_dialog_, _$q_) => { - dialog = _dialog_; - $q = _$q_; - })); - - it('should allow to create a transaction', () => { - const RECIPIENT_ADDRESS = '5932438298200837883L'; - const AMOUNT = '10'; - - const mock = sinon.mock(account); - const deffered = $q.defer(); - mock.expects('send').returns(deffered.promise); - - const spy = sinon.spy(dialog, 'successAlert'); - - element.find('form input[name="amount"]').val(AMOUNT).trigger('input'); - element.find('form input[name="recipient"]').val(RECIPIENT_ADDRESS).trigger('input'); - $scope.$apply(); - element.find('button.md-raised.md-primary').click(); - - deffered.resolve({}); - $scope.$apply(); - expect(spy).to.have.been.calledWith({ text: `${AMOUNT} sent to ${RECIPIENT_ADDRESS}` }); - mock.verify(); - }); - - it('should allow to send all funds', () => { - const RECIPIENT_ADDRESS = '5932438298200837883L'; - const AMOUNT = lsk.normalize(account.get().balance - 10000000); - - const mock = sinon.mock(accountApi); - const deffered = $q.defer(); - mock.expects('transactions.create').returns(deffered.promise); - - const spy = sinon.spy(dialog, 'successAlert'); - - element.find('md-menu-item button').click(); - element.find('form input[name="recipient"]').val(RECIPIENT_ADDRESS).trigger('input'); - $scope.$apply(); - expect(element.find('form input[name="amount"]').val()).to.equal(`${AMOUNT}`); - element.find('button.md-raised').click(); - - deffered.resolve({}); - $scope.$apply(); - expect(spy).to.have.been.calledWith(); - expect(spy).to.have.been.calledWith({ text: `${AMOUNT} LSK was successfully transferred to ${RECIPIENT_ADDRESS}` }); - mock.verify(); - }); - }); -}); - -describe('Send component controller', () => { - beforeEach(angular.mock.module('app')); - - let $rootScope; - let $scope; - let $q; - let controller; - let $componentController; - let account; - - beforeEach(inject((_$componentController_, _$rootScope_, _$q_, _Account_) => { - $componentController = _$componentController_; - $rootScope = _$rootScope_; - $q = _$q_; - account = _Account_; - })); - - beforeEach(() => { - $scope = $rootScope.$new(); - controller = $componentController('send', $scope, {}); - account.set({ - balance: '10000', - passphrase: 'robust swift grocery peasant forget share enable convince deputy road keep cheap', - }); - }); - - describe('reset()', () => { - it('resets this.recipient.value and this.amount.value', () => { - controller.recipient.value = 'TEST'; - controller.amount.value = '1000'; - controller.transferForm = { $setUntouched: () => {} }; - const mock = sinon.mock(controller.transferForm); - mock.expects('$setUntouched'); - - controller.reset(); - - expect(controller.recipient.value).to.equal(''); - expect(controller.amount.value).to.equal(''); - }); - }); - - describe('send()', () => { - it('calls accountApi.transactions.create and success.dialog on success', () => { - const mock = sinon.mock(controller.accountApi.transactions); - const deffered = $q.defer(); - mock.expects('create').returns(deffered.promise); - controller.send(); - - const spy = sinon.spy(controller.dialog, 'successAlert'); - deffered.resolve({}); - $scope.$apply(); - expect(spy).to.have.been.calledWith({ - text: `Your transaction of ${controller.amount.value} LSK to ${controller.recipient.value} was accepted and will be processed in a few seconds.`, - }); - }); - - it('calls accountApi.transactions.create and error.dialog on error', () => { - const mock = sinon.mock(controller.accountApi.transactions); - const deffered = $q.defer(); - mock.expects('create').returns(deffered.promise); - controller.send(); - - const spy = sinon.spy(controller.dialog, 'errorAlert'); - const response = { - message: 'error', - }; - deffered.reject(response); - $scope.$apply(); - expect(spy).to.have.been.calledWith({ - text: response.message, - }); - }); - }); -}); diff --git a/test/components/send/sendModalDirective.spec.js b/test/components/send/sendModalDirective.spec.js deleted file mode 100644 index fb0078433..000000000 --- a/test/components/send/sendModalDirective.spec.js +++ /dev/null @@ -1,41 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe('Send modal directive', () => { - let $scope; - let SendModal; - let compiled; - const template = '
'; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject(($compile, $rootScope, _SendModal_) => { - $scope = $rootScope.$new(); - SendModal = _SendModal_; - compiled = $compile(template)($scope); - - $scope.$digest(); - })); - - afterEach(() => { - if (typeof SendModal.show.restore === 'function') { - SendModal.show.restore(); - } - }); - - it('should render directive', () => { - const el = compiled.find('button'); - expect(el.length).to.equal(1); - }); - - it('should run SendModal.show() when clicked', () => { - const el = compiled.find('button'); - const spy = sinon.spy(SendModal, 'show'); - el.triggerHandler('click'); - expect(spy).to.have.been.calledWith(); - }); -}); diff --git a/test/components/signVerify/signMessage.spec.js b/test/components/signVerify/signMessage.spec.js deleted file mode 100644 index 6dd8f6fe2..000000000 --- a/test/components/signVerify/signMessage.spec.js +++ /dev/null @@ -1,52 +0,0 @@ -const chai = require('chai'); - -const expect = chai.expect; - -describe('Sign message component', () => { - let $compile; - let $rootScope; - let element; - let $scope; - let account; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_$compile_, _$rootScope_, _Account_) => { - $compile = _$compile_; - $rootScope = _$rootScope_; - account = _Account_; - })); - - beforeEach(() => { - $scope = $rootScope.$new(); - account.set({ - passphrase: 'robust swift grocery peasant forget share enable convince deputy road keep cheap', - }); - element = $compile('')($scope); - $scope.$digest(); - }); - - const DIALOG_TITLE = 'Sign message'; - it(`should contain a title saying "${DIALOG_TITLE}"`, () => { - expect(element.find('h2').text()).to.equal(DIALOG_TITLE); - }); - - it('should output signed message into textarea[name="result"] if there is input in textarea[name="message"]', () => { - const message = 'Hello world'; - const result = - '-----BEGIN LISK SIGNED MESSAGE-----\n' + - '-----MESSAGE-----\n' + - 'Hello world\n' + - '-----PUBLIC KEY-----\n' + - '9d3058175acab969f41ad9b86f7a2926c74258670fe56b37c429c01fca9f2f0f\n' + - '-----SIGNATURE-----\n' + - 'dd01775ec30225b24a74ee2ff9578ed3515371ddf32ba50540dc79a5dab66252081d0a345be3ad5d' + - 'fcb939f018d3dd911d9eacfe8998784879cc37fdfde1200448656c6c6f20776f726c64\n' + - '-----END LISK SIGNED MESSAGE-----'; - const ngModelController = element.find('textarea[name="message"]').controller('ngModel'); - ngModelController.$setViewValue(message); - element.find('.sign-button').click(); - expect(element.find('textarea[name="result"]').val()).to.equal(result); - }); -}); - diff --git a/test/components/signVerify/verifyMessage.spec.js b/test/components/signVerify/verifyMessage.spec.js deleted file mode 100644 index c610bfbf8..000000000 --- a/test/components/signVerify/verifyMessage.spec.js +++ /dev/null @@ -1,59 +0,0 @@ -const chai = require('chai'); - -const expect = chai.expect; - -describe('Verify message component', () => { - let $compile; - let $rootScope; - let element; - let $scope; - const publicKey = '9d3058175acab969f41ad9b86f7a2926c74258670fe56b37c429c01fca9f2f0f'; - const signature = 'dd01775ec30225b24a74ee2ff9578ed3515371ddf32ba50540dc79a5dab66252081d0a345be3ad5d' + - 'fcb939f018d3dd911d9eacfe8998784879cc37fdfde1200448656c6c6f20776f726c64'; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_$compile_, _$rootScope_) => { - $compile = _$compile_; - $rootScope = _$rootScope_; - })); - - beforeEach(() => { - $scope = $rootScope.$new(); - element = $compile('')($scope); - $scope.$digest(); - }); - - const DIALOG_TITLE = 'Verify message'; - it(`should contain a title saying "${DIALOG_TITLE}"`, () => { - expect(element.find('h2').text()).to.equal(DIALOG_TITLE); - }); - - it('should output original message into textarea[name="result"]', () => { - const message = 'Hello world'; - const publicKeyModelController = element.find('input[name="publicKey"]').controller('ngModel'); - publicKeyModelController.$setViewValue(publicKey); - const signaturModelController = element.find('textarea[name="signature"]').controller('ngModel'); - signaturModelController.$setViewValue(signature); - expect(element.find('textarea[name="result"]').val()).to.equal(message); - }); - - it('should display error message "Invalid" if part of publicKey is misisng', () => { - const publicKeyModelController = element.find('input[name="publicKey"]').controller('ngModel'); - publicKeyModelController.$setViewValue(publicKey.substr(0, 10)); - const signaturModelController = element.find('textarea[name="signature"]').controller('ngModel'); - signaturModelController.$setViewValue(signature); - expect(element.find('div[ng-messages="$ctrl.publicKey.error"]').text()).to.equal('Invalid'); - expect(element.find('textarea[name="result"]').val()).to.equal(''); - }); - - it('should display error message "Invalid" if part of signature is misisng', () => { - const signaturModelController = element.find('textarea[name="signature"]').controller('ngModel'); - signaturModelController.$setViewValue(signature.substr(0, 100)); - const publicKeyModelController = element.find('input[name="publicKey"]').controller('ngModel'); - publicKeyModelController.$setViewValue(publicKey); - expect(element.find('div[ng-messages="$ctrl.signature.error"]').text()).to.equal('Invalid'); - expect(element.find('textarea[name="result"]').val()).to.equal(''); - }); -}); - diff --git a/test/components/timestamp/timestamp.spec.js b/test/components/timestamp/timestamp.spec.js deleted file mode 100644 index 576745596..000000000 --- a/test/components/timestamp/timestamp.spec.js +++ /dev/null @@ -1,32 +0,0 @@ -const chai = require('chai'); - -const expect = chai.expect; - -describe('timestamp component', () => { - let $compile; - let $rootScope; - let element; - - // Load the myApp module, which contains the directive - beforeEach(angular.mock.module('app')); - - // Store references to $rootScope and $compile - // so they are available to all tests in this describe block - beforeEach(inject((_$compile_, _$rootScope_) => { - // The injector unwraps the underscores (_) from around the parameter names when matching - $compile = _$compile_; - $rootScope = _$rootScope_; - })); - - beforeEach(() => { - const liskEpoch = Date.UTC(2016, 4, 24, 17, 0, 0, 0); - $rootScope.currentTimestamp = Math.floor((new Date().valueOf() - liskEpoch) / 1000); - - element = $compile('')($rootScope); - $rootScope.$digest(); - }); - - it('should contain a timeago of the date', () => { - expect(element.text()).to.equal('a few seconds'); - }); -}); diff --git a/test/components/top/top.spec.js b/test/components/top/top.spec.js deleted file mode 100644 index b5c6a9779..000000000 --- a/test/components/top/top.spec.js +++ /dev/null @@ -1,28 +0,0 @@ -const chai = require('chai'); - -const expect = chai.expect; - -describe('Top component', () => { - let $compile; - let $rootScope; - - // Load the myApp module, which contains the directive - beforeEach(angular.mock.module('app')); - - // Store references to $rootScope and $compile - // so they are available to all tests in this describe block - beforeEach(inject((_$compile_, _$rootScope_) => { - // The injector unwraps the underscores (_) from around the parameter names when matching - $compile = _$compile_; - $rootScope = _$rootScope_; - })); - - it('should contain address', () => { - // Compile a piece of HTML containing the directive - const element = $compile('')($rootScope); - // Fire all the watches, so the scope expression {{1 + 1}} will be evaluated - $rootScope.$digest(); - // Check that the compiled element contains the templated content - expect(element.html()).to.contain('address'); - }); -}); diff --git a/test/components/transactions/transactions.spec.js b/test/components/transactions/transactions.spec.js deleted file mode 100644 index f35fb9348..000000000 --- a/test/components/transactions/transactions.spec.js +++ /dev/null @@ -1,129 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe('transactions component controller', () => { - beforeEach(angular.mock.module('app')); - - let $rootScope; - let $scope; - let $q; - let controller; - let $componentController; - let account; - let accountApi; - let mock; - - beforeEach(inject((_$componentController_, _$rootScope_, _$q_, _Account_, _AccountApi_) => { - $componentController = _$componentController_; - $rootScope = _$rootScope_; - $q = _$q_; - account = _Account_; - accountApi = _AccountApi_; - })); - - beforeEach(() => { - $scope = $rootScope.$new(); - mock = sinon.mock(accountApi.transactions); - const deffered = $q.defer(); - mock.expects('get').returns(deffered.promise); - controller = $componentController('transactions', $scope, {}); - account.set({ - passphrase: 'robust swift grocery peasant forget share enable convince deputy road keep cheap', - balance: '0', - }); - }); - - afterEach(() => { - mock.verify(); - mock.restore(); - }); - - describe('reset()', () => { - it('sets this.loaded = false', () => { - controller.loaded = true; - controller.reset(); - expect(controller.loaded).to.equal(false); - }); - }); - - describe('showMore()', () => { - it('calls this.update(true, true) if this.moreTransactionsExist', () => { - controller.moreTransactionsExist = true; - mock.expects('get').returns($q.defer().promise); - controller.loaded = true; - controller.showMore(); - expect(controller.loaded).to.equal(false); - }); - - it('does nothing if not this.moreTransactionsExist', () => { - controller.moreTransactionsExist = false; - controller.loaded = undefined; - controller.showMore(); - expect(controller.loaded).to.equal(undefined); - }); - }); - - describe('update(showLoading, showMore)', () => { - let transactionsDeferred; - - beforeEach(() => { - transactionsDeferred = $q.defer(); - }); - - it('sets this.loaded = false if showLoading == true', () => { - mock.expects('get').returns(transactionsDeferred.promise); - controller.loaded = undefined; - controller.update(true); - expect(controller.loaded).to.equal(false); - }); - - it('doesn\'t change this.loaded if showLoading == false', () => { - mock.expects('get').returns(transactionsDeferred.promise); - controller.loaded = undefined; - controller.update(false); - expect(controller.loaded).to.equal(undefined); - }); - - it('calls accountApi.transactions.get(account.get().address, limit) with limit = 20 by default', () => { - mock.expects('get').withArgs(account.get().address, 20).returns(transactionsDeferred.promise); - controller.update(); - transactionsDeferred.reject(); - - $scope.$apply(); - }); - }); - - describe('_processTransactionsResponse(response)', () => { - it('sets this.transactions = response.transactions', () => { - const response = { - transactions: [{}], - count: 1, - }; - controller._processTransactionsResponse(response); // eslint-disable-line - expect(controller.transactions).to.deep.equal(response.transactions); - }); - - it('sets this.moreTransactionsExist to how many more other transactions are there on server', () => { - const response = { - transactions: [{}, {}], - count: 3, - }; - controller._processTransactionsResponse(response); // eslint-disable-line - expect(controller.moreTransactionsExist).to.equal( - response.count - response.transactions.length); - }); - }); - - describe('constructor()', () => { - it('sets $watch on acount to run init()', () => { - mock = sinon.mock(controller); - mock.expects('init').withArgs(); - account.set({ balance: 1000 }); - $scope.$apply(); - }); - }); -}); diff --git a/test/libs.js b/test/libs.js deleted file mode 100644 index 3c965da5d..000000000 --- a/test/libs.js +++ /dev/null @@ -1 +0,0 @@ -require('angular-mocks/angular-mocks'); diff --git a/test/run.spec.js b/test/run.spec.js deleted file mode 100644 index 9c4971319..000000000 --- a/test/run.spec.js +++ /dev/null @@ -1,67 +0,0 @@ -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); -const chai = require('chai'); - -const expect = chai.expect; - -const delegateAccount = { - passphrase: 'recipe bomb asset salon coil symbol tiger engine assist pact pumpkin visit', - address: '537318935439898807L', -}; - -chai.use(sinonChai); - -describe('Application run method', () => { - let $rootScope; - let account; - let peers; - let $timeout; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_$rootScope_, _$timeout_, _Peers_, _Account_) => { - $rootScope = _$rootScope_; - $timeout = _$timeout_; - account = _Account_; - peers = _Peers_; - })); - - beforeEach(() => { - account.set({ passphrase: delegateAccount.passphrase }); - peers.setActive({ - name: 'Mainnet', - }); - }); - - describe('reset()', () => { - it('cancels $rootScope.$timeout', () => { - const spy = sinon.spy($timeout, 'cancel'); - $rootScope.reset(); - expect(spy).to.have.been.calledWith($rootScope.$timeout); - }); - }); - - describe('logout()', () => { - it('resets application', () => { - const spy = sinon.spy($rootScope, 'reset'); - $rootScope.logout(); - expect(spy).to.have.been.calledWith(); - }); - - it('resets peers', () => { - const spy = sinon.spy(peers, 'reset'); - $rootScope.logout(); - expect(spy).to.have.been.calledWith(true); - }); - - it('sets $rootScope.logged = false', () => { - $rootScope.logout(); - expect($rootScope.logged).to.equal(false); - }); - - it('resets account service', () => { - $rootScope.logout(); - expect(account.get()).to.deep.equal({}); - }); - }); -}); diff --git a/test/services/account.spec.js b/test/services/account.spec.js deleted file mode 100644 index dc4bc6797..000000000 --- a/test/services/account.spec.js +++ /dev/null @@ -1,55 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const VALID_PASSPHRASE = 'illegal symbol search tree deposit youth mixture craft amazing tool soon unit'; - -const expect = chai.expect; -chai.use(sinonChai); - -describe('Factory: Account', () => { - let account; - let $rootScope; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_Account_, _$rootScope_) => { - account = _Account_; - $rootScope = _$rootScope_; - })); - - describe('set(config)', () => { - it('returns this.account', () => { - const accountInstanse = account.get(); - - const setReturnValue = account.set({ passphrase: VALID_PASSPHRASE }); - - expect(setReturnValue).to.equal(accountInstanse); - }); - - it('should set address and publicKey for a given valid passphrase', () => { - const accountInstanse = account.set({ passphrase: VALID_PASSPHRASE }); - - expect(accountInstanse.address).to.not.equal(undefined); - expect(accountInstanse.publicKey).to.not.equal(undefined); - }); - - it('should broadcast the changes', () => { - const spy = sinon.spy($rootScope, '$broadcast'); - account.set({ passphrase: VALID_PASSPHRASE }); - expect(spy).to.have.been.calledWith(); - }); - }); - - describe('get(config)', () => { - it('returns this.account', () => { - account.set({ passphrase: VALID_PASSPHRASE }); - const accountInstanse = account.get(); - - expect(accountInstanse).to.not.equal(undefined); - expect(accountInstanse.address).to.not.equal(undefined); - expect(accountInstanse.publicKey).to.not.equal(undefined); - expect(accountInstanse.passphrase).to.not.equal(undefined); - }); - }); -}); diff --git a/test/services/api/accountApi.spec.js b/test/services/api/accountApi.spec.js deleted file mode 100644 index c85fe411f..000000000 --- a/test/services/api/accountApi.spec.js +++ /dev/null @@ -1,85 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe('Factory: AccountApi', () => { - let peers; - let accountApi; - let peersMock; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_Peers_, _AccountApi_) => { - peers = _Peers_; - accountApi = _AccountApi_; - })); - - beforeEach(() => { - peersMock = sinon.mock(peers); - peers.setActive({ - name: 'Mainnet', - }); - }); - - afterEach(() => { - peersMock.verify(); - peersMock.restore(); - }); - - describe('transaction.create(recipientId, amount, secret, secondSecret)', () => { - it('returns Peers.sendRequest(\'transactions\', options);', () => { - const options = { - recipientId: '537318935439898807L', - amount: 10, - timestamp: 10, - secret: 'wagon stock borrow episode laundry kitten salute link globe zero feed marble', - secondSecret: null, - }; - const spy = sinon.spy(peers, 'sendRequestPromise'); - - accountApi.transactions.create( - options.recipientId, options.amount, options.secret, - options.secondSecret, options.timestamp); - - expect(spy).to.have.been.calledWith('transactions', options); - }); - }); - - describe('transaction.get(address, limit, offset, orderBy)', () => { - it('returns Peers.sendRequest(\'transactions\', options);', () => { - const options = { - senderId: '537318935439898807L', - recipientId: '537318935439898807L', - limit: 20, - offset: 0, - orderBy: 'timestamp:desc', - }; - - const spy = sinon.spy(peers, 'sendRequestPromise'); - - accountApi.transactions.get( - options.recipientId, options.limit, options.offset); - - expect(spy).to.have.been.calledWith('transactions', options); - }); - }); - - describe('setSecondSecret(secondSecret, publicKey, secret)', () => { - it('returns Peers.sendRequestPromise(\'signatures\', { secondSecret, publicKey, secret });', () => { - const publicKey = '3ff32442bb6da7d60c1b7752b24e6467813c9b698e0f278d48c43580da972135'; - const secret = 'wagon stock borrow episode laundry kitten salute link globe zero feed marble'; - const secondSecret = 'stay undo beyond powder sand laptop grow gloom apology hamster primary arrive'; - - const spy = sinon.spy(peers, 'sendRequestPromise'); - const timestamp = 10; - - accountApi.setSecondSecret(secondSecret, publicKey, secret, timestamp); - - expect(spy).to.have.been.calledWith('signatures', { secondSecret, publicKey, secret, timestamp }); - }); - }); -}); - diff --git a/test/services/api/delegateApi.spec.js b/test/services/api/delegateApi.spec.js deleted file mode 100644 index 396b6b72b..000000000 --- a/test/services/api/delegateApi.spec.js +++ /dev/null @@ -1,123 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe('Factory: delegateApi', () => { - let Peers; - let $q; - let delegateApi; - let mock; - let deffered; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_Peers_, _$q_, _delegateApi_) => { - Peers = _Peers_; - $q = _$q_; - delegateApi = _delegateApi_; - })); - - beforeEach(() => { - deffered = $q.defer(); - mock = sinon.mock(Peers); - }); - - afterEach(() => { - mock.verify(); - mock.restore(); - }); - - describe('listAccountDelegates(address)', () => { - it('returns Peers.sendRequestPromise(\'accounts/delegates\', address);', () => { - const params = { - address: {}, - }; - mock.expects('sendRequestPromise').withArgs('accounts/delegates', params).returns(deffered.promise); - - const promise = delegateApi.listAccountDelegates(params.address); - - expect(promise).to.equal(deffered.promise); - }); - }); - - describe('listDelegates(options)', () => { - it('returns Peers.sendRequestPromise(\'delegates\', options);', () => { - const options = { - username: 'genesis_42', - }; - mock.expects('sendRequestPromise').withArgs('delegates/', options).returns(deffered.promise); - - const promise = delegateApi.listDelegates(options); - - expect(promise).to.equal(deffered.promise); - }); - }); - - describe('getDelegate(options)', () => { - it('returns Peers.sendRequestPromise(\'delegates/get\', options);', () => { - const options = { - username: 'genesis_42', - }; - mock.expects('sendRequestPromise').withArgs('delegates/get', options).returns(deffered.promise); - - const promise = delegateApi.getDelegate(options); - - expect(promise).to.equal(deffered.promise); - }); - }); - - describe('vote(secret, publicKey, voteList, unvoteList, secondSecret = null)', () => { - it('returns Peers.sendRequestPromise(\'accounts/delegates\', options);', () => { - const secret = ''; - const publicKey = ''; - const secondPassphrase = undefined; - const voteList = [{ - username: 'genesis_42', - }]; - const unvoteList = [{ - username: 'genesis_24', - }]; - mock.expects('sendRequestPromise').withArgs('accounts/delegates').returns(deffered.promise); - - const promise = delegateApi.vote(secret, publicKey, - voteList, unvoteList, secondPassphrase); - - expect(promise).to.equal(deffered.promise); - }); - }); - - describe('voteAutocomplete(username, votedDict)', () => { - it('returns Peers.sendRequestPromise(\'delegates/search\', {q: username}) delegates filtered by not in voteDialog);', () => { - const username = 'genesis_4'; - const votedDict = { - genesis_44: { - }, - }; - const delegates = [ - { username: 'genesis_42' }, - { username: 'genesis_44' }, - ]; - mock.expects('sendRequestPromise').withArgs('delegates/search', { q: username }).returns(deffered.promise); - - delegateApi.voteAutocomplete(username, votedDict); - deffered.resolve({ delegates }); - }); - }); - - describe('unvoteAutocomplete(username, votedList)', () => { - it('returns list of elements e of votedList such that e.username contains username);', () => { - const username = 'genesis_4'; - const votedList = [ - { username: 'genesis_44' }, - { username: 'genesis_24' }, - ]; - - const result = delegateApi.unvoteAutocomplete(username, votedList); - expect(result).to.deep.equal([votedList[0]]); - }); - }); -}); - diff --git a/test/services/api/forgingApi.spec.js b/test/services/api/forgingApi.spec.js deleted file mode 100644 index 8eea16d28..000000000 --- a/test/services/api/forgingApi.spec.js +++ /dev/null @@ -1,63 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe('Factory: forgingApi', () => { - let Peers; - let $q; - let forgingApi; - let mock; - let deffered; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_Peers_, _$q_, _forgingApi_) => { - Peers = _Peers_; - $q = _$q_; - forgingApi = _forgingApi_; - })); - - beforeEach(() => { - deffered = $q.defer(); - mock = sinon.mock(Peers); - }); - - afterEach(() => { - mock.verify(); - mock.restore(); - }); - - describe('getDelegate()', () => { - it('returns Peers.sendRequestPromise(\'delegates/get\');', () => { - mock.expects('sendRequestPromise').withArgs('delegates/get').returns(deffered.promise); - - const promise = forgingApi.getDelegate(); - - expect(promise).to.equal(deffered.promise); - }); - }); - - describe('getForgedBlocks(limit, offset)', () => { - it('returns Peers.sendRequestPromise(\'blocks\');', () => { - mock.expects('sendRequestPromise').withArgs('blocks').returns(deffered.promise); - - const promise = forgingApi.getForgedBlocks(); - - expect(promise).to.equal(deffered.promise); - }); - }); - - describe('getForgedStats(startMoment)', () => { - it('returns Peers.sendRequestPromise(\'delegates/forging/getForgedByAccount\');', () => { - mock.expects('sendRequestPromise').withArgs('delegates/forging/getForgedByAccount').returns(deffered.promise); - - const promise = forgingApi.getForgedStats(); - - expect(promise).to.equal(deffered.promise); - }); - }); -}); - diff --git a/test/services/api/peers.spec.js b/test/services/api/peers.spec.js deleted file mode 100644 index 109650ab0..000000000 --- a/test/services/api/peers.spec.js +++ /dev/null @@ -1,75 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe('Factory: Peers', () => { - let Peers; - let $q; - let $rootScope; - let dialog; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_Peers_, _$q_, _$rootScope_, _dialog_) => { - Peers = _Peers_; - $q = _$q_; - $rootScope = _$rootScope_; - dialog = _dialog_; - })); - - describe('setActive(account)', () => { - it('sets Peers.active to a random active official peer', () => { - const account = { - network: { - address: 'http://localhost:8000', - }, - }; - expect(Peers.active).to.equal(undefined); - Peers.setActive(account); - expect(Peers.active).not.to.equal(undefined); - expect(Peers.active.currentPeer).not.to.equal(undefined); - }); - }); - - describe('check(notifyUser)', () => { - let deffered; - let mock; - - beforeEach(() => { - deffered = $q.defer(); - mock = sinon.mock(Peers); - mock.expects('sendRequestPromise').returns(deffered.promise); - Peers.active = {}; - }); - - afterEach(() => { - mock.verify(); - mock.restore(); - }); - - it('checks active peer status and if that succeeds then sets this.online = true', () => { - Peers.check(); - deffered.resolve(); - $rootScope.$apply(); - expect(Peers.online).to.equal(true); - }); - - it('checks active peer status and if that fails then sets this.online = false', () => { - Peers.check(); - deffered.reject(); - $rootScope.$apply(); - expect(Peers.online).to.equal(false); - }); - - it('shows error toast if notifyUser is true', () => { - const spy = sinon.spy(dialog, 'errorToast'); - Peers.check(true); - deffered.reject(); - $rootScope.$apply(); - expect(spy).to.have.been.calledWith(); - }); - }); -}); diff --git a/test/services/dialog.spec.js b/test/services/dialog.spec.js deleted file mode 100644 index b482fd32a..000000000 --- a/test/services/dialog.spec.js +++ /dev/null @@ -1,30 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -chai.use(sinonChai); -chai.should(); - -describe('Factory: dialog', () => { - let $mdDialog; - let dialog; - const component = 'send'; - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_$mdDialog_, _dialog_) => { - $mdDialog = _$mdDialog_; - dialog = _dialog_; - })); - - describe('modal(component, options)', () => { - it('opens a $mdDialog', () => { - const mock = sinon.mock($mdDialog); - dialog.modal(); - mock.expects('show').withArgs(); - }); - - it.skip('only accepts a non empty string as component', () => { - component.should.be.a('string'); - }); - }); -}); diff --git a/test/services/lsk.spec.js b/test/services/lsk.spec.js deleted file mode 100644 index d40ffeeaf..000000000 --- a/test/services/lsk.spec.js +++ /dev/null @@ -1,58 +0,0 @@ -const chai = require('chai'); - -const expect = chai.expect; - -describe('Factory: lsk', () => { - let lsk; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_lsk_) => { - lsk = _lsk_; - })); - - describe('normalize(value)', () => { - it('converts 1 to \'0.00000001\'', () => { - expect(lsk.normalize(1)).to.equal('0.00000001'); - }); - }); - - describe('from(value)', () => { - it('converts 0.00000001 to 1', () => { - expect(lsk.from(0.00000001)).to.equal(1); - }); - - it('converts 1 to 100000000', () => { - expect(lsk.from(1)).to.equal(100000000); - }); - - it('converts 10535.67379498 to 1053567379498', () => { - expect(lsk.from(10535.67379498)).to.equal(1053567379498); - }); - }); - - describe('normalize(from(value))', () => { - it('is identity function on 1', () => { - const value = '1'; - expect(lsk.normalize(lsk.from(value))).to.equal(value); - }); - - it('is identity function on 10535.67379498', () => { - const value = '10535.67379498'; - expect(lsk.normalize(lsk.from(value))).to.equal(value); - }); - }); - - describe('from(normalize(value))', () => { - it('is identity function on 100000000', () => { - const value = 100000000; - expect(lsk.from(lsk.normalize(value))).to.equal(value); - }); - - it('is identity function on 1053567379498', () => { - const value = 1053567379498; - expect(lsk.from(lsk.normalize(value))).to.equal(value); - }); - }); -}); - diff --git a/test/services/notification.spec.js b/test/services/notification.spec.js deleted file mode 100644 index ef3ea624d..000000000 --- a/test/services/notification.spec.js +++ /dev/null @@ -1,50 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); - -describe('Factory: Notification', () => { - let lsk; - let $window; - let notify; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_Notification_, _lsk_, _$window_) => { - lsk = _lsk_; - $window = _$window_; - notify = _Notification_.init(); - })); - - describe('about(data)', () => { - const amount = 100000000; - const mockNotification = sinon.spy(); - - it('should call this._deposit', () => { - const spy = sinon.spy(notify, '_deposit'); - notify.isFocused = false; - notify.about('deposit', amount); - expect(spy).to.have.been.calledWith(amount); - }); - - it('should call $window.Notification', () => { - $window.Notification = mockNotification; - const msg = `You've received ${lsk.normalize(amount)} LSK.`; - - notify.isFocused = false; - notify.about('deposit', amount); - expect(mockNotification).to.have.been.calledWith( - 'LSK received', { body: msg }, - ); - mockNotification.reset(); - }); - - it('should not call $window.Notification if app is focused', () => { - notify.about('deposit', amount); - expect(mockNotification).to.have.been.not.calledWith(); - mockNotification.reset(); - }); - }); -}); diff --git a/test/services/passphrase.spec.js b/test/services/passphrase.spec.js deleted file mode 100644 index 0025f89ff..000000000 --- a/test/services/passphrase.spec.js +++ /dev/null @@ -1,131 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const sinonChai = require('sinon-chai'); - -const expect = chai.expect; -chai.use(sinonChai); -const TEST_SEED = ['12', '12', '12', '12', '12', '12', '12', '12', - '12', '12', '12', '12', '12', '12', '12', '12']; -const INVALID_PASSPHRASE = 'INVALID_PASSPHRASE'; - -describe('Factory: Passphrase', () => { - let Passphrase; - - beforeEach(angular.mock.module('app')); - - beforeEach(inject((_Passphrase_) => { - Passphrase = _Passphrase_; - })); - - describe('Passphrase.reset()', () => { - it('resets percentage of progress and seed', () => { - Passphrase.init(); - Passphrase.progress = { - percentage: 50, - seed: TEST_SEED, - }; - Passphrase.reset(); - expect(Passphrase.progress.percentage).to.equal(0); - let allZero = true; - Passphrase.progress.seed.forEach(member => (allZero = (allZero && member === '00'))); - expect(allZero).to.equal(true); - }); - }); - - describe('Passphrase.init()', () => { - it('should define progress.steps as a number above or equal to 1.6', () => { - Passphrase.init(); - expect(Passphrase.progress.step).to.not.be.below(1.6); - }); - - it('should call Passphrase.reset()', () => { - const spy = sinon.spy(Passphrase, 'reset'); - Passphrase.init(); - expect(spy).to.have.been.calledWith(); - }); - }); - - describe('Passphrase.progress', () => { - it('should define progress object', () => { - Passphrase.init(); - expect(Passphrase.progress).to.not.equal(undefined); - }); - }); - - describe('Passphrase.generatePassPhrase', () => { - it('should generate a valid passphrase out of a given valid seed array', () => { - const passphrase = Passphrase.generatePassPhrase(TEST_SEED); - const isValid = Passphrase.isValidPassphrase(passphrase); - expect(isValid).to.equal(1); - }); - }); - - describe('Passphrase.isValidPassphrase', () => { - it('should return 1 for a valid passphrase', () => { - const passphrase = Passphrase.generatePassPhrase(TEST_SEED); - const isValid = Passphrase.isValidPassphrase(passphrase); - expect(isValid).to.equal(1); - }); - - it('should return 0 for an invalid passphrase', () => { - const isValid = Passphrase.isValidPassphrase(INVALID_PASSPHRASE); - expect(isValid).to.equal(0); - }); - - it('should return 2 for an empty passphrase', () => { - const isValid = Passphrase.isValidPassphrase(''); - expect(isValid).to.equal(2); - }); - }); - - describe('Passphrase.normalize', () => { - it('should trim multiple spaces globally and lowercase the string', () => { - const rawString = ' FIRST second Third '; - const fixedString = 'first second third'; - const result = Passphrase.normalize(rawString); - expect(result).to.equal(fixedString); - }); - }); - - describe('Passphrase.listener', () => { - it('should update progress percentage and seed if called with proper event', () => { - Passphrase.init(); - const event = { - pageY: 0, - pageX: 0, - }; - let percentage = -1; - let isProgressIncreasing = true; - - // √(2 * 90^2) > 120 - for (let i = 0; i < 100; i++) {// eslint-disable-line - event.pageX = i * 90; - event.pageY = i * 90; - Passphrase.listener(event, () => {}); - isProgressIncreasing = isProgressIncreasing && - (percentage <= Passphrase.progress.percentage || - Math.floor(Passphrase.progress.percentage) === 100); - percentage = Passphrase.progress.percentage; - } - expect(isProgressIncreasing).to.equal(true); - }); - - it('should call callback if progress percentage is equal to 100', () => { - Passphrase.init(); - const event = { - pageY: 0, - pageX: 0, - }; - let seed = null; - const callback = param => (seed = param); - - // √(2 * 90^2) > 120 - for (let i = 0; i < 100; i++) { // eslint-disable-line - event.pageX = i * 90; - event.pageY = i * 90; - Passphrase.listener(event, callback); - } - expect(seed).to.not.equal(undefined); - }); - }); -}); diff --git a/test/test.js b/test/test.js deleted file mode 100644 index ecdce5af2..000000000 --- a/test/test.js +++ /dev/null @@ -1,31 +0,0 @@ -require('./components/delegateRegistration/delegateRegistration.spec.js'); -require('./components/delegates/delegates.spec'); -require('./components/delegates/vote.spec'); -require('./components/forging/forging.spec'); -require('./components/header/header.spec'); -require('./components/login/login.spec'); -require('./components/login/newAccount.spec'); -require('./components/main/main.spec'); -require('./components/main/secondPass.spec'); -require('./components/passphrase/passphrase.spec'); -require('./components/passphrase/savePassphrase.spec'); -require('./components/send/send.spec'); -require('./components/signVerify/signMessage.spec'); -require('./components/signVerify/verifyMessage.spec'); -require('./components/timestamp/timestamp.spec'); -require('./components/top/top.spec'); -require('./components/transactions/transactions.spec'); -require('./components/openDialog/openDialog.spec.js'); - -require('./services/account.spec'); -require('./services/api/accountApi.spec'); -require('./services/api/delegateApi.spec'); -require('./services/api/forgingApi.spec'); -require('./services/api/peers.spec'); -require('./services/lsk.spec'); -require('./services/passphrase.spec'); -require('./services/notification.spec'); - -require('./run.spec'); - -require('./util/animateOnChange/animateOnChange.spec'); diff --git a/test/util/animateOnChange/animateOnChange.spec.js b/test/util/animateOnChange/animateOnChange.spec.js deleted file mode 100644 index 2fde34e24..000000000 --- a/test/util/animateOnChange/animateOnChange.spec.js +++ /dev/null @@ -1,43 +0,0 @@ -const chai = require('chai'); - -const expect = chai.expect; - -describe('animate-on-change directive', () => { - let $compile; - let $rootScope; - let element; - let $animate; - let $timeout; - - // Load the myApp module, which contains the directive - beforeEach(angular.mock.module('app')); - beforeEach(angular.mock.module('ngAnimateMock')); - - // Store references to $rootScope and $compile - // so they are available to all tests in this describe block - beforeEach(inject((_$compile_, _$rootScope_, _$animate_, _$timeout_) => { - // The injector unwraps the underscores (_) from around the parameter names when matching - $compile = _$compile_; - $rootScope = _$rootScope_; - $animate = _$animate_; - $timeout = _$timeout_; - })); - - beforeEach(() => { - // Compile a piece of HTML containing the directive - $rootScope.byte = '00'; - element = $compile('')($rootScope); - $rootScope.$digest(); - }); - - it('adds and removes class "change" to the element on change of the attribtde', () => { - expect(element.hasClass('change')).to.equal(false); - $rootScope.byte = '01'; - $rootScope.$digest(); - expect(element.hasClass('change')).to.equal(true); - $animate.flush(); - $timeout.flush(); - expect(element.hasClass('change')).to.equal(false); - }); -}); - diff --git a/webpack.config.babel.js b/webpack.config.babel.js deleted file mode 100644 index a036a326e..000000000 --- a/webpack.config.babel.js +++ /dev/null @@ -1,201 +0,0 @@ -const path = require('path'); - -const webpack = require('webpack'); -const merge = require('webpack-merge'); -const validate = require('webpack-validator'); -const HtmlWebpackPlugin = require('html-webpack-plugin'); -const CleanWebpackPlugin = require('clean-webpack-plugin'); -const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; - -const nodeEnvironment = process.env.NODE_ENV; - -const PATHS = { - app: path.join(__dirname, 'src'), - build: path.resolve(__dirname, 'app'), - spec: path.join(__dirname, 'e2e-test'), - test: path.join(__dirname, 'test'), -}; - -const common = { - devtool: 'source-map', - entry: nodeEnvironment === 'test' ? {} : { - app: PATHS.app, - }, - output: { - path: path.join(PATHS.build, 'dist'), - filename: 'app.js', - }, - node: { - fs: 'empty', - }, - resolve: { - alias: { - jquery: 'jquery/src/jquery', - }, - }, -}; - -const clean = pathToClean => ({ - plugins: [ - new CleanWebpackPlugin([pathToClean], { - root: process.cwd(), - }), - ], -}); - -const html = () => ({ - plugins: [ - new HtmlWebpackPlugin({ - filename: 'index.html', - template: path.resolve(PATHS.app, 'index.pug'), - minify: { - collapseWhitespace: true, - minifyCSS: true, - }, - }), - ], -}); - -const devServer = () => ({ - devServer: { - hot: true, - inline: true, - stats: 'errors-only', - }, - plugins: [ - new webpack.HotModuleReplacementPlugin({ multiStep: true }), - ], -}); - -const babel = () => ({ - module: { - loaders: [ - { - test: /\.js$/, - loader: 'babel', - include: [PATHS.app, PATHS.spec, PATHS.test], - }, - ], - }, -}); - -const eslint = () => ({ - module: { - loaders: [ - { - test: /\.js$/, - loader: 'eslint-loader', - exclude: /node_modules/, - include: [PATHS.app, PATHS.spec, PATHS.test], - }, - ], - }, -}); - -const pug = () => ({ - module: { - loaders: [ - { - test: /\.pug$/, - loader: 'pug-loader', - include: PATHS.app, - }, - ], - }, -}); - -const less = () => ({ - module: { - loaders: [ - { - test: /\.less$/, - loader: 'style!css!less', - include: PATHS.app, - }, - ], - }, -}); - -const css = () => ({ - module: { - loaders: [ - { - test: /\.css$/, - loader: 'style!css', - }, - ], - }, -}); - -const json = () => ({ - module: { - loaders: [ - { - test: /\.json$/, - loader: 'json', - }, - ], - }, -}); - -const png = () => ({ - module: { - loaders: [ - { - test: /\.png$/, - loader: 'url', - }, - ], - }, -}); - -const fonts = () => ({ - module: { - loaders: [ - { - test: /\.(eot|svg|ttf|woff|woff2)$/, - loader: 'url', - include: path.join(PATHS.app, 'assets'), - }, - ], - }, -}); - -const provide = () => ({ - plugins: [ - new webpack.ProvidePlugin({ - app: `exports?exports.default!${path.join(PATHS.app, 'app')}`, - }), - ], -}); - -const define = () => ({ - plugins: [ - new webpack.DefinePlugin({ - PRODUCTION: JSON.stringify(nodeEnvironment === 'prod'), - }), - ], -}); - -const bundleAnalyzer = () => ({ - plugins: [ - new BundleAnalyzerPlugin({ - openAnalyzer: false, - analyzerMode: 'static', - }), - ], -}); - -let config; - -switch (process.env.npm_lifecycle_event) { - case 'build': - config = merge(common, clean(path.join(PATHS.build, 'dist')), html(), provide(), define(), eslint(), babel(), pug(), less(), css(), json(), png(), fonts(), bundleAnalyzer()); - break; - default: - config = merge(common, devServer(), { devtool: 'eval-source-map' }, html(), provide(), define(), eslint(), babel(), pug(), less(), css(), json(), png(), fonts()); - break; -} - -// export default validate(config) -module.exports = validate(config); diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 000000000..8686c3b48 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,159 @@ +/* eslint-disable */ +const path = require('path'); +const webpack = require('webpack'); +const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); +const { NamedModulesPlugin } = require('webpack'); +const StyleLintPlugin = require('stylelint-webpack-plugin'); +const ExtractTextPlugin = require('extract-text-webpack-plugin'); +/* eslint-enable */ + +const reactToolboxVariables = { + 'color-primary': '#0288D1', + 'color-primary-dark': '#0288D1', + 'button-border-radius': '3px', + 'input-text-label-color': 'rgba(0,0,0,0.38)', +}; + +let entries = { + app: `${path.resolve(__dirname, 'src')}/main.js`, + vendor: ['react', 'redux', 'react-dom'], +}; +const external = { + 'react/addons': true, + 'react/lib/ExecutionEnvironment': true, + 'react/lib/ReactContext': true, +}; +module.exports = (env) => { + entries = env.test ? `${path.resolve(__dirname, 'src')}/main.js` : entries; + return { + entry: entries, + output: { + path: path.resolve(__dirname, 'app', 'dist'), + filename: env.test ? 'bundle.js' : 'bundle.[name].js', + }, + devtool: env.test ? 'inline-source-map' : 'source-map', + devServer: { + contentBase: 'src', + inline: true, + port: 8080, + historyApiFallback: true, + }, + plugins: [ + new StyleLintPlugin({ + context: `${path.resolve(__dirname, 'src')}/`, + files: '**/*.css', + config: { + extends: 'stylelint-config-standard', + rules: { + 'selector-pseudo-class-no-unknown': null, + 'unit-whitelist': ['px', 'deg', '%', 'em', 'ms'], + 'length-zero-no-unit': null, + }, + ignoreFiles: './node_modules/**/*.css', + }, + }), + new webpack.DefinePlugin({ + PRODUCTION: env.prod, + TEST: env.test, + // because of https://fb.me/react-minification + 'process.env': { + NODE_ENV: env.prod ? JSON.stringify('production') : null, + }, + }), + new ExtractTextPlugin({ + filename: 'styles.css', + allChunks: true, + }), + env.prod + ? new webpack.optimize.UglifyJsPlugin({ + sourceMap: false, + mangle: false, + }) + : undefined, + env.analyze ? new BundleAnalyzerPlugin() : undefined, + !env.prod ? new NamedModulesPlugin() : undefined, + env.test + ? undefined + : new webpack.optimize.CommonsChunkPlugin({ + name: 'vendor', + }), + ].filter(p => !!p), + externals: env.test ? external : {}, + node: { + fs: 'empty', + child_process: 'empty', + }, + module: { + rules: [ + { + enforce: 'pre', + test: /\.js$/, + exclude: /node_modules/, + loader: 'eslint-loader', + options: !env.prod ? { + emitWarning: true, + } : {}, + }, + { + test: /\.js$/, + exclude: /node_modules/, + loader: 'babel-loader', + options: { + presets: ['es2015', 'react', 'stage-3'], + plugins: ['syntax-trailing-function-commas'], + env: { + test: { + plugins: ['istanbul'], + }, + }, + }, + }, + { + test: /\.(eot|svg|ttf|woff|woff2|png)$/, + loader: 'url-loader', + }, + { + test: /\.css$/, + use: ExtractTextPlugin.extract({ + fallback: 'style-loader', + use: [ + { + loader: 'css-loader', + options: { + sourceMap: !env.prod, + modules: true, + importLoaders: 1, + localIdentName: '[name]__[local]___[hash:base64:5]', + }, + }, + { + loader: 'postcss-loader', + options: { + sourceMap: !env.prod, + sourceComments: !env.prod, + plugins: [ + // eslint-disable-next-line import/no-extraneous-dependencies + require('postcss-partial-import')({ /* options */ }), + require('postcss-cssnext')({ + features: { + customProperties: { + variables: reactToolboxVariables, + }, + }, + }), + // eslint-disable-next-line import/no-extraneous-dependencies + require('postcss-for')({ /* options */ }), + ], + }, + }, + ], + }), + }, + { + test: /\.json$/, + use: ['json-loader'], + }, + ], + }, + }; +};