Browse Source

chore(setup): initial app setup (#1)

feat/auto-update
jamaljsr 5 years ago
committed by jamaljsr
parent
commit
3d885248ec
  1. 3
      .commitlintrc
  2. 12
      .editorconfig
  3. 56
      .electronbuildrc
  4. 9
      .eslintignore
  5. 28
      .eslintrc
  6. 4
      .gitattributes
  7. 32
      .github/ISSUE_TEMPLATE/bug_report.md
  8. 15
      .github/PULL_REQUEST_TEMPLATE.md
  9. 89
      .gitignore
  10. 52
      .i18next-scanner.js
  11. 17
      .lintstagedrc
  12. 19
      .prettierrc
  13. 8
      .renovaterc
  14. 78
      .rescriptsrc.js
  15. 3
      .stylelintrc
  16. 4
      .testcafe-electron-rc
  17. 63
      .travis.yml
  18. 14
      .versionrc
  19. 19
      .vscode/settings.json
  20. 21
      LICENSE
  21. 60
      README.md
  22. 4
      TODO.md
  23. 46
      appveyor.yml
  24. BIN
      assets/icon.icns
  25. BIN
      assets/icon.ico
  26. BIN
      assets/icon.png
  27. BIN
      assets/icons/128x128.png
  28. BIN
      assets/icons/16x16.png
  29. BIN
      assets/icons/24x24.png
  30. BIN
      assets/icons/256x256.png
  31. BIN
      assets/icons/32x32.png
  32. BIN
      assets/icons/48x48.png
  33. BIN
      assets/icons/64x64.png
  34. BIN
      assets/icons/96x96.png
  35. 7
      e2e/App.e2e.ts
  36. 64
      e2e/Counter.e2e.ts
  37. 22
      e2e/Home.e2e.ts
  38. 11
      e2e/helpers/index.ts
  39. 11
      e2e/pages/App.ts
  40. 16
      e2e/pages/Counter.ts
  41. 11
      e2e/pages/Home.ts
  42. 3
      e2e/pages/index.ts
  43. 62
      electron/main.ts
  44. 16
      electron/tsconfig.json
  45. 144
      package.json
  46. BIN
      public/favicon.ico
  47. 38
      public/index.html
  48. 15
      public/manifest.json
  49. 9
      src/__mocks__/electron-log.js
  50. 3
      src/__mocks__/i18next.js
  51. 62
      src/__mocks__/react-i18next.js
  52. 9
      src/components/App.spec.tsx
  53. 26
      src/components/App.tsx
  54. 36
      src/components/Routes.spec.tsx
  55. 19
      src/components/Routes.tsx
  56. 12
      src/components/counter/Counter.module.less
  57. 112
      src/components/counter/Counter.spec.tsx
  58. 74
      src/components/counter/Counter.tsx
  59. 1
      src/components/counter/index.ts
  60. 47
      src/components/home/Home.spec.tsx
  61. 46
      src/components/home/Home.tsx
  62. 1
      src/components/home/index.ts
  63. 67
      src/components/layouts/AppLayout.module.less
  64. 91
      src/components/layouts/AppLayout.spec.tsx
  65. 87
      src/components/layouts/AppLayout.tsx
  66. 1
      src/components/layouts/index.ts
  67. 47
      src/i18n/index.ts
  68. 18
      src/i18n/locales/en-US.json
  69. 18
      src/i18n/locales/es.json
  70. 11
      src/index.less
  71. 7
      src/index.tsx
  72. 10
      src/react-app-env.d.ts
  73. BIN
      src/resources/logo.png
  74. 19
      src/setupTests.js
  75. 52
      src/store/index.ts
  76. 54
      src/store/models/counter.spec.ts
  77. 48
      src/store/models/counter.ts
  78. 18
      src/store/models/index.ts
  79. 40
      src/theme/index.js
  80. 20
      tsconfig.json
  81. 17221
      yarn.lock

3
.commitlintrc

@ -0,0 +1,3 @@
{
"extends": ["@commitlint/config-conventional"]
}

12
.editorconfig

@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

56
.electronbuildrc

@ -0,0 +1,56 @@
{
"appId": "com.lightningditto.app",
"productName": "Ditto",
"copyright": "Copyright © 2019 ${author}",
"artifactName": "ditto-${os}-${arch}-v${version}.${ext}",
"files": ["build/**/*", "node_modules/**/*"],
"directories": {
"buildResources": "assets"
},
"mac": {
"category": "public.app-category.utilities",
"target": ["dmg", "zip"]
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64", "ia32"]
}
]
},
"linux": {
"target": [
{
"target": "deb",
"arch": ["x64", "ia32"]
},
{
"target": "AppImage",
"arch": ["x64", "ia32"]
}
],
"category": "Development"
},
"publish": {
"provider": "github",
"owner": "jamaljsr",
"repo": "ditto",
"private": false
},
"extends": null
}

9
.eslintignore

@ -0,0 +1,9 @@
# testing
/coverage
# production
/build
/dist
# compiled by tsc from /src/electron/
/public/main.js

28
.eslintrc

@ -0,0 +1,28 @@
{
"parser": "@typescript-eslint/parser", // Specifies the ESLint parser
"extends": [
"plugin:react/recommended", // Uses the recommended rules from @eslint-plugin-react
"plugin:@typescript-eslint/recommended", // Uses the recommended rules from @typescript-eslint/eslint-plugin
"prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier
"plugin:prettier/recommended" // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array.
],
"parserOptions": {
"ecmaVersion": 2018, // Allows for the parsing of modern ECMAScript features
"sourceType": "module", // Allows for the use of imports
"ecmaFeatures": {
"jsx": true // Allows for the parsing of JSX
}
},
"settings": {
"react": {
"version": "detect" // Tells eslint-plugin-react to automatically detect the version of React to use
}
},
"rules": {
// Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
"react/prop-types": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-member-accessibility": "off",
"@typescript-eslint/no-explicit-any": false
}
}

4
.gitattributes

@ -0,0 +1,4 @@
* text eol=lf
*.png binary
*.ico binary
*.icns binary

32
.github/ISSUE_TEMPLATE/bug_report.md

@ -0,0 +1,32 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. OSX Majave or Windows 10 v1903]
- Docker Version: [e.g. 19.03.1]
- Docker Compose Version: [e.g. 1.24.1]
**Additional context**
Add any other context about the problem here.

15
.github/PULL_REQUEST_TEMPLATE.md

@ -0,0 +1,15 @@
Closes #(issue number goes here)
### Description
[Description of your changes go here. Please provide both casual and technical explanations.]
### Steps to Test
1. Steps
2. To
3. Test
### Screenshots
[Only if applicable]

89
.gitignore

@ -0,0 +1,89 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# next.js build output
.next
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
/dist
# compiled by tsc from /src/electron/
/public/main.js
/public/main.js.map
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

52
.i18next-scanner.js

@ -0,0 +1,52 @@
const typescript = require('typescript');
const fs = require('fs');
const path = require('path');
module.exports = {
input: [
'src/**/*.{ts,tsx}',
// Use ! to filter out files or directories
'!src/**/*.spec.{ts,tsx}',
],
output: './',
options: {
debug: true,
func: {
list: ['i18next.t', 'i18n.t', 't'],
},
trans: {
defaultsKey: 'defaults',
},
lngs: ['en-US', 'es'],
defaultLng: 'en-US',
ns: ['translation'],
defaultNs: 'translation',
defaultValue: '__MISSING__TRANSLATION__',
resource: {
loadPath: 'src/i18n/locales/{{lng}}.json',
savePath: 'src/i18n/locales/{{lng}}.json',
},
nsSeparator: ':',
keySeparator: false,
},
transform: function transform(file, enc, done) {
const { base, ext } = path.parse(file.path);
// custom transform for typescript files
if (['.ts', '.tsx'].includes(ext) && !base.includes('.d.ts')) {
const content = fs.readFileSync(file.path, enc);
// convert ts code into es2018 code that the parser can injest
const { outputText } = typescript.transpileModule(content, {
compilerOptions: {
target: 'es2018',
},
fileName: path.basename(file.path),
});
this.parser.parseTransFromString(outputText);
this.parser.parseFuncFromString(outputText);
}
done();
},
};

17
.lintstagedrc

@ -0,0 +1,17 @@
{
"*.{ts,tsx}": [
"eslint --ext .ts,.tsx --fix",
"prettier --single-quote --write",
"git add"
],
"{.{babelrc,eslintrc,prettierrc,stylelintrc}}": [
"prettier --parser json --write",
"git add"
],
"*.less": [
"stylelint --syntax scss --fix",
"prettier --single-quote --write",
"git add"
],
"*.{yml,md}": ["prettier --single-quote --write", "git add"]
}

19
.prettierrc

@ -0,0 +1,19 @@
{
"overrides": [
{
"files": [".prettierrc", ".babelrc", ".eslintrc", ".stylelintrc"],
"options": {
"parser": "json"
}
}
],
"printWidth": 90,
"singleQuote": true,
"useTabs": false,
"semi": true,
"tabWidth": 2,
"trailingComma": "all",
"bracketSpacing": true,
"jsxBracketSameLine": false,
"arrowParens": "avoid"
}

8
.renovaterc

@ -0,0 +1,8 @@
{
"extends": ["config:base"],
"rangeStrategy": "bump",
"automerge": true,
"major": {
"automerge": false
}
}

78
.rescriptsrc.js

@ -0,0 +1,78 @@
// const darkTheme = require('./src/theme');
const getLessLoader = (test, withModules) => {
return {
test,
use: [
{ loader: 'style-loader' },
{
loader: 'css-loader',
options: !withModules
? undefined
: {
sourceMap: true,
modules: true,
localIdentName: '[local]___[hash:base64:5]',
},
},
{
loader: 'less-loader',
options: {
javascriptEnabled: true,
modifyVars: {
'@primary-color': '#fa8c16',
'@component-background': '#e8e8e8',
},
// modifyVars: darkTheme,
},
},
],
};
};
module.exports = [
config => {
// set target on webpack config to support electrong
config.target = 'electron-renderer';
// add support for hot reload of hooks
config.resolve.alias['react-dom'] = '@hot-loader/react-dom';
return config;
},
[
'use-babel-config',
{
presets: ['react-app'],
plugins: [
// add babel-plugin-import for antd
[
'import',
{
libraryName: 'antd',
libraryDirectory: 'es',
style: true,
},
],
// adds support for live hot reload
'react-hot-loader/babel',
],
},
],
// add less-loader for antd
config => {
const rule = config.module.rules.find(rule => rule.oneOf);
rule.oneOf.unshift(getLessLoader(/\.less$/, false));
rule.oneOf.unshift(getLessLoader(/\.module\.less$/, true));
return config;
},
config => {
// helper function to troubleshoot webpack config issues
RegExp.prototype.toJSON = RegExp.prototype.toString;
Function.prototype.toJSON = () => 'function() { }'; // Function.prototype.toString;
// uncomment the line below to log the webpack config to the console
// console.log(JSON.stringify(config.module.rules, null, 2));
// process.exit(1);
return config;
},
];

3
.stylelintrc

@ -0,0 +1,3 @@
{
"extends": ["stylelint-config-standard", "stylelint-config-prettier"]
}

4
.testcafe-electron-rc

@ -0,0 +1,4 @@
{
"mainWindowUrl": "./build/index.html",
"appPath": "."
}

63
.travis.yml

@ -0,0 +1,63 @@
branches:
only:
- master
- /^v\d+\.\d+(\.\d+)?(-\S*)?$/
matrix:
include:
- os: osx
language: node_js
node_js:
- node
env:
- ELECTRON_CACHE=$HOME/.cache/electron
- ELECTRON_BUILDER_CACHE=$HOME/.cache/electron-builder
- os: linux
language: node_js
node_js:
- node
dist: xenial
services:
- xvfb
addons:
apt:
sources:
- ubuntu-toolchain-r-test
packages:
- gcc-multilib
- g++-8
- g++-multilib
- icnsutils
- graphicsmagick
- xz-utils
- xorriso
- rpm
before_cache:
- rm -rf $HOME/.cache/electron-builder/wine
cache:
yarn: true
directories:
- node_modules
- $(npm config get prefix)/lib/node_modules
- $HOME/.cache/electron
- $HOME/.cache/electron-builder
before_install:
- if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then export CXX="g++-8"; fi
install:
- yarn --ignore-engines --frozen-lockfile --non-interactive --network-timeout 300000
script:
- yarn lint
- yarn lint:styles
- yarn tsc
# ignore snapshot tests on linux due to file incompatibility across different OS's
- if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then yarn test -u; fi
- if [[ "$TRAVIS_OS_NAME" != "linux" ]]; then yarn test; fi
- yarn test:e2e
- yarn test:codecov
- if [[ "$TRAVIS_BRANCH" == "master" && "$TRAVIS_PULL_REQUEST" == "false" ]]; then yarn package:ci; fi

14
.versionrc

@ -0,0 +1,14 @@
{
"types": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "docs", "section": "Docs" },
{ "type": "perf", "section": "Performance" },
{ "type": "test", "hidden": true },
{ "type": "build", "hidden": true },
{ "type": "ci", "hidden": true },
{ "type": "chore", "hidden": true },
{ "type": "style", "hidden": true },
{ "type": "refactor", "hidden": true }
]
}

19
.vscode/settings.json

@ -0,0 +1,19 @@
{
"files.associations": {
".commitlintrc": "jsonc",
".electronbuildrc": "jsonc",
".eslintrc": "jsonc",
".lintstagedrc": "jsonc",
".prettierrc": "jsonc",
".renovaterc": "jsonc",
".stylelintrc": "json",
".versionrc": "json"
},
"eslint.autoFixOnSave": true,
"eslint.validate": [
"javascript",
"javascriptreact",
{ "language": "typescript", "autoFix": true },
{ "language": "typescriptreact", "autoFix": true }
]
}

21
LICENSE

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 jamaljsr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

60
README.md

@ -1,2 +1,58 @@
# ditto
One-click Bitcoin Lightning networks for your local app development & testing
# Lightning Ditto
> One-click Bitcoin Lightning networks for local app development & testing
[![Build Status](https://travis-ci.org/jamaljsr/ditto.svg?branch=master)](https://travis-ci.org/jamaljsr/ditto)
[![Build status](https://ci.appveyor.com/api/projects/status/l5637xbes42316k6?svg=true)](https://ci.appveyor.com/project/jamaljsr/ditto)
[![codecov](https://codecov.io/gh/jamaljsr/ditto/branch/master/graph/badge.svg)](https://codecov.io/gh/jamaljsr/ditto)
## Development
### Dependencies
Ditto requires that you have Docker installed to create the local networks
- On Mac & Windows, you can just install [Docker Desktop](https://www.docker.com/products/docker-desktop)
- On Linux, you need to install [Docker Server](https://docs.docker.com/install/#server) and [Docker Compose](https://docs.docker.com/compose/install/) separately
### Run the app
`yarn && yarn dev`
### Run Unit Tests
`yarn test`
### Run End-to-end Tests
`yarn test:e2e`
### Run Typescript & Linter
`yarn lint:all`
### Package App for your OS
`yarn package`
### Tech Stack
- [Electron](https://github.com/electron/electron/): cross platform desktop app framework
- [Typescript](https://github.com/microsoft/TypeScript): increased productivity with a typed language
- [ReactJS](https://github.com/facebook/react/): declarative UI library for JavaScript
- [Create React App](https://github.com/facebook/create-react-app): minimize build configuration
- [easy-peasy](https://github.com/ctrlplusb/easy-peasy): Redux state management without the boilerplate
- [Ant Design](https://github.com/ant-design/ant-design/): don't reinvent the wheel with UI design
- [react-i18next](https://github.com/i18next/react-i18next): support for multiple languages (english/spanish included)
- [electron-log](https://github.com/megahertz/electron-log): multi-level logging to console and file
- [Prettier](https://github.com/prettier/prettier): keep code format consistent
- [ESLint](https://github.com/eslint/eslint): follow code quality best practices
- [Travis](https://travis-ci.org): automate builds and testing on Mac/Linux
- [AppVeyor](https://appveyor.com): automate builds and testing on Windows
- [Renevate Bot](https://github.com/renovatebot/renovate): automate dependency upgrades via GitHub bot
- [Jest](https://github.com/facebook/jest): delightful JavaScript testing
- [React Testing Library](https://github.com/testing-library/react-testing-library): React specific testing utilities
- [CodeCov](https://codecov.io/): maintain quality of unit tests
- [Testcafe](https://github.com/DevExpress/testcafe): End-to-end is important
- [commitlint](https://github.com/conventional-changelog/commitlint): standardize git commit messages
- [standard-version](https://github.com/conventional-changelog/commitlint): automate release versioning and changelog generation

4
TODO.md

@ -0,0 +1,4 @@
# TODO List
- update app icon
- better UI design

46
appveyor.yml

@ -0,0 +1,46 @@
image: Visual Studio 2017
platform:
- x64
environment:
matrix:
- nodejs_version: 10
cache:
- '%LOCALAPPDATA%/Yarn'
- node_modules
- '%USERPROFILE%\.electron'
matrix:
fast_finish: true
branches:
only:
- master
build: off
version: '{build}'
shallow_clone: true
clone_depth: 1
skip_branch_with_pr: true
install:
- ps: Install-Product node $env:nodejs_version x64
- set CI=true
- yarn
test_script:
- yarn lint
- yarn lint:styles
- yarn tsc
- yarn test
- yarn test:e2e
- yarn test:codecov
deploy_script:
- cmd: powershell if ($env:appveyor_repo_branch -eq 'master') { yarn package:ci }

BIN
assets/icon.icns

Binary file not shown.

BIN
assets/icon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
assets/icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
assets/icons/128x128.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
assets/icons/16x16.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 B

BIN
assets/icons/24x24.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

BIN
assets/icons/256x256.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
assets/icons/32x32.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

BIN
assets/icons/48x48.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
assets/icons/64x64.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
assets/icons/96x96.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

7
e2e/App.e2e.ts

@ -0,0 +1,7 @@
import { getPageTitle, assertNoConsoleErrors, pageUrl } from './helpers';
fixture`App`.page(pageUrl).afterEach(assertNoConsoleErrors);
test('should have correct title', async t => {
await t.expect(getPageTitle()).eql('React App');
});

64
e2e/Counter.e2e.ts

@ -0,0 +1,64 @@
import { App, Counter } from './pages';
import { assertNoConsoleErrors, pageUrl, getPageUrl } from './helpers';
fixture`Counter`
.page(pageUrl)
.beforeEach(App.clickCounterLink)
.afterEach(assertNoConsoleErrors);
test('should be on the route /counter', async t => {
await t.expect(getPageUrl()).match(/.*#\/counter$/);
});
test('should display updated count after increment button click', async t => {
await t
.click(Counter.increment)
.expect(Counter.getCounterText())
.eql('1');
});
test('should display updated count after decrement button click', async t => {
await t
.click(Counter.decrement)
.expect(Counter.getCounterText())
.eql('-1');
});
test('should not change when count is even and odd button clicked', async t => {
await t
.click(Counter.incrementOdd)
.expect(Counter.getCounterText())
.eql('0');
});
test('should change when count is odd and odd button clicked', async t => {
await t
.click(Counter.increment)
.click(Counter.incrementOdd)
.expect(Counter.getCounterText())
.eql('3');
});
test('should change a second later if async button clicked', async t => {
await t
.click(Counter.incrementAsync)
.expect(Counter.loadingIcon)
.ok()
.expect(Counter.getCounterText())
.eql('1');
});
test('should show error if count is 3 and async button clicked', async t => {
await t
.click(Counter.increment) // increment to 1
.click(Counter.incrementOdd) // increment to 3
.click(Counter.incrementAsync)
.expect(Counter.loadingIcon.exists)
.ok()
.expect(Counter.getCounterText())
.eql('3')
.expect(Counter.error.exists)
.ok()
.expect(Counter.getErrorText())
.eql('Increment Async prohibited when count is 3.');
});

22
e2e/Home.e2e.ts

@ -0,0 +1,22 @@
import { Home } from './pages';
import { getPageUrl, assertNoConsoleErrors, pageUrl } from './helpers';
fixture`Home`.page(pageUrl).afterEach(assertNoConsoleErrors);
test('should be on the route /', async t => {
await t.expect(getPageUrl()).match(/.*#\/$/);
});
test('should show success alert when "Click Me" button clicked', async t => {
await t
.click(Home.clickMeButton)
.expect(Home.successAlert.exists)
.ok();
});
test('should navgiate to /counter', async t => {
await t
.click(Home.counterLink)
.expect(getPageUrl())
.contains('/counter');
});

11
e2e/helpers/index.ts

@ -0,0 +1,11 @@
import { ClientFunction } from 'testcafe';
export const pageUrl = '../build/index.html';
export const getPageUrl = ClientFunction(() => window.location.href);
export const getPageTitle = ClientFunction(() => document.title);
export const assertNoConsoleErrors = async (t: TestController) => {
const { error } = await t.getBrowserConsoleMessages();
await t.expect(error).eql([]);
};

11
e2e/pages/App.ts

@ -0,0 +1,11 @@
import { Selector } from 'testcafe';
class App {
navHomeLink: Selector = Selector('[data-tid=nav-home]');
navCounterLink: Selector = Selector('[data-tid=nav-counter]');
clickHomeLink = async (t: TestController) => t.click(this.navHomeLink);
clickCounterLink = async (t: TestController) => t.click(this.navCounterLink);
}
export default new App();

16
e2e/pages/Counter.ts

@ -0,0 +1,16 @@
import { Selector } from 'testcafe';
class Counter {
counter: Selector = Selector('[data-tid=counter]');
increment: Selector = Selector('[data-tid=incr-btn]');
decrement: Selector = Selector('[data-tid=decr-btn]');
incrementOdd: Selector = Selector('[data-tid=odd-btn]');
incrementAsync: Selector = Selector('[data-tid=async-btn]');
loadingIcon: Selector = Selector('[data-tid=async-loader]');
error: Selector = Selector('[data-tid=error]');
getCounterText = () => this.counter.innerText;
getErrorText = () => this.error.innerText;
}
export default new Counter();

11
e2e/pages/Home.ts

@ -0,0 +1,11 @@
import { Selector } from 'testcafe';
class Home {
successAlert: Selector = Selector('[data-tid=success]');
counterLink: Selector = Selector('[data-tid=counter-link]');
clickMeButton: Selector = Selector('[data-tid=me-btn]');
clickCounterLink = async (t: TestController) => t.click(this.counterLink);
}
export default new Home();

3
e2e/pages/index.ts

@ -0,0 +1,3 @@
export { default as App } from './App';
export { default as Home } from './Home';
export { default as Counter } from './Counter';

62
electron/main.ts

@ -0,0 +1,62 @@
import path from 'path';
import { app, BrowserWindow } from 'electron';
import debug from 'electron-debug';
import isNotPackaged from 'electron-is-dev';
import { warn } from 'electron-log';
const isDev = isNotPackaged && process.env.NODE_ENV !== 'production';
warn(`Starting Electron main process`);
let mainWindow: BrowserWindow | null;
// use dev server for hot reload or file in production
const url = isDev
? 'http://localhost:3000'
: `file://${path.join(__dirname, '../build/index.html')}`;
// install react & redux chrome dev tools
const installExtensions = async () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const installer = require('electron-devtools-installer');
const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
const extensions = ['REACT_DEVELOPER_TOOLS', 'REDUX_DEVTOOLS'];
return Promise.all(
extensions.map(name => installer.default(installer[name], forceDownload)),
).catch(console.log);
};
const createWindow = async () => {
mainWindow = new BrowserWindow({
width: isDev ? 1536 : 900,
height: 680,
webPreferences: {
nodeIntegration: true,
},
});
mainWindow.loadURL(url);
if (isDev) {
debug();
await installExtensions();
mainWindow.webContents.openDevTools();
mainWindow.maximize();
}
mainWindow.on('closed', () => (mainWindow = null));
};
app.on('ready', createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
}
});

16
electron/tsconfig.json

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2019",
"lib": ["esnext"],
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"module": "commonjs",
"moduleResolution": "node",
"outDir": "../public/",
"pretty": true,
"sourceMap": true
},
"include": ["."]
}

144
package.json

@ -0,0 +1,144 @@
{
"name": "lightning-ditto",
"version": "0.1.0",
"homepage": "https://lightningditto.com",
"description": "One-click Bitcoin Lightning networks for local app development & testing",
"author": {
"name": "jamaljsr",
"email": "contact@lightningditto.com",
"url": "https://lightningditto.com"
},
"main": "public/main.js",
"scripts": {
"build": "cross-env PUBLIC_URL=./ rescripts build",
"cm": "git add . && git cz",
"dev": "concurrently \"cross-env BROWSER=none yarn start\" \"wait-on http://localhost:3000 && electron .\"",
"eject": "rescripts eject",
"extract-langs": "i18next-scanner --config ./.i18next-scanner.js",
"lint": "eslint --ext .ts,.tsx --ignore-path .gitignore .",
"lint:all": "yarn tsc && yarn lint && yarn lint:styles",
"lint:fix": "yarn lint --fix",
"lint:styles": "stylelint **/*.*less",
"lint:styles:fix": "stylelint **/*.*less --fix",
"package": "yarn build && yarn package:electron",
"package:ci": "yarn postinstall && yarn build && yarn package:electron --publish onTagOrDraft",
"package:electron": "electron-builder build -c.extraMetadata.main=build/main.js --config .electronbuildrc",
"postinstall": "electron-builder install-app-deps",
"prestart": "tsc -p electron/tsconfig.json",
"prebuild": "tsc -p electron/tsconfig.json",
"release": "standard-version --no-verify",
"start": "rescripts start",
"test": "rescripts test --coverage",
"test:all": "cross-env CI=true yarn test && yarn test:e2e",
"test:codecov": "codecov",
"test:e2e": "yarn build && cross-env NODE_ENV=production testcafe electron:./ ./e2e/**/*.e2e.ts",
"test:e2e:live": "yarn test:e2e -L",
"tsc": "tsc --noEmit"
},
"dependencies": {
"electron-debug": "^3.0.1",
"electron-is-dev": "^1.1.0",
"electron-log": "^3.0.7"
},
"devDependencies": {
"@commitlint/cli": "^8.1.0",
"@commitlint/config-conventional": "^8.1.0",
"@hot-loader/react-dom": "^16.8.6",
"@rescripts/cli": "^0.0.11",
"@rescripts/rescript-use-babel-config": "^0.0.8",
"@testing-library/jest-dom": "^4.0.0",
"@testing-library/react": "^8.0.9",
"@types/jest": "24.0.17",
"@types/node": "12.7.1",
"@types/react": "16.9.0",
"@types/react-dom": "16.8.5",
"@types/react-redux": "^7.1.1",
"@types/react-router": "^5.0.3",
"@types/react-router-dom": "^4.3.4",
"@types/redux-logger": "^3.0.7",
"@typescript-eslint/eslint-plugin": "^1.13.0",
"@typescript-eslint/parser": "^1.13.0",
"antd": "^3.21.2",
"babel-plugin-import": "^1.12.0",
"codecov": "^3.5.0",
"commitizen": "^4.0.3",
"concurrently": "^4.1.1",
"connected-react-router": "^6.5.2",
"cross-env": "^5.2.0",
"easy-peasy": "3.0.1",
"electron": "^5.0.9",
"electron-builder": "^21.2.0",
"electron-devtools-installer": "^2.2.4",
"eslint": "5.16.0",
"eslint-config-prettier": "^6.0.0",
"eslint-plugin-prettier": "^3.1.0",
"eslint-plugin-react": "^7.14.3",
"history": "^4.9.0",
"husky": "^3.0.3",
"i18next": "^17.0.9",
"i18next-browser-languagedetector": "^3.0.3",
"i18next-scanner": "^2.10.2",
"less": "^3.9.0",
"less-loader": "^5.0.0",
"lint-staged": "^9.2.1",
"prettier": "^1.18.2",
"react": "^16.9.0",
"react-async-hook": "^3.4.0",
"react-dom": "^16.9.0",
"react-hot-loader": "^4.12.10",
"react-i18next": "^10.11.5",
"react-redux": "^7.1.0",
"react-router": "^5.0.1",
"react-router-dom": "^5.0.1",
"react-scripts": "3.0.1",
"redux": "^4.0.4",
"redux-logger": "^3.0.6",
"standard-version": "^7.0.0",
"stylelint": "^10.1.0",
"stylelint-config-prettier": "^5.2.0",
"stylelint-config-standard": "^18.3.0",
"testcafe": "^1.4.0",
"testcafe-browser-provider-electron": "^0.0.11",
"testcafe-react-selectors": "^3.1.0",
"typescript": "3.5.3",
"wait-on": "^3.3.0",
"webpack": "4.29.6"
},
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
"pre-commit": "lint-staged"
}
},
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!<rootDir>/node_modules/",
"!<rootDir>/src/i18n/index.ts",
"!<rootDir>/src/index.tsx",
"!<rootDir>/src/react-app-env.d.ts",
"!<rootDir>/src/store/index.ts",
"!<rootDir>/src/theme/index.js"
]
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}

BIN
public/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

38
public/index.html

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

15
public/manifest.json

@ -0,0 +1,15 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

9
src/__mocks__/electron-log.js

@ -0,0 +1,9 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
module.exports = {
error: (...params) => {},
warn: (...params) => {},
info: (...params) => {},
verbose: (...params) => {},
debug: (...params) => {},
silly: (...params) => {},
};

3
src/__mocks__/i18next.js

@ -0,0 +1,3 @@
module.exports = {
t: k => k,
};

62
src/__mocks__/react-i18next.js

@ -0,0 +1,62 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const React = require('react');
const reactI18next = require('react-i18next');
const hasChildren = node =>
node && (node.children || (node.props && node.props.children));
const getChildren = node =>
node && node.children ? node.children : node.props && node.props.children;
const renderNodes = reactNodes => {
if (typeof reactNodes === 'string') {
return reactNodes;
}
return Object.keys(reactNodes).map((key, i) => {
const child = reactNodes[key];
const isElement = React.isValidElement(child);
if (typeof child === 'string') {
return child;
}
if (hasChildren(child)) {
const inner = renderNodes(getChildren(child));
return React.cloneElement(child, { ...child.props, key: i }, inner);
}
if (typeof child === 'object' && !isElement) {
return Object.keys(child).reduce((str, childKey) => `${str}${child[childKey]}`, '');
}
return child;
});
};
// wrap changeLanguage() in a jest mock to track invocations
const i18n = {
changeLanguage: jest.fn(l => l),
};
// t() should just return the language key
const t = k => k;
// mock for the useTranslation hook
const useMock = [t, i18n];
useMock.t = t;
useMock.i18n = i18n;
module.exports = {
// this mock makes sure any components using the translate HoC receive the t function as a prop
// eslint-disable-next-line react/display-name
withTranslation: () => Component => props => <Component t={k => k} {...props} />,
Trans: ({ children }) => renderNodes(children),
Translation: ({ children }) => children(k => k, { i18n: {} }),
useTranslation: () => useMock,
// mock if needed
I18nextProvider: reactI18next.I18nextProvider,
initReactI18next: reactI18next.initReactI18next,
setDefaults: reactI18next.setDefaults,
getDefaults: reactI18next.getDefaults,
setI18n: reactI18next.setI18n,
getI18n: reactI18next.getI18n,
};

9
src/components/App.spec.tsx

@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

26
src/components/App.tsx

@ -0,0 +1,26 @@
import React, { useEffect } from 'react';
import { hot } from 'react-hot-loader/root';
import { Provider } from 'react-redux';
import { ConnectedRouter } from 'connected-react-router';
import { StoreProvider } from 'easy-peasy';
import store, { history } from 'store';
import Routes from './Routes';
import { warn } from 'electron-log';
const App: React.FC = () => {
useEffect(() => warn('Rendering App component'), []);
return (
// store provider for easy-peasy hooks
<StoreProvider store={store}>
{/* react-redux provider for router state */}
<Provider store={store as any}>
{/* connected-react-router */}
<ConnectedRouter history={history}>
<Routes />
</ConnectedRouter>
</Provider>
</StoreProvider>
);
};
export default hot(App);

36
src/components/Routes.spec.tsx

@ -0,0 +1,36 @@
import React from 'react';
import { StoreProvider } from 'easy-peasy';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import '@testing-library/react/cleanup-after-each';
import { createReduxStore } from 'store';
import Routes, { HOME, COUNTER } from './Routes';
describe('App container', () => {
const renderComponent = (route: string) => {
const store = createReduxStore();
const routes = (
<StoreProvider store={store}>
<Provider store={store as any}>
<MemoryRouter initialEntries={[route]}>
<Routes />
</MemoryRouter>
</Provider>
</StoreProvider>
);
return render(routes);
};
it('should render the home page', () => {
const { getByTestId } = renderComponent(HOME);
expect(getByTestId('me-btn')).toHaveTextContent('home.me-btn');
});
it('should render the counter page', () => {
const { getByTestId } = renderComponent(COUNTER);
expect(getByTestId('counter')).toHaveTextContent('0');
});
});

19
src/components/Routes.tsx

@ -0,0 +1,19 @@
import React from 'react';
import { Switch, Route } from 'react-router';
import { AppLayout } from './layouts';
import { Home } from './home';
import { Counter } from './counter';
export const HOME = '/';
export const COUNTER = '/counter';
const Routes = () => (
<AppLayout>
<Switch>
<Route path={COUNTER} component={Counter} />
<Route path={HOME} component={Home} />
</Switch>
</AppLayout>
);
export default Routes;

12
src/components/counter/Counter.module.less

@ -0,0 +1,12 @@
.body {
padding: 20px;
text-align: center;
.counter {
font-size: 5rem;
}
.btnGroup .btn {
margin: 5px;
}
}

112
src/components/counter/Counter.spec.tsx

@ -0,0 +1,112 @@
import React from 'react';
import {
render,
fireEvent,
waitForElement,
waitForElementToBeRemoved,
} from '@testing-library/react';
import { StoreProvider } from 'easy-peasy';
import { ConnectedRouter } from 'connected-react-router';
import { Provider } from 'react-redux';
import { createMemoryHistory } from 'history';
import { createReduxStore } from 'store';
import Counter from './Counter';
describe('Counter component', () => {
const renderComponent = () => {
const store = createReduxStore();
const app = (
<StoreProvider store={store}>
<Provider store={store as any}>
<ConnectedRouter history={createMemoryHistory()}>
<Counter />
</ConnectedRouter>
</Provider>
</StoreProvider>
);
return render(app);
};
it('should contain default counter value of zero', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('counter')).toHaveTextContent('0');
});
it('should increment by one when plus btn is clicked', () => {
const { getByTestId } = renderComponent();
const btn = getByTestId('incr-btn');
fireEvent.click(btn);
expect(getByTestId('counter')).toHaveTextContent('1');
});
it('should decrement by one when minus btn is clicked', () => {
const { getByTestId } = renderComponent();
const btn = getByTestId('decr-btn');
fireEvent.click(btn);
expect(getByTestId('counter')).toHaveTextContent('-1');
});
it('should not increment when odd btn is clicked and count is even', () => {
const { getByTestId } = renderComponent();
const btn = getByTestId('odd-btn');
fireEvent.click(btn);
expect(getByTestId('counter')).toHaveTextContent('0');
});
it('should increment by two when odd btn is clicked and count is odd', () => {
const { getByTestId } = renderComponent();
// first increment to 1 so that the current count is odd
const incr = getByTestId('incr-btn');
fireEvent.click(incr);
const btn = getByTestId('odd-btn');
fireEvent.click(btn);
expect(getByTestId('counter')).toHaveTextContent('3');
});
it('should show loader when async btn is clicked', async () => {
const { getByTestId } = renderComponent();
const btn = getByTestId('async-btn');
fireEvent.click(btn);
const loader = await waitForElement(() => getByTestId('async-loader'));
expect(loader).not.toBeNull();
});
it('should increment after some time when async btn is clicked', async () => {
const { getByTestId } = renderComponent();
const btn = getByTestId('async-btn');
fireEvent.click(btn);
// wait for loader to show and then be removed
await waitForElement(() => getByTestId('async-loader'));
await waitForElementToBeRemoved(() => getByTestId('async-loader'));
expect(btn).toHaveTextContent('cmps.counter.increment-async');
expect(getByTestId('counter')).toHaveTextContent('1');
});
it('should raise error when count is 3 and async btn is clicked', async () => {
const { getByTestId } = renderComponent();
// first increment to 3
const incr = getByTestId('incr-btn');
fireEvent.click(incr);
fireEvent.click(incr);
fireEvent.click(incr);
const btn = getByTestId('async-btn');
fireEvent.click(btn);
// wait for loader to show and then be removed
await waitForElement(() => getByTestId('async-loader'));
await waitForElementToBeRemoved(() => getByTestId('async-loader'));
expect(btn).toHaveTextContent('cmps.counter.increment-async');
expect(getByTestId('error')).toHaveTextContent(
'models.counter.increment-async.error',
);
});
});

74
src/components/counter/Counter.tsx

@ -0,0 +1,74 @@
import React, { useEffect } from 'react';
import { Alert, Button, Icon } from 'antd';
import { useStoreState, useStoreActions } from 'store';
import { useAsyncCallback } from 'react-async-hook';
import { useTranslation } from 'react-i18next';
import styles from './Counter.module.less';
import { info } from 'electron-log';
const Counter = () => {
useEffect(() => info('Rendering Counter component'), []);
const { t } = useTranslation();
const { count } = useStoreState(s => s.counter);
const { increment, decrement, incrementIfOdd, incrementAsync } = useStoreActions(
a => a.counter,
);
const { execute: incrementAsyncCb, loading, error } = useAsyncCallback(() =>
incrementAsync(),
);
const [incrementCb, decrementCb, incrementIfOddCb] = [
increment,
decrement,
incrementIfOdd,
].map(x => () => x());
return (
<div className={styles.body}>
{error && <Alert message={error.message} type="error" data-tid="error" />}
<h1 className={styles.counter} data-tid="counter">
{loading ? <Icon type="loading" data-tid="async-loader" /> : count}
</h1>
<div className={styles.btnGroup}>
<Button
type="primary"
icon="plus"
className={styles.btn}
data-tid="incr-btn"
onClick={incrementCb}
>
{t('cmps.counter.increment', 'Increment')}
</Button>
<Button
type="primary"
icon="minus"
className={styles.btn}
data-tid="decr-btn"
onClick={decrementCb}
>
{t('cmps.counter.decrement', 'Decrement')}
</Button>
<Button
type="primary"
icon="question"
className={styles.btn}
data-tid="odd-btn"
onClick={incrementIfOddCb}
>
{t('cmps.counter.increment-odd', 'Increment Odd')}
</Button>
<Button
type="primary"
icon="retweet"
className={styles.btn}
data-tid="async-btn"
onClick={incrementAsyncCb}
loading={loading}
>
{t('cmps.counter.increment-async', 'Increment Async')}
</Button>
</div>
</div>
);
};
export default Counter;

1
src/components/counter/index.ts

@ -0,0 +1 @@
export { default as Counter } from './Counter';

47
src/components/home/Home.spec.tsx

@ -0,0 +1,47 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { StoreProvider } from 'easy-peasy';
import { ConnectedRouter } from 'connected-react-router';
import { Provider } from 'react-redux';
import { createMemoryHistory } from 'history';
import { createReduxStore } from 'store';
import Home from './Home';
describe('Home component', () => {
const renderComponent = () => {
const store = createReduxStore();
const app = (
<StoreProvider store={store}>
<Provider store={store as any}>
<ConnectedRouter history={createMemoryHistory()}>
<Home />
</ConnectedRouter>
</Provider>
</StoreProvider>
);
return render(app);
};
it('should contain a "Click Me!" button', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('me-btn')).toHaveTextContent('cmps.home.me-btn');
});
it('should contain a link to Counter page', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('counter-link')).toHaveTextContent('Counter');
});
it('should not show alert message', () => {
const { queryByTestId } = renderComponent();
expect(queryByTestId('success')).toBeFalsy();
});
it('should show alert after button is clicked', () => {
const { getByTestId } = renderComponent();
const btn = getByTestId('me-btn');
fireEvent.click(btn);
expect(getByTestId('success')).toHaveTextContent('cmps.home.success-text');
});
});

46
src/components/home/Home.tsx

@ -0,0 +1,46 @@
import React, { useState, useEffect } from 'react';
import { Card, Button, Alert } from 'antd';
import { Link } from 'react-router-dom';
import { useTranslation, Trans } from 'react-i18next';
import { COUNTER } from 'components/Routes';
import { info } from 'electron-log';
const Home = () => {
useEffect(() => info('Rendering Home component'), []);
const { t } = useTranslation();
const [showAlert, setShowAlert] = useState(false);
const handleClickMe = () => setShowAlert(true);
return (
<div>
{showAlert && (
<Alert
message={t('cmps.home.success-text')}
type="success"
showIcon
data-tid="success"
/>
)}
<Card title={t('cmps.home.card-title')}>
<p>{t('cmps.home.card-description')}</p>
<p>
<Trans i18nKey="cmps.home.play">
Play with the{' '}
<Link to={COUNTER} data-tid="counter-link">
Counter
</Link>{' '}
thing
</Trans>
</p>
<p>
<Button type="primary" data-tid="me-btn" onClick={handleClickMe}>
{t('cmps.home.me-btn')}
</Button>
</p>
</Card>
</div>
);
};
export default Home;

1
src/components/home/index.ts

@ -0,0 +1 @@
export { default as Home } from './Home';

67
src/components/layouts/AppLayout.module.less

@ -0,0 +1,67 @@
.logo {
position: relative;
height: 64px;
padding-left: 24px;
overflow: hidden;
line-height: 64px;
background: #002140;
transition: all 0.3s;
img {
height: 32px;
display: inline-block;
vertical-align: middle;
}
span {
display: inline-block;
color: #fff;
font-weight: 600;
font-size: 18px;
vertical-align: middle;
}
}
.layout {
min-height: 100vh;
}
.header {
padding: 0;
.trigger {
color: rgba(255, 255, 255, 0.65);
font-size: 18px;
line-height: 64px;
padding: 0 24px;
cursor: pointer;
transition: color 0.3s;
}
.trigger:hover {
color: @primary-color;
}
}
.content {
margin: 0 16px;
}
.breadcrumb {
margin: 16px 0;
}
.container {
padding: 24;
min-height: 360;
background-color: @component-background;
}
.footer {
text-align: center;
}
// Ant overrides
.ant-layout-header {
border-left: 1px solid #000c17;
}

91
src/components/layouts/AppLayout.spec.tsx

@ -0,0 +1,91 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { StoreProvider } from 'easy-peasy';
import { ConnectedRouter } from 'connected-react-router';
import { Provider } from 'react-redux';
import { createMemoryHistory, MemoryHistory } from 'history';
import { createReduxStore } from 'store';
import { useTranslation } from 'react-i18next';
import AppLayout from './AppLayout';
describe('AppLayout component', () => {
let history: MemoryHistory;
const renderComponent = (path?: string) => {
history = createMemoryHistory();
if (path) history.push(path);
const store = createReduxStore();
const app = (
<StoreProvider store={store}>
<Provider store={store as any}>
<ConnectedRouter history={history}>
<AppLayout>
<p data-tid="hello">Hello World!</p>
</AppLayout>
</ConnectedRouter>
</Provider>
</StoreProvider>
);
return render(app);
};
const changeLanguageMock = () => {
// get access to the mocked 'changeLanguage' function
const { i18n } = useTranslation();
const changeLanguage = (i18n.changeLanguage as unknown) as jest.Mock<
typeof i18n.changeLanguage
>;
return changeLanguage;
};
it('should contain a "Hello World!" text', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('hello')).toHaveTextContent('Hello World!');
});
it('should contain a collapse trigger', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('trigger')).toBeTruthy();
});
it('should be colappsed by default', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('sider')).toHaveClass('ant-layout-sider-collapsed');
});
it('should expand menu when trigger clicked', () => {
const { getByTestId } = renderComponent();
fireEvent.click(getByTestId('trigger'));
expect(getByTestId('sider')).not.toHaveClass('ant-layout-sider-collapsed');
});
it('should set language to English', () => {
const { getByTestId } = renderComponent();
const changeLanguage = changeLanguageMock();
fireEvent.click(getByTestId('english'));
expect(changeLanguage.mock.calls.length).toBe(1);
expect(changeLanguage.mock.calls[0][0]).toBe('en-US');
changeLanguage.mockClear();
});
it('should set language to Spanish', async () => {
const { getByTestId } = renderComponent();
const changeLanguage = changeLanguageMock();
fireEvent.click(getByTestId('spanish'));
expect(changeLanguage.mock.calls.length).toBe(1);
expect(changeLanguage.mock.calls[0][0]).toBe('es');
changeLanguage.mockClear();
});
it('should navigate to counter page when Counter link clicked', () => {
const { getByTestId } = renderComponent();
fireEvent.click(getByTestId('nav-counter'));
expect(history.location.pathname).toEqual('/counter');
});
it('should navigate to home page when logo clicked', () => {
const { getByTestId } = renderComponent('/counter');
fireEvent.click(getByTestId('logo'));
expect(history.location.pathname).toEqual('/');
});
});

87
src/components/layouts/AppLayout.tsx

@ -0,0 +1,87 @@
import React, { useState } from 'react';
import { Layout, Menu, Icon } from 'antd';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { HOME, COUNTER } from 'components/Routes';
import logo from 'resources/logo.png';
import styles from './AppLayout.module.less';
const { Header, Content, Footer, Sider } = Layout;
const { SubMenu } = Menu;
interface Props {
children: React.ReactNode;
}
const AppLayout: React.FC<Props> = (props: Props) => {
const { t, i18n } = useTranslation();
const [collapsed, setCollapsed] = useState(true);
const toggle = () => setCollapsed(!collapsed);
const setEnglish = () => i18n.changeLanguage('en-US');
const setSpanish = () => i18n.changeLanguage('es');
return (
<Layout className={styles.layout}>
<Sider collapsible collapsed={collapsed} trigger={null} data-tid="sider">
<div className={styles.logo}>
<Link to={HOME} data-tid="logo">
<img src={logo} alt="logo" />
{!collapsed && <span>Ditto</span>}
</Link>
</div>
<Menu theme="dark" mode="inline" selectable={false}>
<Menu.Item key="1">
<Link to={HOME} data-tid="nav-home">
<Icon type="pie-chart" />
<span>{t('cmps.app-layout.home', 'Home')}</span>
</Link>
</Menu.Item>
<Menu.Item key="2">
<Link to={COUNTER} data-tid="nav-counter">
<Icon type="desktop" />
<span>{t('cmps.app-layout.counter', 'Counter')}</span>
</Link>
</Menu.Item>
<SubMenu
key="sub1"
title={
<span>
<Icon type="user" />
<span>{t('cmps.app-layout.menu', 'Menu')}</span>
</span>
}
>
<Menu.Item key="3">{t('cmps.app-layout.item1', 'Item 1')}</Menu.Item>
<Menu.Item key="4">{t('cmps.app-layout.item2', 'Item 2')}</Menu.Item>
<Menu.Item key="5">{t('cmps.app-layout.item3', 'Item 3')}</Menu.Item>
</SubMenu>
</Menu>
</Sider>
<Layout>
<Header className={styles.header}>
<Icon
className={styles.trigger}
type={collapsed ? 'menu-unfold' : 'menu-fold'}
onClick={toggle}
data-tid="trigger"
/>
</Header>
<Content className={styles.content}>
<div className={styles.container}>{props.children}</div>
</Content>
<Footer className={styles.footer}>
React App &copy; 2019 Fomo Bros{' '}
<a href="/#" data-tid="english" onClick={setEnglish}>
EN
</a>{' '}
|{' '}
<a href="/#" data-tid="spanish" onClick={setSpanish}>
ES
</a>
</Footer>
</Layout>
</Layout>
);
};
export default AppLayout;

1
src/components/layouts/index.ts

@ -0,0 +1 @@
export { default as AppLayout } from './AppLayout';

47
src/i18n/index.ts

@ -0,0 +1,47 @@
import i18n from 'i18next';
import { app, remote } from 'electron';
import { initReactI18next } from 'react-i18next';
const detectedLang = (app || remote.app).getLocale();
const config: LocalConfig = {
fallbackLng: 'en-US',
languages: {
'en-US': 'English',
es: 'Español',
},
};
const resources = Object.keys(config.languages).reduce(
(acc: { [key: string]: any }, lang) => {
acc[lang] = {
translation: require(`./locales/${lang}.json`),
};
return acc;
},
Object,
);
const whitelist = Object.keys(config.languages).reduce((acc: string[], lang) => {
acc.push(lang);
if (lang.includes('-')) {
acc.push(lang.substring(0, lang.indexOf('-')));
}
return acc;
}, []);
i18n.use(initReactI18next).init({
lng: detectedLang || 'en-US',
resources,
whitelist,
fallbackLng: config.fallbackLng,
debug: process.env.NODE_ENV !== 'production',
keySeparator: false,
interpolation: {
escapeValue: false,
},
});
export default i18n;

18
src/i18n/locales/en-US.json

@ -0,0 +1,18 @@
{
"cmps.app-layout.home": "Home",
"cmps.app-layout.counter": "Counter",
"cmps.app-layout.menu": "Menu",
"cmps.app-layout.item1": "Item 1",
"cmps.app-layout.item2": "Item 2",
"cmps.app-layout.item3": "Item 3",
"cmps.home.card-title": "Welcome to Ditto",
"cmps.home.card-description": "Much evil soon high in hope do view. Out may few northward believing attempted. Yet timed being songs marry one defer men our. Although finished blessing do of. Consider speaking me prospect whatever if. Ten nearer rather hunted six parish indeed number. Allowance repulsive sex may contained can set suspected abilities cordially. Do part am he high rest that. So fruit to ready it being views match. Was justice improve age article between. No projection as up preference reasonably delightful celebrated. Preserved and abilities assurance tolerably breakfast use saw. And painted letters forming far village elderly compact. Her rest west each spot his and you knew. Estate gay wooded depart six far her. Of we be have it lose gate bred. Do separate removing or expenses in. Had covered but evident chapter matters anxious.",
"cmps.home.me-btn": "Click Me!",
"cmps.home.play": "Play with the <2>Counter</2> thing",
"cmps.home.success-text": "Success Tips",
"cmps.counter.increment": "Increment",
"cmps.counter.decrement": "Decrement",
"cmps.counter.increment-odd": "Increment Odd",
"cmps.counter.increment-async": "Increment Async",
"models.counter.increment-async.error": "Increment Async prohibited when count is 3."
}

18
src/i18n/locales/es.json

@ -0,0 +1,18 @@
{
"cmps.app-layout.home": "Casa",
"cmps.app-layout.counter": "Mostrador",
"cmps.app-layout.menu": "Menú",
"cmps.app-layout.item1": "Objeto 1",
"cmps.app-layout.item2": "Objeto 2",
"cmps.app-layout.item3": "Objeto 3",
"cmps.home.card-title": "Bienvenido a Ditto",
"cmps.home.card-description": "Mucho mal pronto pronto alto en esperanza ver. Fuera se pueden intentar algunos creyentes hacia el norte. Sin embargo, las canciones son cronometradas y se casan con un hombre diferente. Aunque terminó la bendición de hacer. Considere hablarme prospectar lo que sea si. Diez más cercanos, más bien cazados, seis parroquias de hecho número. Permiso que puede contener el sexo repulsivo puede establecer sospechas habilidades cordialmente. Haz parte, en lo alto, descansa eso. Así que las frutas para que estén listas sean vistas coinciden. Se hizo justicia entre la edad del artículo. Ninguna proyección como preferencia celebrada razonablemente encantadora celebrada. Preservado y garantía de habilidades tolerablemente el uso de desayunos. Y pintó letras formando pueblo lejano compacto de ancianos. Ella descansa al oeste de cada punto suyo y tú lo sabías. Finca gay boscosa partió seis lejos de ella. De nosotros seremos perdidos por la puerta criada. Haga retiros separados o gastos en. Había cubierto, pero el capítulo evidente importa ansioso.",
"cmps.home.me-btn": "Haz click en mi",
"cmps.home.play": "Jugar con el <2>contador</2>",
"cmps.home.success-text": "Consejos de éxito",
"cmps.counter.increment": "Incremento",
"cmps.counter.decrement": "Decremento",
"cmps.counter.increment-odd": "Incremento impar",
"cmps.counter.increment-async": "Incremento asincrónico",
"models.counter.increment-async.error": "Incremento de asíncrono prohibido cuando el conteo es 3."
}

11
src/index.less

@ -0,0 +1,11 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}

7
src/index.tsx

@ -0,0 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/App';
import './index.less';
import './i18n';
ReactDOM.render(<App />, document.getElementById('root'));

10
src/react-app-env.d.ts

@ -0,0 +1,10 @@
/// <reference types="react-scripts" />
declare module '*.module.less';
interface LocalConfig {
fallbackLng: string;
languages: {
[key: string]: string;
};
}

BIN
src/resources/logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

19
src/setupTests.js

@ -0,0 +1,19 @@
// react-testing-library renders your components to document.body,
// this will ensure they're removed after each test.
import '@testing-library/react/cleanup-after-each';
// this adds jest-dom's custom assertions
import '@testing-library/jest-dom/extend-expect';
import { configure } from '@testing-library/react';
configure({ testIdAttribute: 'data-tid' });
// FIXME Remove when we upgrade to React >= 16.9
// see https://github.com/testing-library/react-testing-library/issues/281#issuecomment-507584839
const originalConsoleError = console.error;
console.error = (...args) => {
if (/Warning.*not wrapped in act/.test(args[0])) {
return;
}
originalConsoleError(...args);
};

52
src/store/index.ts

@ -0,0 +1,52 @@
import { routerMiddleware } from 'connected-react-router';
import { createStore, createTypedHooks } from 'easy-peasy';
import { createHashHistory } from 'history';
import { createLogger } from 'redux-logger';
import { createModel, RootModel } from './models';
export const history = createHashHistory();
export const createReduxStore = () => {
// Redux store Configuration
const middleware = [];
// Skip redux logs in console during the tests
if (process.env.NODE_ENV !== 'test') {
// Logging Middleware
const logger = createLogger({
level: 'info',
collapsed: true,
});
middleware.push(logger);
}
// Router Middleware
const router = routerMiddleware(history);
middleware.push(router);
const models = createModel(history);
// create easy-peasy store
return createStore(models, {
middleware,
});
};
const store = createReduxStore();
// export hooks directly from the store to get proper type inference
const typedHooks = createTypedHooks<RootModel>();
export const { useStoreActions, useStoreDispatch, useStoreState } = typedHooks;
// enable hot reload for models
if (process.env.NODE_ENV === 'development') {
if ((module as any).hot) {
(module as any).hot.accept('./models', () => {
const models = createModel(history);
store.reconfigure(models);
});
}
}
export default store;

54
src/store/models/counter.spec.ts

@ -0,0 +1,54 @@
import counterModel from './counter';
import { createStore } from 'easy-peasy';
describe('counter model', () => {
// initialize store for type inference
let store = createStore(counterModel);
beforeEach(() => {
// reset the store before each test run
store = createStore(counterModel);
});
it('should have a valid initial state', () => {
expect(store.getState().count).toBe(0);
});
it('should increment by one', () => {
store.getActions().increment();
expect(store.getState().count).toBe(1);
});
it('should decrement by one', () => {
store.getActions().decrement();
expect(store.getState().count).toBe(-1);
});
it('should increment by two if count is odd', () => {
// initialize state with an odd number
store = createStore(counterModel, { initialState: { count: 1 } });
store.getActions().incrementIfOdd();
expect(store.getState().count).toBe(3);
});
it('should not increment by two if count is even', () => {
store.getActions().incrementIfOdd();
expect(store.getState().count).toBe(0);
});
it('should increment after a delay', async () => {
await store.getActions().incrementAsync();
expect(store.getState().count).toBe(1);
});
it('should fail to increment after a delay if count is three', async () => {
expect.assertions(1);
try {
// initialize state with count set to three
store = createStore(counterModel, { initialState: { count: 3 } });
await store.getActions().incrementAsync();
} catch (e) {
expect(e.message).toMatch('models.counter.increment-async.error');
}
});
});

48
src/store/models/counter.ts

@ -0,0 +1,48 @@
import { Action, action, Thunk, thunk } from 'easy-peasy';
import i18n from 'i18next';
import { info } from 'electron-log';
export interface CounterModel {
count: number;
increment: Action<CounterModel>;
decrement: Action<CounterModel>;
incrementIfOdd: Action<CounterModel>;
incrementAsync: Thunk<CounterModel, number | void>;
}
const counterModel: CounterModel = {
// state vars
count: 0,
// reducer actions (mutations allowed thx to immer)
increment: action(state => {
state.count++;
info(`Incremented count in redux state to ${state.count}`);
}),
decrement: action(state => {
state.count = state.count - 1;
info(`Decremented count in redux state to ${state.count}`);
}),
incrementIfOdd: action(state => {
info(`Incrementing count in redux state by 2 if "${state.count}" is odd`);
if (state.count % 2 !== 0) {
state.count += 2;
info(`Incremented count in redux state to ${state.count}`);
}
}),
incrementAsync: thunk(async (actions, payload, { getState }) => {
info(`Incremented count in redux state asynchronously`);
return new Promise((resolve, reject) => {
setTimeout(() => {
if (getState().count !== 3) {
actions.increment();
resolve();
} else {
info(`Failed to increment count because it is currently ${getState().count}`);
reject(new Error(i18n.t('models.counter.increment-async.error')));
}
}, payload || 1000);
});
}),
};
export default counterModel;

18
src/store/models/index.ts

@ -0,0 +1,18 @@
import { AnyAction } from 'redux';
import { connectRouter, RouterState } from 'connected-react-router';
import { History } from 'history';
import { reducer, Reducer } from 'easy-peasy';
import counterModel, { CounterModel } from './counter';
export interface RootModel {
router: Reducer<RouterState, AnyAction>;
counter: CounterModel;
}
export const createModel = (history: History<any>): RootModel => {
const rootModel: RootModel = {
router: reducer(connectRouter(history) as any),
counter: counterModel,
};
return rootModel;
};

40
src/theme/index.js

@ -0,0 +1,40 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { blue, red, gold } = require('@ant-design/colors');
module.exports = {
'@body-background': '#000C17',
'@component-background': '#08121C',
'@layout-header-background': '#001529',
'@layout-sider-background': '#001529',
'@text-color': '#D9D9D9',
'@text-color-secondary': '#D9D9D9',
'@heading-color': 'fade(#D9D9D9,85%)',
'@layout-body-background': '#000C17',
'@pro-header-box-shadow': '0 1px 4px 0 rgba(0,21,41,0.12)',
'@btn-default-bg': '#13222E',
'@btn-primary-bg': '#052F61',
'@border-color-split': '#172C41',
'@input-bg': '#08121C',
'@border-color-base': 'rgba(255, 255, 255, 0.25)',
'@btn-default-border': '#13222E',
'@table-header-bg': '#0A1A2B',
'@table-row-hover-bg': '#0F2239',
'@table-selected-row-bg': '#0F2239',
'@tag-default-bg': '#13222E',
'@alert-info-bg-color': '#102134',
'@highlight-color': red[7],
'@warning-color': gold[9],
'@card-actions-background': '#08121C',
'@primary-color': '#FF9500',
'@item-hover-bg': `fade(${blue[5]}, 20%)`,
'@item-active-bg': `fade(${blue[5]}, 40%)`,
'@checkbox-check-color': '#13222E',
'@disabled-color': '#404C56',
'@input-disabled-bg': '#404C56',
'@background-color-light': '#001529',
'@popover-bg': '#08121C',
};

20
tsconfig.json

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"baseUrl": "src"
},
"include": ["src"]
}

17221
yarn.lock

File diff suppressed because it is too large
Loading…
Cancel
Save