diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d4eed84 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = false +indent_style = space +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a29125a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,106 @@ +# From https://github.com/Danimoth/gitattributes/blob/master/Web.gitattributes + +# Handle line endings automatically for files detected as text +# and leave all files detected as binary untouched. +* text=auto + +# +# The above will handle all files NOT found below +# + +# +## These files are text and should be normalized (Convert crlf => lf) +# + +# source code +*.php text +*.css text +*.sass text +*.scss text +*.less text +*.styl text +*.js text eol=lf +*.coffee text +*.json text +*.htm text +*.html text +*.xml text +*.svg text +*.txt text +*.ini text +*.inc text +*.pl text +*.rb text +*.py text +*.scm text +*.sql text +*.sh text +*.bat text + +# templates +*.ejs text +*.hbt text +*.jade text +*.haml text +*.hbs text +*.dot text +*.tmpl text +*.phtml text + +# server config +.htaccess text + +# git config +.gitattributes text +.gitignore text +.gitconfig text + +# code analysis config +.jshintrc text +.jscsrc text +.jshintignore text +.csslintrc text + +# misc config +*.yaml text +*.yml text +.editorconfig text + +# build config +*.npmignore text +*.bowerrc text + +# Heroku +Procfile text +.slugignore text + +# Documentation +*.md text +LICENSE text +AUTHORS text + + +# +## These files are binary and should be left untouched +# + +# (binary is a macro for -text -diff) +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.mov binary +*.mp4 binary +*.mp3 binary +*.flv binary +*.fla binary +*.swf binary +*.gz binary +*.zip binary +*.7z binary +*.ttf binary +*.eot binary +*.woff binary +*.pyc binary +*.pdf binary diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..f5cc234 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,122 @@ +# Contributing to react-boilerplate + +Love react-boilerplate and want to help? Thanks so much, there's something to do for everybody! + +Please take a moment to review this document in order to make the contribution process easy and effective for everyone involved. + +Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project. In return, they should reciprocate that respect in addressing your issue or assessing patches and features. + +## Using the issue tracker + +The [issue tracker](https://github.com/mxstbr/react-boilerplate/issues) is +the preferred channel for [bug reports](#bugs), [features requests](#features) +and [submitting pull requests](#pull-requests). + + +## Bug reports + +A bug is a _demonstrable problem_ that is caused by the code in the repository. +Good bug reports are extremely helpful - thank you! + +Guidelines for bug reports: + +1. **Use the GitHub issue search** — check if the issue has already been reported. + +2. **Check if the issue has been fixed** — try to reproduce it using the latest `master` or development branch in the repository. + +3. **Isolate the problem** — ideally create a [reduced test case](https://css-tricks.com/reduced-test-cases/) and a live example. + +A good bug report shouldn't leave others needing to chase you up for more information. Please try to be as detailed as possible in your report. What is your environment? What steps will reproduce the issue? What browser(s) and OS +experience the problem? What would you expect to be the outcome? All these details will help people to fix any potential bugs. + +Example: + +> Short and descriptive example bug report title +> +> A summary of the issue and the browser/OS environment in which it occurs. If +> suitable, include the steps required to reproduce the bug. +> +> 1. This is the first step +> 2. This is the second step +> 3. Further steps, etc. +> +> `` - a link to the reduced test case +> +> Any other information you want to share that is relevant to the issue being +> reported. This might include the lines of code that you have identified as +> causing the bug, and potential solutions (and your opinions on their +> merits). + + + +## Feature requests + +Feature requests are welcome. But take a moment to find out whether your idea fits with the scope and aims of the project. It's up to *you* to make a strong case to convince the project's developers of the merits of this feature. Please provide as much detail and context as possible. + + + +## Pull requests + +Good pull requests - patches, improvements, new features - are a fantastic +help. They should remain focused in scope and avoid containing unrelated +commits. + +**Please ask first** before embarking on any significant pull request (e.g. +implementing features, refactoring code, porting to a different language), +otherwise you risk spending a lot of time working on something that the +project's developers might not want to merge into the project. + +Please adhere to the coding conventions used throughout a project (indentation, +accurate comments, etc.) and any other requirements (such as test coverage). + +Since the `master` branch is what people actually use in production, we have a +`dev` branch that unstable changes get merged into first. Only when we +consider that stable we merge it into the `master` branch and release the +changes for real. + +Adhering to the following process is the best way to get your work +included in the project: + +1. [Fork](https://help.github.com/articles/fork-a-repo/) the project, clone your fork, and configure the remotes: + + ```bash + # Clone your fork of the repo into the current directory + git clone https://github.com//react-boilerplate.git + # Navigate to the newly cloned directory + cd react-boilerplate + # Assign the original repo to a remote called "upstream" + git remote add upstream https://github.com/mxstbr/react-boilerplate.git + ``` + +2. If you cloned a while ago, get the latest changes from upstream: + + ```bash + git checkout dev + git pull upstream dev + ``` + +3. Create a new topic branch (off the `dev` branch) to contain your feature, change, or fix: + + ```bash + git checkout -b + ``` + +4. Commit your changes in logical chunks. Please adhere to these [git commit message guidelines](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) or your code is unlikely be merged into the main project. Use Git's [interactive rebase](https://help.github.com/articles/about-git-rebase/) feature to tidy up your commits before making them public. + +5. Locally merge (or rebase) the upstream dev branch into your topic branch: + + ```bash + git pull [--rebase] upstream dev + ``` + +6. Push your topic branch up to your fork: + + ```bash + git push origin + ``` + +7. [Open a Pull Request](https://help.github.com/articles/using-pull-requests/) + with a clear title and description. + +**IMPORTANT**: By submitting a patch, you agree to allow the project +owners to license your work under the terms of the [MIT License](https://github.com/mxstbr/react-boilerplate/blob/master/LICENSE.md). diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..2d17ae4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,30 @@ +# React Boilerplate + +Before opening a new issue, please take a moment to review our [**community guidelines**](https://github.com/mxstbr/react-boilerplate/blob/master/.github/CONTRIBUTING.md) to make the contribution process easy and effective for everyone involved. + +Please direct redux-saga related questions to stack overflow: +http://stackoverflow.com/questions/tagged/redux-saga + +For questions related to the boilerplate itself, you can also find answers on our gitter chat: +https://gitter.im/mxstbr/react-boilerplate + +**Before opening a new issue, you may find an answer in already closed issues**: +https://github.com/mxstbr/react-boilerplate/issues?q=is%3Aissue+is%3Aclosed + +## Issue Type + +- [ ] Bug (https://github.com/mxstbr/react-boilerplate/blob/master/.github/CONTRIBUTING.md#bug-reports) +- [ ] Feature (https://github.com/mxstbr/react-boilerplate/blob/master/.github/CONTRIBUTING.md#feature-requests) + +## Description + +(Add images if possible) + +## Steps to reproduce + +(Add link to a demo on https://jsfiddle.net or similar if possible) + +# Versions + +- Node/NPM: +- Browser: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..95659d8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +## React Boilerplate + +Thank you for contributing! Please take a moment to review our [**contributing guidelines**](https://github.com/mxstbr/react-boilerplate/blob/master/.github/CONTRIBUTING.md) +to make the process easy and effective for everyone involved. + +**Please open an issue** before embarking on any significant pull request, especially those that +add a new library or change existing tests, otherwise you risk spending a lot of time working +on something that might not end up being merged into the project. + +Before opening a pull request, please ensure: + +- [ ] You have followed our [**contributing guidelines**](https://github.com/mxstbr/react-boilerplate/blob/master/.github/CONTRIBUTING.md) +- [ ] Pull request has tests (we are going for 100% coverage!) +- [ ] Code is well-commented, linted and follows project conventions +- [ ] Documentation is updated (if necessary) +- [ ] Internal code generators and templates are updated (if necessary) +- [ ] Description explains the issue/use-case resolved and auto-closes related issues + +Be kind to code reviewers, please try to keep pull requests as small and focused as possible :) + +**IMPORTANT**: By submitting a patch, you agree to allow the project +owners to license your work under the terms of the [MIT License](https://github.com/mxstbr/react-boilerplate/blob/master/LICENSE.md). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d06c05 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Don't check auto-generated stuff into git +coverage +build +node_modules +stats.json + +# Cruft +.DS_Store +npm-debug.log +.idea diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e522d8e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: node_js +sudo: true +dist: trusty +node_js: + - "5.0" +script: npm run build +before_install: + - export CHROME_BIN=/usr/bin/google-chrome + - export DISPLAY=:99.0 + - sudo apt-get update + - sudo apt-get install -y libappindicator1 fonts-liberation + - wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb + - sudo dpkg -i google-chrome*.deb + - sh -e /etc/init.d/xvfb start +notifications: + email: + on_failure: change +after_success: 'npm run coveralls' diff --git a/.vagrant/machines/default/docker/action_provision b/.vagrant/machines/default/docker/action_provision new file mode 100644 index 0000000..91840cc --- /dev/null +++ b/.vagrant/machines/default/docker/action_provision @@ -0,0 +1 @@ +1.5:35250a5dd976e12dc0b834ad59b40a22462dd02a2d8d020d8315d41bbaf5f663 \ No newline at end of file diff --git a/.vagrant/machines/default/docker/creator_uid b/.vagrant/machines/default/docker/creator_uid new file mode 100644 index 0000000..4b8433a --- /dev/null +++ b/.vagrant/machines/default/docker/creator_uid @@ -0,0 +1 @@ +10208 \ No newline at end of file diff --git a/.vagrant/machines/default/docker/id b/.vagrant/machines/default/docker/id new file mode 100644 index 0000000..a4466b1 --- /dev/null +++ b/.vagrant/machines/default/docker/id @@ -0,0 +1 @@ +35250a5dd976e12dc0b834ad59b40a22462dd02a2d8d020d8315d41bbaf5f663 \ No newline at end of file diff --git a/.vagrant/machines/default/docker/index_uuid b/.vagrant/machines/default/docker/index_uuid new file mode 100644 index 0000000..633ca5a --- /dev/null +++ b/.vagrant/machines/default/docker/index_uuid @@ -0,0 +1 @@ +38fdb046ba06412995172bb776cca87e \ No newline at end of file diff --git a/.vagrant/machines/default/docker/private_key b/.vagrant/machines/default/docker/private_key new file mode 100644 index 0000000..e8501f1 --- /dev/null +++ b/.vagrant/machines/default/docker/private_key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAucV2btDL3NhCNwUzdbDwnSXQDtY5cVhnsQoH07GuwLqSj4DQ +zd66YuuQaX0xfhdYkasdQKicZ6qLZhVvocy8PgEHpgjQdhJbM0HB95EwdrUS6ZSj +4Zs+L7nc0QRdInSzPItw25C+8qcioOrkwu+ahL9TTyvFWWWFWHD8XOzi/kI7JEeJ +jzrolBoJgN5lXggwaEIGQh6xanTwV6pyNASwDctNSEFHcDgqsKo8mjrxeZD6yiAh +S0PxBcvPL8NapjVGvyHmWRdvcGU4MSijUo6wvazAIvGMqdCfv9XxRvhAbLy58ct8 +vyZcT/V+8E7F3ZCndTXKxtCGqNh+wZs1Kl/u0wIDAQABAoIBAQCTLloFtBFAOGpF +ky5RGU2ZA8NSbfF21rbYcfz/gK/WbDr/zOwhn0wGYWG54gFbR/3Y8zwq5St9ioYE +7AjUpROjAEfiCOu4EBUHiBq4HOTLt+xy+VvZu5hKUbQcOZvcV59F4agZnRVbxIVP +/qods521fKvxdtlVWXSLPIEE0n2JOPBgBqgD8rMThhx2//L3dKaphC8HtXYwVCro +/xSTrbDo3OulSL+DMptW0pa2cTCymnVaZfjCKy6BbZcF0Hxpf4GJhD0GUlBuU+0f +YzQRzZOGQfZG3NGVepT3Aq/NP6pY/RAZWMqCWupwcBBmXZaPuO+79x10wxxmXWxQ +Epst6HJBAoGBAN9rkceH9sba2BneAtgwVi300/fUR8Subc3jzDz0LpIPhPfeBafz +IfS46gn6WNRImdHPX8kBQ0ttllLFD6ezbFHQ5g4mvmmCD6NaZ5riAVkuerCwhtuU +p83u1N9JMRHQucXimFjxw9z3bWNC2pSNqOVAGt7FqON57PzTKwaDvzyzAoGBANTc +b3NWKdfEU0lACnvhd/p7iogSZfmodRsAgxJzsp2ULwe520eM+Msk44QuxWEMkFAH +XxtxTYhjkRrgjN3CSQcwC6mF4GSRAWNXXcMyiN5aDbS+X6pKxEHmlMuQ7YSK7P42 +xD8bInzD95CmDa46BZcP68aKB3iJwM/jWaYvc9VhAoGBANlw0L1350Yb4WwzdWNA +j+9EMzQlBwA1nypdxP2hzN1ce1XdYHXXnDmX6jdxzhg03HelMxzmvL7hVgcSQS0+ +43IxNGWbcYAwE9Yw+1pzEUrhgIkMFQQKBtLW2ZjCnB4xnUwpP4p5Kd2ZdX3AqAki +YblUjZI4nyldFbfuMRazDGEfAoGAFowbCh6QZBiZseKkuaaSbOf1LqC0SJO9g9S0 +DZpPyz1NFgZr4dJe8DXCG9hQdA0+pBuDyYZg7heN4Ujz4vGXhrliItzZfg2WFg3F +Es4hjVwAo6qeu40b6Nch38ZEQovsuqjWdNDNAGZJrPrJ7DCdMvkuwmMQk4YT9HFi +p6XTIUECgYEAhI3d0/l+kaIVN0XNBGB2JnP8a+XS8nJsQfCaundbCvoR38Nocrxk +KP79gt33p3OI08Zu/wb/Yf8sFgTnjcBjbUXpKwBcRK8LFwImwsxZ/FekAKnvoGPo +hX5n7ZzWJw8ak+8cEoZ7Roppj4F2os5i3qd2qvSoHKxPECVXOdD95V8= +-----END RSA PRIVATE KEY----- diff --git a/.vagrant/machines/default/docker/synced_folders b/.vagrant/machines/default/docker/synced_folders new file mode 100644 index 0000000..c097d46 --- /dev/null +++ b/.vagrant/machines/default/docker/synced_folders @@ -0,0 +1 @@ +{"docker":{"/vagrant":{"guestpath":"/vagrant","hostpath":"/home/aron/src/ss/sonos/frontend","disabled":false,"__vagrantfile":true}}} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..a02dddf --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,50 @@ +# Contributor Code of Conduct + +As contributors and maintainers of this project, and in the interest of +fostering an open and welcoming community, we pledge to respect all people who +contribute through reporting issues, posting feature requests, updating +documentation, submitting pull requests or patches, and other activities. + +We are committed to making participation in this project a harassment-free +experience for everyone, regardless of level of experience, gender, gender +identity and expression, sexual orientation, disability, personal appearance, +body size, race, ethnicity, age, religion, or nationality. + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery +* Personal attacks +* Trolling or insulting/derogatory comments +* Public or private harassment +* Publishing other's private information, such as physical or electronic + addresses, without explicit permission +* Other unethical or unprofessional conduct + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +By adopting this Code of Conduct, project maintainers commit themselves to +fairly and consistently applying these principles to every aspect of managing +this project. Project maintainers who do not follow or enforce the Code of +Conduct may be permanently removed from the project team. + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project maintainer at contact@mxstbr.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. Maintainers are +obligated to maintain confidentiality with regard to the reporter of an +incident. + + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 1.3.0, available at +[http://contributor-covenant.org/version/1/3/0/][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/3/0/ diff --git a/Changelog.md b/Changelog.md new file mode 100644 index 0000000..077c426 --- /dev/null +++ b/Changelog.md @@ -0,0 +1,75 @@ +# Changelog + +## RBP v3: The "JS Fatigue Antivenin" Edition + +React Boilerplate (RBP) v3.0.0 is out, and it's a _complete_ rewrite! :tada: + +We've focused on becoming a rock-solid foundation to start your next project +with, no matter what its scale. You get to focus on writing your app because we +focus on making that as easy as pie. + +website! + +## Highlights + +- **Scaffolding**: Thanks to @somus, you can now run `npm run generate` in your + terminal and immediately create new components, containers, sagas, routes and + selectors! No more context switching, no more "Create new file, copy and paste + that boilerplate structure, bla bla": just `npm run generate ` and go. + + Oh... and starting a project got a whole lot easier too: `npm run setup`. Done. + +- **Revamped architecture**: Following the incredible discussion in #27 (thanks + everybody for sharing your thoughts), we now have a weapons-grade, domain-driven + application architecture. + + "Smart" containers are now isolated from stateless and/or generic components, + tests are now co-located with the code that they validate. + +- **New industry-standard JS utilities** We're now making the most of... + - ImmutableJS + - reselect + - react-router-redux + - redux-saga + +- **Huge CSS Improvements** + - _[CSS Modules](docs/css/css-modules.md)_: Finally, truly modular, reusable + styles! + - _Page-specific CSS_: smart Webpack configuration means that only the CSS + your components need is served + - _Standards rock:_ Nothing beats consistent styling so we beefed up the + quality checks with **[stylelint](docs/css/stylelint.md)** to help ensure + that you and your team stay on point. + +- **Performance** + - _Code splitting_: splitting/chunking by route means the leanest, meanest + payload (because the fastest code is the code you don't load!) + - _PageSpeed Metrics_ are built right in with `npm run pagespeed` + +- **Testing setup**: Thanks to @jbinto's herculean efforts, testing is now a + first-class citizen of this boilerplate. (the example app has _99% test coverage!_) + Karma and enzyme take care of unit testing, while ngrok tunnels your local + server for access from anywhere in the world – perfect for testing on different + devices in different locations. + +- **New server setup**: Thanks to the mighty @grabbou, we now use express.js to + give users a production-ready server right out of the box. Hot reloading is + still as available as always, but adding a custom API or a non-React page to + your application is now easier than ever :smile: + +- **Cleaner layout:** We've taken no prisoners with our approach to keeping your + code the star of the show: wherever possible, the new file layout keeps the + config in the background so that you can keep your focus where it needs to be. + +- **Documentation**: Thanks to @oliverturner, this boilerplate has some of the best + documentation going. Not just clearly explained usage guides, but easy-to-follow + _removal_ guides for most features too. RBP is just a launchpad: don't want to + use a bundled feature? Get rid of it quickly and easily without having to dig + through the code. + +- **Countless small improvements**: Everything, from linting pre-commit (thanks + @okonet!) to code splitting to cross-OS compatibility is now tested and ready + to go: + + - We finally added a **[CoC](CODE_OF_CONDUCT.md)** + - Windows compatibility has improved massively diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..39cb81e --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Maximilian Stoiber + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..90d55e0 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +react boilerplate banner + +
+ +
Start your next react project in seconds
+
A highly scalable, offline-first foundation with the best DX and a focus on performance and best practices
+ +
+ +
+ + + Dependency Status + + + + devDependency Status + + + + Build Status + + + + Test Coverage + +
+ + +
+ +
+ Made with ❤︎ by Max Stoiber and contributors. If you're using this boilerplate, we'd love to hear from you! +
+ +## Features + +
+
Quick scaffolding
+
Create components, containers, routes, selectors and sagas - and their tests - right from the CLI!
+ +
Instant feedback
+
Enjoy the best DX (Developer eXperience) and code your app at the speed of thought! Your saved changes to the CSS and JS are reflected instantaneously without refreshing the page. Preserve application state even when you update something in the underlying code!
+ +
Predictable state management
+
Unidirectional data flow allows for change logging and time travel debugging.
+ +
Next generation JavaScript
+
Use template strings, object destructuring, arrow functions, JSX syntax and more, today.
+ +
Next generation CSS
+
Write composable CSS that's co-located with your components for complete modularity. Unique generated class names keep the specificity low while eliminating style clashes. Ship only the styles that are on the page for the best performance.
+ +
Industry-standard routing
+
It's natural to want to add pages (e.g. `/about`) to your application, and routing makes this possible.
+ +
Industry-standard i18n internationalization support
+
Scalable apps need to support multiple languages, easily add and support multiple languages with `react-intl`.
+ +
Offline-first
+
The next frontier in performant web apps: availability without a network connection from the instant your users load the app.
+ +
SEO
+
We support SEO (document head tags management) for search engines that support indexing of JavaScript content. (eg. Google)
+
+ +But wait... there's more! + + - *The best test setup:* Automatically guarantee code quality and non-breaking + changes. (Seen a react app with 99% test coverage before?) + - *Native web app:* Your app's new home? The home screen of your users' phones. + - *The fastest fonts:* Say goodbye to vacant text. + - *Stay fast*: Profile your app's performance from the comfort of your command + line! + - *Catch problems:* AppVeyor and TravisCI setups included by default, so your + tests get run automatically on Windows and Unix. + +There’s also a fantastic video on how to structure your React.js apps with scalability in mind. It provides rationale for the majority of boilerplate's design decisions. + +Keywords: React.js, Redux, Hot Reloading, ESNext, Babel, PostCSS, Autoprefixer, react-router, Offline First, ServiceWorker, CSS Modules, redux-saga, FontFaceObserver, PageSpeed Insights + +## Quick start + +1. Clone this repo using `git clone --depth=1 https://github.com/mxstbr/react-boilerplate.git` +1. Run `npm run setup` to install dependencies and clean the git repo.
+ *At this point you can run `npm start` to see the example app at `http://localhost:3000`.* +1. Run `npm run clean` to delete the example app. + +Now you're ready to rumble! + +> Please note that this boilerplate is **not meant for beginners**! If you're just starting out with react or redux, please refer to https://github.com/petehunt/react-howto instead. + +## Documentation + +- [Intro](docs/general): What's included and why +- [**Commands**](docs/general/commands.md): Getting the most out of this boilerplate +- [Testing](docs/testing): How to work with the built-in test harness +- [Styling](docs/css): How to work with the CSS tooling +- [Your app](docs/js): Supercharging your app with Routing, Redux, simple + asynchronicity helpers, etc. + +## Supporters + +This project would not be possible without the support by these amazing folks. [**Become a sponsor**](https://opencollective.com/react-boilerplate) to get your company in front of thousands of engaged react developers and help us out! + +
+ + React.js Program – A linear approach to learning the React.js ecosystem! + +
+ +---- + + + + + + + + + + + + +---- + + + + + + + + + + + + + +## License + +This project is licensed under the MIT license, Copyright (c) 2016 Maximilian +Stoiber. For more information see `LICENSE.md`. diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..caef2f9 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,13 @@ +# Load .env.bash -- backticks actually run /bin/sh so run bash inside. +if File.exist?('.env.bash') + ENV.replace(eval `bash -ac "source .env.bash >/dev/null; ruby -e 'p ENV'"`) +end + +Vagrant.configure("2") do |config| + config.vm.provider :docker do |docker, override| + docker.image = ENV.fetch("VAGRANT_DOCKER_IMAGE", "jesselang/debian-vagrant:jessie") + docker.has_ssh = true + end + config.ssh.forward_agent = true # for github in VM + config.vm.provision :shell, path: "provision.bash" +end diff --git a/app/.htaccess b/app/.htaccess new file mode 100644 index 0000000..5204b4b --- /dev/null +++ b/app/.htaccess @@ -0,0 +1,44 @@ + + + + ####################################################################### + # GENERAL # + ####################################################################### + + # Make apache follow sym links to files + Options +FollowSymLinks + # If somebody opens a folder, hide all files from the resulting folder list + IndexIgnore */* + + + ####################################################################### + # REWRITING # + ####################################################################### + + # Enable rewriting + RewriteEngine On + + # If its not HTTPS + RewriteCond %{HTTPS} off + + # Comment out the RewriteCond above, and uncomment the RewriteCond below if you're using a load balancer (e.g. CloudFlare) for SSL + # RewriteCond %{HTTP:X-Forwarded-Proto} !https + + # Redirect to the same URL with https://, ignoring all further rules if this one is in effect + RewriteRule ^(.*) https://%{HTTP_HOST}/$1 [R,L] + + # If we get to here, it means we are on https:// + + # If the file with the specified name in the browser doesn't exist + RewriteCond %{REQUEST_FILENAME} !-f + + # and the directory with the specified name in the browser doesn't exist + RewriteCond %{REQUEST_FILENAME} !-d + + # and we are not opening the root already (otherwise we get a redirect loop) + RewriteCond %{REQUEST_FILENAME} !\/$ + + # Rewrite all requests to the root + RewriteRule ^(.*) / + + diff --git a/app/.nginx.conf b/app/.nginx.conf new file mode 100644 index 0000000..cf6b6ca --- /dev/null +++ b/app/.nginx.conf @@ -0,0 +1,66 @@ +## +# Put this file in /etc/nginx/conf.d folder and make sure +# you have line 'include /etc/nginx/conf.d/*.conf;' +# in your main nginx configuration file +## + +## +# Redirect to the same URL with https:// +## + +server { + + listen 80; + +# Type your domain name below + server_name example.com; + + return 301 https://$server_name$request_uri; + +} + +## +# HTTPS configurations +## + +server { + + listen 443; + +# Type your domain name below + server_name example.com; + + ssl on; + ssl_certificate /path/to/certificate.crt; + ssl_certificate_key /path/to/server.key; + +# Use only TSL protocols for more secure + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + +# Always serve index.html for any request + location / { + # Set path + root /var/www/; + try_files $uri /index.html; + } + +## +# If you want to use Node/Rails/etc. API server +# on the same port (443) config Nginx as a reverse proxy. +# For security reasons use a firewall like ufw in Ubuntu +# and deny port 3000/tcp. +## + +# location /api/ { +# +# proxy_pass http://localhost:3000; +# proxy_http_version 1.1; +# proxy_set_header X-Forwarded-Proto https; +# proxy_set_header Upgrade $http_upgrade; +# proxy_set_header Connection 'upgrade'; +# proxy_set_header Host $host; +# proxy_cache_bypass $http_upgrade; +# +# } + +} diff --git a/app/app.js b/app/app.js new file mode 100644 index 0000000..13b72ca --- /dev/null +++ b/app/app.js @@ -0,0 +1,127 @@ +/** + * app.js + * + * This is the entry file for the application, only setup and boilerplate + * code. + */ + +// Needed for redux-saga es6 generator support +import 'babel-polyfill'; + +/* eslint-disable import/no-unresolved */ +// Load the favicon, the manifest.json file and the .htaccess file +import 'file?name=[name].[ext]!./favicon.ico'; +import '!file?name=[name].[ext]!./manifest.json'; +import 'file?name=[name].[ext]!./.htaccess'; +/* eslint-enable import/no-unresolved */ + +// Import all the third party stuff +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import { applyRouterMiddleware, Router, browserHistory } from 'react-router'; +import { syncHistoryWithStore } from 'react-router-redux'; +import FontFaceObserver from 'fontfaceobserver'; +import { useScroll } from 'react-router-scroll'; +import configureStore from './store'; + +// Import Language Provider +import LanguageProvider from 'containers/LanguageProvider'; + +// Observe loading of Open Sans (to remove open sans, remove the tag in +// the index.html file and this observer) +import styles from 'containers/App/styles.css'; +const openSansObserver = new FontFaceObserver('Open Sans', {}); + +// When Open Sans is loaded, add a font-family using Open Sans to the body +openSansObserver.load().then(() => { + document.body.classList.add(styles.fontLoaded); +}, () => { + document.body.classList.remove(styles.fontLoaded); +}); + +// Import i18n messages +import { translationMessages } from './i18n'; + +// Create redux store with history +// this uses the singleton browserHistory provided by react-router +// Optionally, this could be changed to leverage a created history +// e.g. `const browserHistory = useRouterHistory(createBrowserHistory)();` +const initialState = {}; +const store = configureStore(initialState, browserHistory); + +// If you use Redux devTools extension, since v2.0.1, they added an +// `updateStore`, so any enhancers that change the store object +// could be used with the devTools' store. +// As this boilerplate uses Redux & Redux-Saga, the `updateStore` is needed +// if you want to `take` actions in your Sagas, dispatched from devTools. +if (window.devToolsExtension) { + window.devToolsExtension.updateStore(store); +} + +// Sync history and store, as the react-router-redux reducer +// is under the non-default key ("routing"), selectLocationState +// must be provided for resolving how to retrieve the "route" in the state +import { selectLocationState } from 'containers/App/selectors'; +const history = syncHistoryWithStore(browserHistory, store, { + selectLocationState: selectLocationState(), +}); + +// Set up the router, wrapping all Routes in the App component +import App from 'containers/App'; +import createRoutes from './routes'; +const rootRoute = { + component: App, + childRoutes: createRoutes(store), +}; + +const render = (messages) => { + ReactDOM.render( + + + + + , + document.getElementById('app') + ); +}; + +// Hot reloadable translation json files +if (module.hot) { + // modules.hot.accept does not accept dynamic dependencies, + // have to be constants at compile-time + module.hot.accept('./i18n', () => { + render(translationMessages); + }); +} + +// Chunked polyfill for browsers without Intl support +if (!window.Intl) { + (new Promise((resolve) => { + resolve(System.import('intl')); + })) + .then(() => Promise.all([ + System.import('intl/locale-data/jsonp/en.js'), + System.import('intl/locale-data/jsonp/de.js'), + ])) + .then(() => render(translationMessages)) + .catch((err) => { + throw err; + }); +} else { + render(translationMessages); +} + +// Install ServiceWorker and AppCache in the end since +// it's not most important operation and if main code fails, +// we do not want it installed +import { install } from 'offline-plugin/runtime'; +install(); diff --git a/app/components/A/index.js b/app/components/A/index.js new file mode 100644 index 0000000..62634e9 --- /dev/null +++ b/app/components/A/index.js @@ -0,0 +1,30 @@ +/** + * A link to a certain page, an anchor tag + */ + +import React, { PropTypes } from 'react'; + +import styles from './styles.css'; + +function A({ ...props }) { + const { className, children, href, target, ...rest } = props; + return ( + + { children } + + ); +} + +A.propTypes = { + className: PropTypes.string, + href: PropTypes.string.isRequired, + target: PropTypes.string, + children: PropTypes.node.isRequired, +}; + +export default A; diff --git a/app/components/A/styles.css b/app/components/A/styles.css new file mode 100644 index 0000000..f69b7bc --- /dev/null +++ b/app/components/A/styles.css @@ -0,0 +1,7 @@ +.link { + color: #41ADDD; +} + +.link:hover { + color: #6CC0E5; +} diff --git a/app/components/A/tests/index.test.js b/app/components/A/tests/index.test.js new file mode 100644 index 0000000..e8161e0 --- /dev/null +++ b/app/components/A/tests/index.test.js @@ -0,0 +1,52 @@ +/** + * Testing our link component + */ + +import A from '../index'; + +import expect from 'expect'; +import { shallow } from 'enzyme'; +import React from 'react'; + +const href = 'http://mxstbr.com/'; +const children = (

Test

); +const renderComponent = (props = {}) => shallow( + + {children} + +); + +describe('', () => { + it('should render an tag', () => { + const renderedComponent = renderComponent(); + expect(renderedComponent.type()).toEqual('a'); + }); + + it('should have an href attribute', () => { + const renderedComponent = renderComponent(); + expect(renderedComponent.prop('href')).toEqual(href); + }); + + it('should have children', () => { + const renderedComponent = renderComponent(); + expect(renderedComponent.contains(children)).toEqual(true); + }); + + it('should adopt a className attribute', () => { + const className = 'test'; + const renderedComponent = renderComponent({ className }); + expect(renderedComponent.find('a').hasClass(className)).toEqual(true); + }); + + it('should adopt a target attribute', () => { + const target = '_blank'; + const renderedComponent = renderComponent({ target }); + expect(renderedComponent.prop('target')).toEqual(target); + }); + + it('should adopt a type attribute', () => { + const type = 'text/html'; + const renderedComponent = renderComponent({ type }); + expect(renderedComponent.prop('type')).toEqual(type); + }); +}); diff --git a/app/components/Button/index.js b/app/components/Button/index.js new file mode 100644 index 0000000..869d61a --- /dev/null +++ b/app/components/Button/index.js @@ -0,0 +1,47 @@ +/** + * + * Button.react.js + * + * A common button, if you pass it a prop "route" it'll render a link to a react-router route + * otherwise it'll render a link with an onclick + */ + +import React, { PropTypes, Children } from 'react'; + +import styles from './styles.css'; + +function Button(props) { + const className = props.className ? props.className : styles.button; + + // Render an anchor tag + let button = ( + + {Children.toArray(props.children)} + + ); + + // If the Button has a handleRoute prop, we want to render a button + if (props.handleRoute) { + button = ( + + ); + } + + return ( +
+ {button} +
+ ); +} + +Button.propTypes = { + className: PropTypes.string, + handleRoute: PropTypes.func, + href: PropTypes.string, + onClick: PropTypes.func, + children: PropTypes.node.isRequired, +}; + +export default Button; diff --git a/app/components/Button/styles.css b/app/components/Button/styles.css new file mode 100644 index 0000000..15f59fe --- /dev/null +++ b/app/components/Button/styles.css @@ -0,0 +1,29 @@ +.buttonWrapper { + width: 100%; + text-align: center; + margin: 4em 0; +} + +.button { + display: inline-block; + box-sizing: border-box; + padding: 0.25em 2em; + margin: 0; + text-decoration: none; + border-radius: 4px; + -webkit-font-smoothing: antialiased; + -webkit-touch-callout: none; + user-select: none; + cursor: pointer; + outline: 0; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: bold; + font-size: 16px; + border: 2px solid #41ADDD; + color: #41ADDD; +} + +.button:active { + background: #41ADDD; + color: #FFF; +} diff --git a/app/components/Button/tests/index.test.js b/app/components/Button/tests/index.test.js new file mode 100644 index 0000000..be6fd3a --- /dev/null +++ b/app/components/Button/tests/index.test.js @@ -0,0 +1,60 @@ +/** + * Testing our Button component + */ + +import Button from '../index'; + +import expect from 'expect'; +import { shallow } from 'enzyme'; +import React from 'react'; + +const handleRoute = () => {}; +const href = 'http://mxstbr.com'; +const children = (

Test

); +const renderComponent = (props = {}) => shallow( + +); + +describe(' + + ); + } +} + +FeaturePage.propTypes = { + dispatch: React.PropTypes.func, +}; + +export default connect()(FeaturePage); diff --git a/app/containers/FeaturePage/messages.js b/app/containers/FeaturePage/messages.js new file mode 100644 index 0000000..96ffec9 --- /dev/null +++ b/app/containers/FeaturePage/messages.js @@ -0,0 +1,99 @@ +/* + * FeaturePage Messages + * + * This contains all the text for the FeaturePage component. + */ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + header: { + id: 'boilerplate.containers.FeaturePage.header', + defaultMessage: 'Features', + }, + scaffoldingHeader: { + id: 'boilerplate.containers.FeaturePage.scaffolding.header', + defaultMessage: 'Quick scaffolding', + }, + scaffoldingMessage: { + id: 'boilerplate.containers.FeaturePage.scaffolding.message', + defaultMessage: `Automate the creation of components, containers, routes, selectors + and sagas - and their tests - right from the CLI!`, + }, + feedbackHeader: { + id: 'boilerplate.containers.FeaturePage.feedback.header', + defaultMessage: 'Instant feedback', + }, + feedbackMessage: { + id: 'boilerplate.containers.FeaturePage.feedback.message', + defaultMessage: ` + Enjoy the best DX and code your app at the speed of thought! Your + saved changes to the CSS and JS are reflected instantaneously + without refreshing the page. Preserve application state even when + you update something in the underlying code! + `, + }, + stateManagementHeader: { + id: 'boilerplate.containers.FeaturePage.state_management.header', + defaultMessage: 'Predictable state management', + }, + stateManagementMessages: { + id: 'boilerplate.containers.FeaturePage.state_management.message', + defaultMessage: ` + Unidirectional data flow allows for change logging and time travel + debugging. + `, + }, + javascriptHeader: { + id: 'boilerplate.containers.FeaturePage.javascript.header', + defaultMessage: 'Next generation JavaScript', + }, + javascriptMessage: { + id: 'boilerplate.containers.FeaturePage.javascript.message', + defaultMessage: `Use template strings, object destructuring, arrow functions, JSX + syntax and more, today.`, + }, + cssHeader: { + id: 'boilerplate.containers.FeaturePage.css.header', + defaultMessage: 'Features', + }, + cssMessage: { + id: 'boilerplate.containers.FeaturePage.css.message', + defaultMessage: 'Next generation CSS', + }, + routingHeader: { + id: 'boilerplate.containers.FeaturePage.routing.header', + defaultMessage: 'Industry-standard routing', + }, + routingMessage: { + id: 'boilerplate.containers.FeaturePage.routing.message', + defaultMessage: ` + Write composable CSS that's co-located with your components for + complete modularity. Unique generated class names keep the + specificity low while eliminating style clashes. Ship only the + styles that are on the page for the best performance. + `, + }, + networkHeader: { + id: 'boilerplate.containers.FeaturePage.network.header', + defaultMessage: 'Offline-first', + }, + networkMessage: { + id: 'boilerplate.containers.FeaturePage.network.message', + defaultMessage: ` + The next frontier in performant web apps: availability without a + network connection from the instant your users load the app. + `, + }, + intlHeader: { + id: 'boilerplate.containers.FeaturePage.internationalization.header', + defaultMessage: 'Complete i18n Standard Internationalization & Pluralization', + }, + intlMessage: { + id: 'boilerplate.containers.FeaturePage.internationalization.message', + defaultMessage: 'Scalable apps need to support multiple languages, easily add and support multiple languages with `react-intl`.', + }, + homeButton: { + id: 'boilerplate.containers.FeaturePage.home', + defaultMessage: 'Home', + }, +}); diff --git a/app/containers/FeaturePage/styles.css b/app/containers/FeaturePage/styles.css new file mode 100644 index 0000000..e291798 --- /dev/null +++ b/app/containers/FeaturePage/styles.css @@ -0,0 +1,12 @@ +.list { + font-family: Georgia, Times, 'Times New Roman', serif; + padding-left: 1.75em; +} + +.listItem { + margin: 1em 0; +} + +.listItemTitle { + font-weight: bold; +} diff --git a/app/containers/FeaturePage/tests/index.test.js b/app/containers/FeaturePage/tests/index.test.js new file mode 100644 index 0000000..49fca03 --- /dev/null +++ b/app/containers/FeaturePage/tests/index.test.js @@ -0,0 +1,36 @@ +import expect from 'expect'; +import { shallow } from 'enzyme'; +import React from 'react'; + +import Button from 'components/Button'; +import { FormattedMessage } from 'react-intl'; +import messages from '../messages'; +import { FeaturePage } from '../index'; +import H1 from 'components/H1'; + +describe('', () => { + it('should render its heading', () => { + const renderedComponent = shallow( + + ); + expect(renderedComponent.contains( +

+ +

+ )).toEqual(true); + }); + + it('should link to "/"', (done) => { + // Spy on the openRoute method of the FeaturePage + const dispatch = (action) => { + expect(action.payload.args).toEqual('/'); + done(); + }; + + const renderedComponent = shallow( + + ); + const button = renderedComponent.find(Button); + button.prop('handleRoute')(); + }); +}); diff --git a/app/containers/HomePage/actions.js b/app/containers/HomePage/actions.js new file mode 100644 index 0000000..7706bbb --- /dev/null +++ b/app/containers/HomePage/actions.js @@ -0,0 +1,34 @@ +/* + * Home Actions + * + * Actions change things in your application + * Since this boilerplate uses a uni-directional data flow, specifically redux, + * we have these actions which are the only way your application interacts with + * your application state. This guarantees that your state is up to date and nobody + * messes it up weirdly somewhere. + * + * To add a new Action: + * 1) Import your constant + * 2) Add a function like this: + * export function yourAction(var) { + * return { type: YOUR_ACTION_CONSTANT, var: var } + * } + */ + +import { + CHANGE_USERNAME, +} from './constants'; + +/** + * Changes the input field of the form + * + * @param {name} name The new text of the input field + * + * @return {object} An action object with a type of CHANGE_USERNAME + */ +export function changeUsername(name) { + return { + type: CHANGE_USERNAME, + name, + }; +} diff --git a/app/containers/HomePage/constants.js b/app/containers/HomePage/constants.js new file mode 100644 index 0000000..640f918 --- /dev/null +++ b/app/containers/HomePage/constants.js @@ -0,0 +1,12 @@ +/* + * HomeConstants + * Each action has a corresponding type, which the reducer knows and picks up on. + * To avoid weird typos between the reducer and the actions, we save them as + * constants here. We prefix them with 'yourproject/YourComponent' so we avoid + * reducers accidentally picking up actions they shouldn't. + * + * Follow this format: + * export const YOUR_ACTION_CONSTANT = 'yourproject/YourContainer/YOUR_ACTION_CONSTANT'; + */ + +export const CHANGE_USERNAME = 'boilerplate/Home/CHANGE_USERNAME'; diff --git a/app/containers/HomePage/index.js b/app/containers/HomePage/index.js new file mode 100644 index 0000000..835fcdf --- /dev/null +++ b/app/containers/HomePage/index.js @@ -0,0 +1,167 @@ +/* + * HomePage + * + * This is the first thing users see of our App, at the '/' route + */ + +import React from 'react'; +import { connect } from 'react-redux'; +import { push } from 'react-router-redux'; +import Helmet from 'react-helmet'; + +import messages from './messages'; +import { createStructuredSelector } from 'reselect'; + +import { + selectRepos, + selectLoading, + selectError, +} from 'containers/App/selectors'; + +import { + selectUsername, +} from './selectors'; + +import { changeUsername } from './actions'; +import { loadRepos } from '../App/actions'; + +import { FormattedMessage } from 'react-intl'; +import RepoListItem from 'containers/RepoListItem'; +import Button from 'components/Button'; +import H2 from 'components/H2'; +import List from 'components/List'; +import ListItem from 'components/ListItem'; +import LoadingIndicator from 'components/LoadingIndicator'; + +import styles from './styles.css'; + +export class HomePage extends React.Component { + /** + * when initial state username is not null, submit the form to load repos + */ + componentDidMount() { + if (this.props.username && this.props.username.trim().length > 0) { + this.props.onSubmitForm(); + } + } + /** + * Changes the route + * + * @param {string} route The route we want to go to + */ + openRoute = (route) => { + this.props.changeRoute(route); + }; + + /** + * Changed route to '/features' + */ + openFeaturesPage = () => { + this.openRoute('/features'); + }; + + render() { + let mainContent = null; + + // Show a loading indicator when we're loading + if (this.props.loading) { + mainContent = (); + + // Show an error if there is one + } else if (this.props.error !== false) { + const ErrorComponent = () => ( + + ); + mainContent = (); + + // If we're not loading, don't have an error and there are repos, show the repos + } else if (this.props.repos !== false) { + mainContent = (); + } + + return ( +
+ +
+
+

+ +

+

+ +

+
+
+

+ +

+
+ +
+ {mainContent} +
+ +
+
+ ); + } +} + +HomePage.propTypes = { + changeRoute: React.PropTypes.func, + loading: React.PropTypes.bool, + error: React.PropTypes.oneOfType([ + React.PropTypes.object, + React.PropTypes.bool, + ]), + repos: React.PropTypes.oneOfType([ + React.PropTypes.array, + React.PropTypes.bool, + ]), + onSubmitForm: React.PropTypes.func, + username: React.PropTypes.string, + onChangeUsername: React.PropTypes.func, +}; + +export function mapDispatchToProps(dispatch) { + return { + onChangeUsername: (evt) => dispatch(changeUsername(evt.target.value)), + changeRoute: (url) => dispatch(push(url)), + onSubmitForm: (evt) => { + if (evt !== undefined && evt.preventDefault) evt.preventDefault(); + dispatch(loadRepos()); + }, + + dispatch, + }; +} + +const mapStateToProps = createStructuredSelector({ + repos: selectRepos(), + username: selectUsername(), + loading: selectLoading(), + error: selectError(), +}); + +// Wrap the component to inject dispatch and state into it +export default connect(mapStateToProps, mapDispatchToProps)(HomePage); diff --git a/app/containers/HomePage/messages.js b/app/containers/HomePage/messages.js new file mode 100644 index 0000000..f2113a7 --- /dev/null +++ b/app/containers/HomePage/messages.js @@ -0,0 +1,33 @@ +/* + * HomePage Messages + * + * This contains all the text for the HomePage component. + */ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + startProjectHeader: { + id: 'boilerplate.containers.HomePage.start_project.header', + defaultMessage: 'Start your next react project in seconds', + }, + startProjectMessage: { + id: 'boilerplate.containers.HomePage.start_project.message', + defaultMessage: 'A highly scalable, offline-first foundation with the best DX and a focus on performance and best practices', + }, + trymeHeader: { + id: 'boilerplate.containers.HomePage.tryme.header', + defaultMessage: 'Try me!', + }, + trymeMessage: { + id: 'boilerplate.containers.HomePage.tryme.message', + defaultMessage: 'Show Github repositories by', + }, + trymeAtPrefix: { + id: 'boilerplate.containers.HomePage.tryme.atPrefix', + defaultMessage: '@', + }, + featuresButton: { + id: 'boilerplate.containers.HomePage.features.Button', + defaultMessage: 'Features', + }, +}); diff --git a/app/containers/HomePage/reducer.js b/app/containers/HomePage/reducer.js new file mode 100644 index 0000000..0ac28b8 --- /dev/null +++ b/app/containers/HomePage/reducer.js @@ -0,0 +1,35 @@ +/* + * HomeReducer + * + * The reducer takes care of our data. Using actions, we can change our + * application state. + * To add a new action, add it to the switch statement in the reducer function + * + * Example: + * case YOUR_ACTION_CONSTANT: + * return state.set('yourStateVariable', true); + */ + +import { + CHANGE_USERNAME, +} from './constants'; +import { fromJS } from 'immutable'; + +// The initial state of the App +const initialState = fromJS({ + username: '', +}); + +function homeReducer(state = initialState, action) { + switch (action.type) { + case CHANGE_USERNAME: + + // Delete prefixed '@' from the github username + return state + .set('username', action.name.replace(/@/gi, '')); + default: + return state; + } +} + +export default homeReducer; diff --git a/app/containers/HomePage/sagas.js b/app/containers/HomePage/sagas.js new file mode 100644 index 0000000..6f955ac --- /dev/null +++ b/app/containers/HomePage/sagas.js @@ -0,0 +1,55 @@ +/** + * Gets the repositories of the user from Github + */ + +import { take, call, put, select, fork, cancel } from 'redux-saga/effects'; +import { LOCATION_CHANGE } from 'react-router-redux'; +import { LOAD_REPOS } from 'containers/App/constants'; +import { reposLoaded, repoLoadingError } from 'containers/App/actions'; + +import request from 'utils/request'; +import { selectUsername } from 'containers/HomePage/selectors'; + +/** + * Github repos request/response handler + */ +export function* getRepos() { + // Select username from store + const username = yield select(selectUsername()); + const requestURL = `https://api.github.com/users/${username}/repos?type=all&sort=updated`; + + // Call our request helper (see 'utils/request') + const repos = yield call(request, requestURL); + + if (!repos.err) { + yield put(reposLoaded(repos.data, username)); + } else { + yield put(repoLoadingError(repos.err)); + } +} + +/** + * Watches for LOAD_REPOS action and calls handler + */ +export function* getReposWatcher() { + while (yield take(LOAD_REPOS)) { + yield call(getRepos); + } +} + +/** + * Root saga manages watcher lifecycle + */ +export function* githubData() { + // Fork watcher so we can continue execution + const watcher = yield fork(getReposWatcher); + + // Suspend execution until location changes + yield take(LOCATION_CHANGE); + yield cancel(watcher); +} + +// Bootstrap sagas +export default [ + githubData, +]; diff --git a/app/containers/HomePage/selectors.js b/app/containers/HomePage/selectors.js new file mode 100644 index 0000000..fdad1c6 --- /dev/null +++ b/app/containers/HomePage/selectors.js @@ -0,0 +1,17 @@ +/** + * Homepage selectors + */ + +import { createSelector } from 'reselect'; + +const selectHome = () => (state) => state.get('home'); + +const selectUsername = () => createSelector( + selectHome(), + (homeState) => homeState.get('username') +); + +export { + selectHome, + selectUsername, +}; diff --git a/app/containers/HomePage/styles.css b/app/containers/HomePage/styles.css new file mode 100644 index 0000000..258f155 --- /dev/null +++ b/app/containers/HomePage/styles.css @@ -0,0 +1,35 @@ +.textSection { + margin: 3em auto; +} + +.textSection:first-child { + margin-top: 0; +} + +.centered { + text-align: center; +} + +p, +label { + font-family: Georgia, Times, 'Times New Roman', serif; + line-height: 1.5em; +} + +.link { + text-decoration: none; +} + +.usernameForm { + margin-bottom: 1em; +} + +.input { + outline: none; + border-bottom: 1px dotted #999; +} + +.atPrefix { + color: black; + margin-left: 0.4em; +} diff --git a/app/containers/HomePage/tests/actions.test.js b/app/containers/HomePage/tests/actions.test.js new file mode 100644 index 0000000..f04baa6 --- /dev/null +++ b/app/containers/HomePage/tests/actions.test.js @@ -0,0 +1,23 @@ +import expect from 'expect'; + +import { + CHANGE_USERNAME, +} from '../constants'; + +import { + changeUsername, +} from '../actions'; + +describe('Home Actions', () => { + describe('changeUsername', () => { + it('should return the correct type and the passed name', () => { + const fixture = 'Max'; + const expectedResult = { + type: CHANGE_USERNAME, + name: fixture, + }; + + expect(changeUsername(fixture)).toEqual(expectedResult); + }); + }); +}); diff --git a/app/containers/HomePage/tests/index.test.js b/app/containers/HomePage/tests/index.test.js new file mode 100644 index 0000000..c189a9d --- /dev/null +++ b/app/containers/HomePage/tests/index.test.js @@ -0,0 +1,152 @@ +/** + * Test the HomePage + */ + +import expect from 'expect'; +import { shallow, mount } from 'enzyme'; +import React from 'react'; + +import { IntlProvider } from 'react-intl'; +import { HomePage, mapDispatchToProps } from '../index'; +import { changeUsername } from '../actions'; +import { loadRepos } from '../../App/actions'; +import { push } from 'react-router-redux'; +import RepoListItem from 'containers/RepoListItem'; +import List from 'components/List'; +import LoadingIndicator from 'components/LoadingIndicator'; + +describe('', () => { + it('should render the loading indicator when its loading', () => { + const renderedComponent = shallow( + + ); + expect(renderedComponent.contains()).toEqual(true); + }); + + it('should render an error if loading failed', () => { + const renderedComponent = mount( + + + + ); + expect( + renderedComponent + .text() + .indexOf('Something went wrong, please try again!') + ).toBeGreaterThan(-1); + }); + + it('should render fetch the repos on mount if a username exists', () => { + const submitSpy = expect.createSpy(); + mount( + + {}} + onSubmitForm={submitSpy} + /> + + ); + expect(submitSpy).toHaveBeenCalled(); + }); + + it('should render the repositories if loading was successful', () => { + const repos = [{ + owner: { + login: 'mxstbr', + }, + html_url: 'https://github.com/mxstbr/react-boilerplate', + name: 'react-boilerplate', + open_issues_count: 20, + full_name: 'mxstbr/react-boilerplate', + }]; + const renderedComponent = shallow( + + ); + + expect(renderedComponent.contains()).toEqual(true); + }); + + it('should link to /features', () => { + const openRouteSpy = expect.createSpy(); + + // Spy on the openRoute method of the HomePage + const openRoute = (dest) => { + if (dest === '/features') { + openRouteSpy(); + } + }; + + const renderedComponent = mount( + + + + ); + const button = renderedComponent.find('button'); + button.simulate('click'); + expect(openRouteSpy).toHaveBeenCalled(); + }); + + describe('mapDispatchToProps', () => { + describe('onChangeUsername', () => { + it('should be injected', () => { + const dispatch = expect.createSpy(); + const result = mapDispatchToProps(dispatch); + expect(result.onChangeUsername).toExist(); + }); + + it('should dispatch changeUsername when called', () => { + const dispatch = expect.createSpy(); + const result = mapDispatchToProps(dispatch); + const username = 'mxstbr'; + result.onChangeUsername({ target: { value: username } }); + expect(dispatch).toHaveBeenCalledWith(changeUsername(username)); + }); + }); + }); + + describe('changeRoute', () => { + it('should be injected', () => { + const dispatch = expect.createSpy(); + const result = mapDispatchToProps(dispatch); + expect(result.changeRoute).toExist(); + }); + + it('should dispatch push when called', () => { + const dispatch = expect.createSpy(); + const result = mapDispatchToProps(dispatch); + const route = '/'; + result.changeRoute(route); + expect(dispatch).toHaveBeenCalledWith(push(route)); + }); + }); + + describe('onSubmitForm', () => { + it('should be injected', () => { + const dispatch = expect.createSpy(); + const result = mapDispatchToProps(dispatch); + expect(result.onSubmitForm).toExist(); + }); + + it('should dispatch loadRepos when called', () => { + const dispatch = expect.createSpy(); + const result = mapDispatchToProps(dispatch); + result.onSubmitForm(); + expect(dispatch).toHaveBeenCalledWith(loadRepos()); + }); + + it('should preventDefault if called with event', () => { + const preventDefault = expect.createSpy(); + const result = mapDispatchToProps(() => {}); + const evt = { preventDefault }; + result.onSubmitForm(evt); + expect(preventDefault).toHaveBeenCalledWith(); + }); + }); +}); diff --git a/app/containers/HomePage/tests/reducer.test.js b/app/containers/HomePage/tests/reducer.test.js new file mode 100644 index 0000000..c0f38dd --- /dev/null +++ b/app/containers/HomePage/tests/reducer.test.js @@ -0,0 +1,27 @@ +import expect from 'expect'; +import homeReducer from '../reducer'; +import { + changeUsername, +} from '../actions'; +import { fromJS } from 'immutable'; + +describe('homeReducer', () => { + let state; + beforeEach(() => { + state = fromJS({ + username: '', + }); + }); + + it('should return the initial state', () => { + const expectedResult = state; + expect(homeReducer(undefined, {})).toEqual(expectedResult); + }); + + it('should handle the changeUsername action correctly', () => { + const fixture = 'mxstbr'; + const expectedResult = state.set('username', fixture); + + expect(homeReducer(state, changeUsername(fixture))).toEqual(expectedResult); + }); +}); diff --git a/app/containers/HomePage/tests/sagas.test.js b/app/containers/HomePage/tests/sagas.test.js new file mode 100644 index 0000000..49537ae --- /dev/null +++ b/app/containers/HomePage/tests/sagas.test.js @@ -0,0 +1,92 @@ +/** + * Tests for HomePage sagas + */ + +import expect from 'expect'; +import { take, call, put, select, fork, cancel } from 'redux-saga/effects'; +import { LOCATION_CHANGE } from 'react-router-redux'; + +import { getRepos, getReposWatcher, githubData } from '../sagas'; + +import { LOAD_REPOS } from 'containers/App/constants'; +import { reposLoaded, repoLoadingError } from 'containers/App/actions'; + +import request from 'utils/request'; +import { selectUsername } from 'containers/HomePage/selectors'; + +const username = 'mxstbr'; + +describe('getRepos Saga', () => { + let getReposGenerator; + + // We have to test twice, once for a successful load and once for an unsuccessful one + // so we do all the stuff that happens beforehand automatically in the beforeEach + beforeEach(() => { + getReposGenerator = getRepos(); + + const selectDescriptor = getReposGenerator.next().value; + expect(selectDescriptor).toEqual(select(selectUsername())); + + const requestURL = `https://api.github.com/users/${username}/repos?type=all&sort=updated`; + const callDescriptor = getReposGenerator.next(username).value; + expect(callDescriptor).toEqual(call(request, requestURL)); + }); + + it('should dispatch the reposLoaded action if it requests the data successfully', () => { + const response = { + data: [{ + name: 'First repo', + }, { + name: 'Second repo', + }], + }; + const putDescriptor = getReposGenerator.next(response).value; + expect(putDescriptor).toEqual(put(reposLoaded(response.data, username))); + }); + + it('should call the repoLoadingError action if the response errors', () => { + const response = { + err: 'Some error', + }; + const putDescriptor = getReposGenerator.next(response).value; + expect(putDescriptor).toEqual(put(repoLoadingError(response.err))); + }); +}); + +describe('getReposWatcher Saga', () => { + const getReposWatcherGenerator = getReposWatcher(); + + it('should watch for LOAD_REPOS action', () => { + const takeDescriptor = getReposWatcherGenerator.next().value; + expect(takeDescriptor).toEqual(take(LOAD_REPOS)); + }); + + it('should invoke getRepos saga on actions', () => { + const callDescriptor = getReposWatcherGenerator.next(put(LOAD_REPOS)).value; + expect(callDescriptor).toEqual(call(getRepos)); + }); +}); + +describe('githubDataSaga Saga', () => { + const githubDataSaga = githubData(); + + let forkDescriptor; + + it('should asyncronously fork getReposWatcher saga', () => { + forkDescriptor = githubDataSaga.next(); + expect(forkDescriptor.value).toEqual(fork(getReposWatcher)); + }); + + it('should yield until LOCATION_CHANGE action', () => { + const takeDescriptor = githubDataSaga.next(); + expect(takeDescriptor.value).toEqual(take(LOCATION_CHANGE)); + }); + + it('should finally cancel() the forked getReposWatcher saga', + function* githubDataSagaCancellable() { + // reuse open fork for more integrated approach + forkDescriptor = githubDataSaga.next(put(LOCATION_CHANGE)); + expect(forkDescriptor.value).toEqual(cancel(forkDescriptor)); + } + ); +}); diff --git a/app/containers/HomePage/tests/selectors.test.js b/app/containers/HomePage/tests/selectors.test.js new file mode 100644 index 0000000..c1a9b5f --- /dev/null +++ b/app/containers/HomePage/tests/selectors.test.js @@ -0,0 +1,33 @@ +import { fromJS } from 'immutable'; +import expect from 'expect'; + +import { + selectHome, + selectUsername, +} from '../selectors'; + +describe('selectHome', () => { + const homeSelector = selectHome(); + it('should select the home state', () => { + const homeState = fromJS({ + userData: {}, + }); + const mockedState = fromJS({ + home: homeState, + }); + expect(homeSelector(mockedState)).toEqual(homeState); + }); +}); + +describe('selectUsername', () => { + const usernameSelector = selectUsername(); + it('should select the username', () => { + const username = 'mxstbr'; + const mockedState = fromJS({ + home: { + username, + }, + }); + expect(usernameSelector(mockedState)).toEqual(username); + }); +}); diff --git a/app/containers/LanguageProvider/actions.js b/app/containers/LanguageProvider/actions.js new file mode 100644 index 0000000..779eed0 --- /dev/null +++ b/app/containers/LanguageProvider/actions.js @@ -0,0 +1,16 @@ +/* + * + * LanguageProvider actions + * + */ + +import { + CHANGE_LOCALE, +} from './constants'; + +export function changeLocale(languageLocale) { + return { + type: CHANGE_LOCALE, + locale: languageLocale, + }; +} diff --git a/app/containers/LanguageProvider/constants.js b/app/containers/LanguageProvider/constants.js new file mode 100644 index 0000000..7d2f425 --- /dev/null +++ b/app/containers/LanguageProvider/constants.js @@ -0,0 +1,7 @@ +/* + * + * LanguageProvider constants + * + */ + +export const CHANGE_LOCALE = 'app/LanguageToggle/CHANGE_LOCALE'; diff --git a/app/containers/LanguageProvider/index.js b/app/containers/LanguageProvider/index.js new file mode 100644 index 0000000..1e2a746 --- /dev/null +++ b/app/containers/LanguageProvider/index.js @@ -0,0 +1,36 @@ +/* + * + * LanguageProvider + * + * this component connects the redux state language locale to the + * IntlProvider component and i18n messages (loaded from `app/translations`) + */ + +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { IntlProvider } from 'react-intl'; +import { selectLocale } from './selectors'; + +export class LanguageProvider extends React.Component { // eslint-disable-line react/prefer-stateless-function + render() { + return ( + + {React.Children.only(this.props.children)} + + ); + } +} + +LanguageProvider.propTypes = { + locale: React.PropTypes.string, + messages: React.PropTypes.object, + children: React.PropTypes.element.isRequired, +}; + +const mapStateToProps = createSelector( + selectLocale(), + (locale) => ({ locale }) +); + +export default connect(mapStateToProps)(LanguageProvider); diff --git a/app/containers/LanguageProvider/reducer.js b/app/containers/LanguageProvider/reducer.js new file mode 100644 index 0000000..2908bcc --- /dev/null +++ b/app/containers/LanguageProvider/reducer.js @@ -0,0 +1,26 @@ +/* + * + * LanguageProvider reducer + * + */ + +import { fromJS } from 'immutable'; +import { + CHANGE_LOCALE, +} from './constants'; + +const initialState = fromJS({ + locale: 'en', +}); + +function languageProviderReducer(state = initialState, action) { + switch (action.type) { + case CHANGE_LOCALE: + return state + .set('locale', action.locale); + default: + return state; + } +} + +export default languageProviderReducer; diff --git a/app/containers/LanguageProvider/selectors.js b/app/containers/LanguageProvider/selectors.js new file mode 100644 index 0000000..c43816f --- /dev/null +++ b/app/containers/LanguageProvider/selectors.js @@ -0,0 +1,20 @@ +import { createSelector } from 'reselect'; + +/** + * Direct selector to the languageToggle state domain + */ +const selectLanguage = () => (state) => state.get('language'); + +/** + * Select the language locale + */ + +const selectLocale = () => createSelector( + selectLanguage(), + (languageState) => languageState.get('locale') +); + +export { + selectLanguage, + selectLocale, +}; diff --git a/app/containers/LanguageProvider/tests/actions.test.js b/app/containers/LanguageProvider/tests/actions.test.js new file mode 100644 index 0000000..53c604f --- /dev/null +++ b/app/containers/LanguageProvider/tests/actions.test.js @@ -0,0 +1,19 @@ +import expect from 'expect'; +import { + changeLocale, +} from '../actions'; +import { + CHANGE_LOCALE, +} from '../constants'; + +describe('LanguageProvider actions', () => { + describe('Change Local Action', () => { + it('has a type of CHANGE_LOCALE', () => { + const expected = { + type: CHANGE_LOCALE, + locale: 'de', + }; + expect(changeLocale('de')).toEqual(expected); + }); + }); +}); diff --git a/app/containers/LanguageProvider/tests/index.test.js b/app/containers/LanguageProvider/tests/index.test.js new file mode 100644 index 0000000..8e73bcd --- /dev/null +++ b/app/containers/LanguageProvider/tests/index.test.js @@ -0,0 +1,35 @@ +import LanguageProvider from '../index'; + +import expect from 'expect'; +import { shallow } from 'enzyme'; +import { FormattedMessage, defineMessages } from 'react-intl'; +import configureStore from '../../../store'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { browserHistory } from 'react-router'; +import { translationMessages } from '../../../i18n'; + +describe('', () => { + let store; + + before(() => { + store = configureStore({}, browserHistory); + }); + + it('should render the default language messages', () => { + const messages = defineMessages({ + someMessage: { + id: 'some.id', + defaultMessage: 'This is some default message', + }, + }); + const renderedComponent = shallow( + + + + + + ); + expect(renderedComponent.contains()).toEqual(true); + }); +}); diff --git a/app/containers/LanguageProvider/tests/reducer.test.js b/app/containers/LanguageProvider/tests/reducer.test.js new file mode 100644 index 0000000..693052d --- /dev/null +++ b/app/containers/LanguageProvider/tests/reducer.test.js @@ -0,0 +1,20 @@ +import expect from 'expect'; +import languageProviderReducer from '../reducer'; +import { fromJS } from 'immutable'; +import { + CHANGE_LOCALE, +} from '../constants'; + +describe('languageProviderReducer', () => { + it('returns the initial state', () => { + expect(languageProviderReducer(undefined, {})).toEqual(fromJS({ + locale: 'en', + })); + }); + + it('changes the locale', () => { + expect(languageProviderReducer(undefined, { type: CHANGE_LOCALE, locale: 'de' }).toJS()).toEqual({ + locale: 'de', + }); + }); +}); diff --git a/app/containers/LanguageProvider/tests/selectors.test.js b/app/containers/LanguageProvider/tests/selectors.test.js new file mode 100644 index 0000000..f850874 --- /dev/null +++ b/app/containers/LanguageProvider/tests/selectors.test.js @@ -0,0 +1,16 @@ +import { + selectLanguage, +} from '../selectors'; +import { fromJS } from 'immutable'; +import expect from 'expect'; + +describe('selectLanguage', () => { + const globalSelector = selectLanguage(); + it('should select the global state', () => { + const globalState = fromJS({}); + const mockedState = fromJS({ + language: globalState, + }); + expect(globalSelector(mockedState)).toEqual(globalState); + }); +}); diff --git a/app/containers/LocaleToggle/index.js b/app/containers/LocaleToggle/index.js new file mode 100644 index 0000000..37241af --- /dev/null +++ b/app/containers/LocaleToggle/index.js @@ -0,0 +1,43 @@ +/* + * + * LanguageToggle + * + */ + +import React from 'react'; +import { connect } from 'react-redux'; +import { selectLocale } from '../LanguageProvider/selectors'; +import { changeLocale } from '../LanguageProvider/actions'; +import { appLocales } from '../../i18n'; +import { createSelector } from 'reselect'; +import styles from './styles.css'; +import messages from './messages'; +import Toggle from 'components/Toggle'; + +export class LocaleToggle extends React.Component { // eslint-disable-line + render() { + return ( +
+ +
+ ); + } +} + +LocaleToggle.propTypes = { + onLocaleToggle: React.PropTypes.func, +}; + +const mapStateToProps = createSelector( + selectLocale(), + (locale) => ({ locale }) +); + +export function mapDispatchToProps(dispatch) { + return { + onLocaleToggle: (evt) => dispatch(changeLocale(evt.target.value)), + dispatch, + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(LocaleToggle); diff --git a/app/containers/LocaleToggle/messages.js b/app/containers/LocaleToggle/messages.js new file mode 100644 index 0000000..cefec26 --- /dev/null +++ b/app/containers/LocaleToggle/messages.js @@ -0,0 +1,21 @@ +/* + * LocaleToggle Messages + * + * This contains all the text for the LanguageToggle component. + */ +import { defineMessages } from 'react-intl'; +import { appLocales } from '../../i18n'; + +export function getLocaleMessages(locales) { + return locales.reduce((messages, locale) => + Object.assign(messages, { + [locale]: { + id: `app.components.LocaleToggle.${locale}`, + defaultMessage: `${locale}`, + }, + }), {}); +} + +export default defineMessages( + getLocaleMessages(appLocales) +); diff --git a/app/containers/LocaleToggle/styles.css b/app/containers/LocaleToggle/styles.css new file mode 100644 index 0000000..b8a5a97 --- /dev/null +++ b/app/containers/LocaleToggle/styles.css @@ -0,0 +1,3 @@ +.localeToggle { + padding: 2px; +} diff --git a/app/containers/LocaleToggle/tests/index.test.js b/app/containers/LocaleToggle/tests/index.test.js new file mode 100644 index 0000000..fdc76f0 --- /dev/null +++ b/app/containers/LocaleToggle/tests/index.test.js @@ -0,0 +1,60 @@ +import LocaleToggle, { mapDispatchToProps } from '../index'; +import { changeLocale } from '../../LanguageProvider/actions'; +import LanguageProvider from '../../LanguageProvider'; + +import expect from 'expect'; +import { shallow, mount } from 'enzyme'; +import configureStore from '../../../store'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { browserHistory } from 'react-router'; +import { translationMessages } from '../../../i18n'; + +describe('', () => { + let store; + + before(() => { + store = configureStore({}, browserHistory); + }); + + it('should render the default language messages', () => { + const renderedComponent = shallow( + + + + + + ); + expect(renderedComponent.contains()).toEqual(true); + }); + + it('should present the default `en` english language option', () => { + const renderedComponent = mount( + + + + + + ); + expect(renderedComponent.contains()).toEqual(true); + }); + + describe('mapDispatchToProps', () => { + describe('onLocaleToggle', () => { + it('should be injected', () => { + const dispatch = expect.createSpy(); + const result = mapDispatchToProps(dispatch); + expect(result.onLocaleToggle).toExist(); + }); + + it('should dispatch changeLocale when called', () => { + const dispatch = expect.createSpy(); + const result = mapDispatchToProps(dispatch); + const locale = 'de'; + const evt = { target: { value: locale } }; + result.onLocaleToggle(evt); + expect(dispatch).toHaveBeenCalledWith(changeLocale(locale)); + }); + }); + }); +}); diff --git a/app/containers/LocaleToggle/tests/messages.test.js b/app/containers/LocaleToggle/tests/messages.test.js new file mode 100644 index 0000000..64e7c67 --- /dev/null +++ b/app/containers/LocaleToggle/tests/messages.test.js @@ -0,0 +1,21 @@ +import assert from 'assert'; +import { getLocaleMessages } from '../messages'; + +describe('getLocaleMessages', () => { + it('should create i18n messages for all locales', () => { + const expected = { + en: { + id: 'app.components.LocaleToggle.en', + defaultMessage: 'en', + }, + fr: { + id: 'app.components.LocaleToggle.fr', + defaultMessage: 'fr', + }, + }; + + const actual = getLocaleMessages(['en', 'fr']); + + assert.deepEqual(expected, actual); + }); +}); diff --git a/app/containers/NotFoundPage/index.js b/app/containers/NotFoundPage/index.js new file mode 100644 index 0000000..2aa8c0f --- /dev/null +++ b/app/containers/NotFoundPage/index.js @@ -0,0 +1,38 @@ +/** + * NotFoundPage + * + * This is the page we show when the user visits a url that doesn't have a route + */ + +import React from 'react'; +import { connect } from 'react-redux'; +import { push } from 'react-router-redux'; + +import messages from './messages'; +import { FormattedMessage } from 'react-intl'; +import Button from 'components/Button'; +import H1 from 'components/H1'; + +export function NotFound(props) { + return ( +
+

+ +

+ +
+ ); +} + +NotFound.propTypes = { + dispatch: React.PropTypes.func, +}; + +// Wrap the component to inject dispatch and state into it +export default connect()(NotFound); diff --git a/app/containers/NotFoundPage/messages.js b/app/containers/NotFoundPage/messages.js new file mode 100644 index 0000000..28db83b --- /dev/null +++ b/app/containers/NotFoundPage/messages.js @@ -0,0 +1,17 @@ +/* + * NotFoundPage Messages + * + * This contains all the text for the NotFoundPage component. + */ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + header: { + id: 'boilerplate.containers.NotFoundPage.header', + defaultMessage: 'Page not found.', + }, + homeButton: { + id: 'boilerplate.containers.NotFoundPage.home', + defaultMessage: 'Home', + }, +}); diff --git a/app/containers/NotFoundPage/tests/index.test.js b/app/containers/NotFoundPage/tests/index.test.js new file mode 100644 index 0000000..fe2e7f2 --- /dev/null +++ b/app/containers/NotFoundPage/tests/index.test.js @@ -0,0 +1,48 @@ +/** + * Testing the NotFoundPage + */ + +import expect from 'expect'; +import { shallow } from 'enzyme'; +import React from 'react'; + +import { FormattedMessage } from 'react-intl'; +import { NotFound } from '../index'; +import H1 from 'components/H1'; +import Button from 'components/Button'; + +describe('', () => { + it('should render the Page Not Found text', () => { + const renderedComponent = shallow( + + ); + expect(renderedComponent.contains( +

+ +

)).toEqual(true); + }); + + it('should render a button', () => { + const renderedComponent = shallow( + + ); + const renderedButton = renderedComponent.find(Button); + expect(renderedButton.length).toEqual(1); + }); + + it('should link to "/"', (done) => { + const dispatch = (action) => { + expect(action.payload.args).toEqual('/'); + done(); + }; + + const renderedComponent = shallow( + + ); + const button = renderedComponent.find(Button); + button.prop('handleRoute')(); + }); +}); diff --git a/app/containers/RepoListItem/index.js b/app/containers/RepoListItem/index.js new file mode 100644 index 0000000..818ed9f --- /dev/null +++ b/app/containers/RepoListItem/index.js @@ -0,0 +1,66 @@ +/** + * RepoListItem + * + * Lists the name and the issue count of a repository + */ + +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { FormattedNumber } from 'react-intl'; +import { selectCurrentUser } from 'containers/App/selectors'; +import ListItem from 'components/ListItem'; +import IssueIcon from 'components/IssueIcon'; +import A from 'components/A'; + +import styles from './styles.css'; + +export class RepoListItem extends React.Component { // eslint-disable-line react/prefer-stateless-function + render() { + const item = this.props.item; + let nameprefix = ''; + + // If the repository is owned by a different person than we got the data for + // it's a fork and we should show the name of the owner + if (item.owner.login !== this.props.currentUser) { + nameprefix = `${item.owner.login}/`; + } + + // Put together the content of the repository + const content = ( + + ); + + // Render the content into a list item + return ( + + ); + } +} + +RepoListItem.propTypes = { + item: React.PropTypes.object, + currentUser: React.PropTypes.string, +}; + +export default connect(createSelector( + selectCurrentUser(), + (currentUser) => ({ currentUser }) +))(RepoListItem); diff --git a/app/containers/RepoListItem/styles.css b/app/containers/RepoListItem/styles.css new file mode 100644 index 0000000..0741c89 --- /dev/null +++ b/app/containers/RepoListItem/styles.css @@ -0,0 +1,27 @@ +.linkWrapper { + width: 100%; + height: 100%; + display: flex; + align-items: space-between; +} + +.linkRepo { + height: 100%; + color: black; + display: flex; + align-items: center; + width: 100%; +} + +.linkIssues { + color: black; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.issueIcon { + fill: #CCC; + margin-right: 0.25em; +} diff --git a/app/containers/RepoListItem/tests/index.test.js b/app/containers/RepoListItem/tests/index.test.js new file mode 100644 index 0000000..75d3053 --- /dev/null +++ b/app/containers/RepoListItem/tests/index.test.js @@ -0,0 +1,80 @@ +/** + * Test the repo list item + */ + +import expect from 'expect'; +import { shallow, mount } from 'enzyme'; +import React from 'react'; + +import { IntlProvider } from 'react-intl'; +import { RepoListItem } from '../index'; +import ListItem from 'components/ListItem'; + +describe('', () => { + let item; + + // Before each test reset the item data for safety + beforeEach(() => { + item = { + owner: { + login: 'mxstbr', + }, + html_url: 'https://github.com/mxstbr/react-boilerplate', + name: 'react-boilerplate', + open_issues_count: 20, + full_name: 'mxstbr/react-boilerplate', + }; + }); + + it('should render a ListItem', () => { + const renderedComponent = shallow( + + ); + expect(renderedComponent.find(ListItem).length).toEqual(1); + }); + + it('should not render the current username', () => { + const renderedComponent = mount( + + + + ); + expect(renderedComponent.text().indexOf(item.owner.login)).toBeLessThan(0); + }); + + it('should render usernames that are not the current one', () => { + const renderedComponent = mount( + + + + ); + expect(renderedComponent.text().indexOf(item.owner.login)).toBeGreaterThan(-1); + }); + + it('should render the repo name', () => { + const renderedComponent = mount( + + + + ); + expect(renderedComponent.text().indexOf(item.name)).toBeGreaterThan(-1); + }); + + it('should render the issue count', () => { + const renderedComponent = mount( + + + + ); + expect(renderedComponent.text().indexOf(item.open_issues_count)).toBeGreaterThan(1); + }); + + it('should render the IssueIcon', () => { + const renderedComponent = mount( + + + + ); + expect(renderedComponent.find('svg').length).toEqual(1); + }); +}); diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..a041392 Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/i18n.js b/app/i18n.js new file mode 100644 index 0000000..3ad360f --- /dev/null +++ b/app/i18n.js @@ -0,0 +1,35 @@ +/** + * i18n.js + * + * This will setup the i18n language files and locale data for your app. + * + */ +import { addLocaleData } from 'react-intl'; + +import enLocaleData from 'react-intl/locale-data/en'; +import deLocaleData from 'react-intl/locale-data/de'; + +addLocaleData(enLocaleData); +addLocaleData(deLocaleData); + +export const appLocales = [ + 'en', + 'de', +]; + +import enTranslationMessages from './translations/en.json'; +import deTranslationMessages from './translations/de.json'; + +export const formatTranslationMessages = (messages) => { + const formattedMessages = {}; + for (const message of messages) { + formattedMessages[message.id] = message.message || message.defaultMessage; + } + + return formattedMessages; +}; + +export const translationMessages = { + en: formatTranslationMessages(enTranslationMessages), + de: formatTranslationMessages(deTranslationMessages), +}; diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..876f9cd --- /dev/null +++ b/app/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + React.js Boilerplate + + + +
+ + + + + diff --git a/app/manifest.json b/app/manifest.json new file mode 100644 index 0000000..e07264b --- /dev/null +++ b/app/manifest.json @@ -0,0 +1,33 @@ +{ + "name": "React Boilerplate", + "icons": [ + { + "src": "favicon.png", + "sizes": "48x48", + "type": "image/png", + "density": 1.0 + }, + { + "src": "favicon.png", + "sizes": "96x96", + "type": "image/png", + "density": 2.0 + }, + { + "src": "favicon.png", + "sizes": "144x144", + "type": "image/png", + "density": 3.0 + }, + { + "src": "favicon.png", + "sizes": "192x192", + "type": "image/png", + "density": 4.0 + } + ], + "start_url": "index.html", + "display": "standalone", + "orientation": "portrait", + "background_color": "#FFFFFF" +} \ No newline at end of file diff --git a/app/reducers.js b/app/reducers.js new file mode 100644 index 0000000..8879324 --- /dev/null +++ b/app/reducers.js @@ -0,0 +1,51 @@ +/** + * Combine all reducers in this file and export the combined reducers. + * If we were to do this in store.js, reducers wouldn't be hot reloadable. + */ + +import { fromJS } from 'immutable'; +import { combineReducers } from 'redux-immutable'; +import { LOCATION_CHANGE } from 'react-router-redux'; + +import globalReducer from 'containers/App/reducer'; +import languageProviderReducer from 'containers/LanguageProvider/reducer'; + +/* + * routeReducer + * + * The reducer merges route location changes into our immutable state. + * The change is necessitated by moving to react-router-redux@4 + * + */ + +// Initial routing state +const routeInitialState = fromJS({ + locationBeforeTransitions: null, +}); + +/** + * Merge route into the global application state + */ +function routeReducer(state = routeInitialState, action) { + switch (action.type) { + /* istanbul ignore next */ + case LOCATION_CHANGE: + return state.merge({ + locationBeforeTransitions: action.payload, + }); + default: + return state; + } +} + +/** + * Creates the main reducer with the asynchronously loaded ones + */ +export default function createReducer(asyncReducers) { + return combineReducers({ + route: routeReducer, + global: globalReducer, + language: languageProviderReducer, + ...asyncReducers, + }); +} diff --git a/app/routes.js b/app/routes.js new file mode 100644 index 0000000..ecea83d --- /dev/null +++ b/app/routes.js @@ -0,0 +1,59 @@ +// These are the pages you can go to. +// They are all wrapped in the App component, which should contain the navbar etc +// See http://blog.mxstbr.com/2016/01/react-apps-with-pages for more information +// about the code splitting business +import { getAsyncInjectors } from './utils/asyncInjectors'; + +const errorLoading = (err) => { + console.error('Dynamic page loading failed', err); // eslint-disable-line no-console +}; + +const loadModule = (cb) => (componentModule) => { + cb(null, componentModule.default); +}; + +export default function createRoutes(store) { + // create reusable async injectors using getAsyncInjectors factory + const { injectReducer, injectSagas } = getAsyncInjectors(store); + + return [ + { + path: '/', + name: 'home', + getComponent(nextState, cb) { + const importModules = Promise.all([ + System.import('containers/HomePage/reducer'), + System.import('containers/HomePage/sagas'), + System.import('containers/HomePage'), + ]); + + const renderRoute = loadModule(cb); + + importModules.then(([reducer, sagas, component]) => { + injectReducer('home', reducer.default); + injectSagas(sagas.default); + + renderRoute(component); + }); + + importModules.catch(errorLoading); + }, + }, { + path: '/features', + name: 'features', + getComponent(nextState, cb) { + System.import('containers/FeaturePage') + .then(loadModule(cb)) + .catch(errorLoading); + }, + }, { + path: '*', + name: 'notfound', + getComponent(nextState, cb) { + System.import('containers/NotFoundPage') + .then(loadModule(cb)) + .catch(errorLoading); + }, + }, + ]; +} diff --git a/app/store.js b/app/store.js new file mode 100644 index 0000000..eec3111 --- /dev/null +++ b/app/store.js @@ -0,0 +1,52 @@ +/** + * Create the store with asynchronously loaded reducers + */ + +import { createStore, applyMiddleware, compose } from 'redux'; +import { fromJS } from 'immutable'; +import { routerMiddleware } from 'react-router-redux'; +import createSagaMiddleware from 'redux-saga'; +import createReducer from './reducers'; + +const sagaMiddleware = createSagaMiddleware(); +const devtools = window.devToolsExtension || (() => (noop) => noop); + +export default function configureStore(initialState = {}, history) { + // Create the store with two middlewares + // 1. sagaMiddleware: Makes redux-sagas work + // 2. routerMiddleware: Syncs the location/URL path to the state + const middlewares = [ + sagaMiddleware, + routerMiddleware(history), + ]; + + const enhancers = [ + applyMiddleware(...middlewares), + devtools(), + ]; + + const store = createStore( + createReducer(), + fromJS(initialState), + compose(...enhancers) + ); + + // Extensions + store.runSaga = sagaMiddleware.run; + store.asyncReducers = {}; // Async reducer registry + + // Make reducers hot reloadable, see http://mxs.is/googmo + /* istanbul ignore next */ + if (module.hot) { + module.hot.accept('./reducers', () => { + System.import('./reducers').then((reducerModule) => { + const createReducers = reducerModule.default; + const nextReducers = createReducers(store.asyncReducers); + + store.replaceReducer(nextReducers); + }); + }); + } + + return store; +} diff --git a/app/tests/store.test.js b/app/tests/store.test.js new file mode 100644 index 0000000..f494d09 --- /dev/null +++ b/app/tests/store.test.js @@ -0,0 +1,27 @@ +/** + * Test store addons + */ + +import expect from 'expect'; +import configureStore from '../store'; +import { browserHistory } from 'react-router'; + +describe('configureStore', () => { + let store; + + before(() => { + store = configureStore({}, browserHistory); + }); + + describe('asyncReducers', () => { + it('should contain an object for async reducers', () => { + expect(typeof store.asyncReducers).toEqual('object'); + }); + }); + + describe('runSaga', () => { + it('should contain a hook for `sagaMiddleware.run`', () => { + expect(typeof store.runSaga).toEqual('function'); + }); + }); +}); diff --git a/app/translations/de.json b/app/translations/de.json new file mode 100644 index 0000000..0290000 --- /dev/null +++ b/app/translations/de.json @@ -0,0 +1,152 @@ +[ + { + "id": "app.components.LocaleToggle.de", + "defaultMessage": "de", + "message": "" + }, + { + "id": "app.components.LocaleToggle.en", + "defaultMessage": "en", + "message": "" + }, + { + "id": "boilerplate.components.Footer.author.message", + "defaultMessage": "Made with love by {author}.", + "message": "Mit Liebe gemacht von {author}." + }, + { + "id": "boilerplate.components.Footer.license.message", + "defaultMessage": "This project is licensed under the MIT license.", + "message": "Dieses Projekt wird unter der MIT-Lizenz veröffentlicht." + }, + { + "id": "boilerplate.containers.FeaturePage.css.header", + "defaultMessage": "Features", + "message": "" + }, + { + "id": "boilerplate.containers.FeaturePage.css.message", + "defaultMessage": "Next generation CSS", + "message": "Die nächste Generation von CSS" + }, + { + "id": "boilerplate.containers.FeaturePage.feedback.header", + "defaultMessage": "Instant feedback", + "message": "Sofortiges Feedback" + }, + { + "id": "boilerplate.containers.FeaturePage.feedback.message", + "defaultMessage": "Enjoy the best DX and code your app at the speed of thought! Your\n saved changes to the CSS and JS are reflected instantaneously\n without refreshing the page. Preserve application state even when\n you update something in the underlying code!", + "message": "Genießen Sie die beste Entwicklungserfahrung und programmieren Sie Ihre App so schnell wie noch nie! Ihre Änderungen an dem CSS und JavaScript sind sofort reflektiert, ohne die Seite aktualisieren zu müssen. So bleibt der Anwendungszustand bestehen, auch wenn Sie etwas in dem darunter liegenden Code aktualisieren!" + }, + { + "id": "boilerplate.containers.FeaturePage.header", + "defaultMessage": "Features", + "message": "" + }, + { + "id": "boilerplate.containers.FeaturePage.home", + "defaultMessage": "Home", + "message": "" + }, + { + "id": "boilerplate.containers.FeaturePage.internationalization.header", + "defaultMessage": "Complete i18n Standard Internationalization & Pluralization", + "message": "Komplette i18n Standard-Internationalisierung und Pluralisierung" + }, + { + "id": "boilerplate.containers.FeaturePage.internationalization.message", + "defaultMessage": "Scalable apps need to support multiple languages, easily add and support multiple languages with `react-intl`.", + "message": "Das Internet ist global. Mehrsprachige- und Pluralisierungsunterstützung ist entscheidend für große Web-Anwendungen." + }, + { + "id": "boilerplate.containers.FeaturePage.javascript.header", + "defaultMessage": "Next generation JavaScript", + "message": "Die nächste Generation von JavaScript" + }, + { + "id": "boilerplate.containers.FeaturePage.javascript.message", + "defaultMessage": "Use template strings, object destructuring, arrow functions, JSX\n syntax and more, today.", + "message": "Benutzen Sie ES6 template strings, object destructuring, arrow functions, JSX syntax und mehr, heute." + }, + { + "id": "boilerplate.containers.FeaturePage.network.header", + "defaultMessage": "Offline-first", + "message": "" + }, + { + "id": "boilerplate.containers.FeaturePage.network.message", + "defaultMessage": "The next frontier in performant web apps: availability without a\n network connection from the instant your users load the app.", + "message": "Die nächste Schwelle für performanten Web-Anwendungen: Verfügbarkeit ohne Netzwerkverbindung wenn Ihre Benutzer die App einmal heruntergeladen haben." + }, + { + "id": "boilerplate.containers.FeaturePage.routing.header", + "defaultMessage": "Industry-standard routing", + "message": "Standard Routing" + }, + { + "id": "boilerplate.containers.FeaturePage.routing.message", + "defaultMessage": "Write composable CSS that's co-located with your components for\n complete modularity. Unique generated class names keep the\n specificity low while eliminating style clashes. Ship only the\n styles that are on the page for the best performance.", + "message": "Schreiben Sie CSS, das am selben Ort wie ihre Komponenten ist. Deterministisch generierte, einzigartige Klassennamen halten die Spezifität niedrig während styling Konflikte vermieden werden. Senden Sie nur das CSS an ihre Benutzer welches dann wirklich sichtbar ist für die schnellste Performance!" + }, + { + "id": "boilerplate.containers.FeaturePage.scaffolding.header", + "defaultMessage": "Quick scaffolding", + "message": "Schnelles Scaffolding" + }, + { + "id": "boilerplate.containers.FeaturePage.scaffolding.message", + "defaultMessage": "Automate the creation of components, containers, routes, selectors\n and sagas - and their tests - right from the CLI!", + "message": "Automatisieren Sie die Kreation von Komponenten, Containern, Routen, Selektoren und Sagas – und ihre Tests – direkt von dem Terminal!" + }, + { + "id": "boilerplate.containers.FeaturePage.state_management.header", + "defaultMessage": "Predictable state management", + "message": "Berechenbare Stateverwaltung" + }, + { + "id": "boilerplate.containers.FeaturePage.state_management.message", + "defaultMessage": "Unidirectional data flow allows for change logging and time travel\n debugging.", + "message": "Unidirectional data flow erlaubt uns alle Änderungen ihrer Applikation zu loggen und time travel debugging einzusetzen." + }, + { + "id": "boilerplate.containers.HomePage.features.Button", + "defaultMessage": "Features", + "message": "" + }, + { + "id": "boilerplate.containers.HomePage.start_project.header", + "defaultMessage": "Start your next react project in seconds", + "message": "Beginnen Sie Ihr nächstes React Projekt in Sekunden" + }, + { + "id": "boilerplate.containers.HomePage.start_project.message", + "defaultMessage": "A highly scalable, offline-first foundation with the best DX and a focus on performance and best practices", + "message": "Ein skalierendes, offline-first Fundament mit der besten DX und einem Fokus auf Performance und bewährte Methoden" + }, + { + "id": "boilerplate.containers.HomePage.tryme.atPrefix", + "defaultMessage": "@", + "message": "" + }, + { + "id": "boilerplate.containers.HomePage.tryme.header", + "defaultMessage": "Try me!", + "message": "Probiere mich!" + }, + { + "id": "boilerplate.containers.HomePage.tryme.message", + "defaultMessage": "Show Github repositories by", + "message": "Zeige die Github Repositories von" + }, + { + "id": "boilerplate.containers.NotFoundPage.header", + "defaultMessage": "Page not found.", + "message": "Seite nicht gefunden." + }, + { + "id": "boilerplate.containers.NotFoundPage.home", + "defaultMessage": "Home", + "message": "" + } +] diff --git a/app/translations/en.json b/app/translations/en.json new file mode 100644 index 0000000..369a26c --- /dev/null +++ b/app/translations/en.json @@ -0,0 +1,152 @@ +[ + { + "id": "app.components.LocaleToggle.de", + "defaultMessage": "de", + "message": "" + }, + { + "id": "app.components.LocaleToggle.en", + "defaultMessage": "en", + "message": "" + }, + { + "id": "boilerplate.components.Footer.author.message", + "defaultMessage": "Made with love by {author}.", + "message": "Made with love by {author}." + }, + { + "id": "boilerplate.components.Footer.license.message", + "defaultMessage": "This project is licensed under the MIT license.", + "message": "This project is licensed under the MIT license." + }, + { + "id": "boilerplate.containers.FeaturePage.css.header", + "defaultMessage": "Features", + "message": "Next generation CSS" + }, + { + "id": "boilerplate.containers.FeaturePage.css.message", + "defaultMessage": "Next generation CSS", + "message": "Write composable CSS that's co-located with your components for\n complete modularity. Unique generated class names keep the\n specificity low while eliminating style clashes. Ship only the\n styles that are on the page for the best performance." + }, + { + "id": "boilerplate.containers.FeaturePage.feedback.header", + "defaultMessage": "Instant feedback", + "message": "Instant feedback" + }, + { + "id": "boilerplate.containers.FeaturePage.feedback.message", + "defaultMessage": "Enjoy the best DX and code your app at the speed of thought! Your\n saved changes to the CSS and JS are reflected instantaneously\n without refreshing the page. Preserve application state even when\n you update something in the underlying code!", + "message": "Enjoy the best DX and code your app at the speed of thought! Your\n saved changes to the CSS and JS are reflected instantaneously\n without refreshing the page. Preserve application state even when\n you update something in the underlying code!" + }, + { + "id": "boilerplate.containers.FeaturePage.header", + "defaultMessage": "Features", + "message": "" + }, + { + "id": "boilerplate.containers.FeaturePage.home", + "defaultMessage": "Home", + "message": "Home" + }, + { + "id": "boilerplate.containers.FeaturePage.internationalization.header", + "defaultMessage": "Complete i18n Standard Internationalization & Pluralization", + "message": "Complete i18n Standard Internationalization & Pluralization" + }, + { + "id": "boilerplate.containers.FeaturePage.internationalization.message", + "defaultMessage": "Scalable apps need to support multiple languages, easily add and support multiple languages with `react-intl`.", + "message": "Scalable apps need to support multiple languages, easily add and support multiple languages with `react-intl`." + }, + { + "id": "boilerplate.containers.FeaturePage.javascript.header", + "defaultMessage": "Next generation JavaScript", + "message": "Next generation JavaScript" + }, + { + "id": "boilerplate.containers.FeaturePage.javascript.message", + "defaultMessage": "Use template strings, object destructuring, arrow functions, JSX\n syntax and more, today.", + "message": "Use template strings, object destructuring, arrow functions, JSX\n syntax and more, today." + }, + { + "id": "boilerplate.containers.FeaturePage.network.header", + "defaultMessage": "Offline-first", + "message": "Offline-first" + }, + { + "id": "boilerplate.containers.FeaturePage.network.message", + "defaultMessage": "The next frontier in performant web apps: availability without a\n network connection from the instant your users load the app.", + "message": "The next frontier in performant web apps: availability without a\n network connection from the instant your users load the app." + }, + { + "id": "boilerplate.containers.FeaturePage.routing.header", + "defaultMessage": "Industry-standard routing", + "message": "Industry-standard routing" + }, + { + "id": "boilerplate.containers.FeaturePage.routing.message", + "defaultMessage": "Write composable CSS that's co-located with your components for\n complete modularity. Unique generated class names keep the\n specificity low while eliminating style clashes. Ship only the\n styles that are on the page for the best performance.", + "message": "It's natural to want to add pages (e.g. '/about') to your\n application, and routing makes this possible." + }, + { + "id": "boilerplate.containers.FeaturePage.scaffolding.header", + "defaultMessage": "Quick scaffolding", + "message": "Quick scaffolding" + }, + { + "id": "boilerplate.containers.FeaturePage.scaffolding.message", + "defaultMessage": "Automate the creation of components, containers, routes, selectors\n and sagas - and their tests - right from the CLI!", + "message": "Automate the creation of components, containers, routes, selectors\n and sagas - and their tests - right from the CLI!" + }, + { + "id": "boilerplate.containers.FeaturePage.state_management.header", + "defaultMessage": "Predictable state management", + "message": "Predictable state management" + }, + { + "id": "boilerplate.containers.FeaturePage.state_management.message", + "defaultMessage": "Unidirectional data flow allows for change logging and time travel\n debugging.", + "message": "Unidirectional data flow allows for change logging and time travel\n debugging." + }, + { + "id": "boilerplate.containers.HomePage.features.Button", + "defaultMessage": "Features", + "message": "Features" + }, + { + "id": "boilerplate.containers.HomePage.start_project.header", + "defaultMessage": "Start your next react project in seconds", + "message": "Start your next react project in seconds" + }, + { + "id": "boilerplate.containers.HomePage.start_project.message", + "defaultMessage": "A highly scalable, offline-first foundation with the best DX and a focus on performance and best practices", + "message": "A highly scalable, offline-first foundation with the best DX and a focus on performance and best practices" + }, + { + "id": "boilerplate.containers.HomePage.tryme.atPrefix", + "defaultMessage": "@", + "message": "@" + }, + { + "id": "boilerplate.containers.HomePage.tryme.header", + "defaultMessage": "Try me!", + "message": "Try me!" + }, + { + "id": "boilerplate.containers.HomePage.tryme.message", + "defaultMessage": "Show Github repositories by", + "message": "Show Github repositories by" + }, + { + "id": "boilerplate.containers.NotFoundPage.header", + "defaultMessage": "Page not found.", + "message": "Page not found." + }, + { + "id": "boilerplate.containers.NotFoundPage.home", + "defaultMessage": "Home", + "message": "Home" + } +] diff --git a/app/utils/asyncInjectors.js b/app/utils/asyncInjectors.js new file mode 100644 index 0000000..9e9578d --- /dev/null +++ b/app/utils/asyncInjectors.js @@ -0,0 +1,74 @@ +import { conformsTo, isEmpty, isFunction, isObject, isString } from 'lodash'; +import invariant from 'invariant'; +import warning from 'warning'; +import createReducer from '../reducers'; + +/** + * Validate the shape of redux store + */ +export function checkStore(store) { + const shape = { + dispatch: isFunction, + subscribe: isFunction, + getState: isFunction, + replaceReducer: isFunction, + runSaga: isFunction, + asyncReducers: isObject, + }; + invariant( + conformsTo(store, shape), + '(app/utils...) asyncInjectors: Expected a valid redux store' + ); +} + +/** + * Inject an asynchronously loaded reducer + */ +export function injectAsyncReducer(store, isValid) { + return function injectReducer(name, asyncReducer) { + if (!isValid) checkStore(store); + + invariant( + isString(name) && !isEmpty(name) && isFunction(asyncReducer), + '(app/utils...) injectAsyncReducer: Expected `asyncReducer` to be a reducer function' + ); + + if (Reflect.has(store.asyncReducers, name)) return; + + store.asyncReducers[name] = asyncReducer; // eslint-disable-line no-param-reassign + store.replaceReducer(createReducer(store.asyncReducers)); + }; +} + +/** + * Inject an asynchronously loaded saga + */ +export function injectAsyncSagas(store, isValid) { + return function injectSagas(sagas) { + if (!isValid) checkStore(store); + + invariant( + Array.isArray(sagas), + '(app/utils...) injectAsyncSagas: Expected `sagas` to be an array of generator functions' + ); + + warning( + !isEmpty(sagas), + '(app/utils...) injectAsyncSagas: Received an empty `sagas` array' + ); + + sagas.map(store.runSaga); + }; +} + +/** + * Helper for creating injectors + */ +export function getAsyncInjectors(store) { + checkStore(store); + + return { + injectReducer: injectAsyncReducer(store, true), + injectSagas: injectAsyncSagas(store, true), + }; +} diff --git a/app/utils/request.js b/app/utils/request.js new file mode 100644 index 0000000..b42743a --- /dev/null +++ b/app/utils/request.js @@ -0,0 +1,45 @@ +import 'whatwg-fetch'; + +/** + * Parses the JSON returned by a network request + * + * @param {object} response A response from a network request + * + * @return {object} The parsed JSON from the request + */ +function parseJSON(response) { + return response.json(); +} + +/** + * Checks if a network request came back fine, and throws an error if not + * + * @param {object} response A response from a network request + * + * @return {object|undefined} Returns either the response, or throws an error + */ +function checkStatus(response) { + if (response.status >= 200 && response.status < 300) { + return response; + } + + const error = new Error(response.statusText); + error.response = response; + throw error; +} + +/** + * Requests a URL, returning a promise + * + * @param {string} url The URL we want to request + * @param {object} [options] The options we want to pass to "fetch" + * + * @return {object} An object containing either "data" or "err" + */ +export default function request(url, options) { + return fetch(url, options) + .then(checkStatus) + .then(parseJSON) + .then((data) => ({ data })) + .catch((err) => ({ err })); +} diff --git a/app/utils/tests/asyncInjectors.test.js b/app/utils/tests/asyncInjectors.test.js new file mode 100644 index 0000000..32519f3 --- /dev/null +++ b/app/utils/tests/asyncInjectors.test.js @@ -0,0 +1,164 @@ +/** + * Test async injectors + */ + +import expect from 'expect'; +import configureStore from '../../store'; +import { memoryHistory } from 'react-router'; +import { put } from 'redux-saga/effects'; +import { fromJS } from 'immutable'; + +import { + injectAsyncReducer, + injectAsyncSagas, + getAsyncInjectors, +} from '../asyncInjectors'; + +// Fixtures + +const initialState = fromJS({ reduced: 'soon' }); + +const reducer = (state = initialState, action) => { + switch (action.type) { + case 'TEST': + return state.set('reduced', action.payload); + default: + return state; + } +}; + +function* testSaga() { + yield put({ type: 'TEST', payload: 'yup' }); +} + +const sagas = [ + testSaga, +]; + +describe('asyncInjectors', () => { + let store; + + describe('getAsyncInjectors', () => { + before(() => { + store = configureStore({}, memoryHistory); + }); + + it('given a store, should return all async injectors', () => { + const { injectReducer, injectSagas } = getAsyncInjectors(store); + + injectReducer('test', reducer); + injectSagas(sagas); + + const actual = store.getState().get('test'); + const expected = initialState.merge({ reduced: 'yup' }); + + expect(actual.toJS()).toEqual(expected.toJS()); + }); + + it('should throw if passed invalid store shape', () => { + let result = false; + + Reflect.deleteProperty(store, 'dispatch'); + + try { + getAsyncInjectors(store); + } catch (err) { + result = err.name === 'Invariant Violation'; + } + + expect(result).toEqual(true); + }); + }); + + describe('helpers', () => { + before(() => { + store = configureStore({}, memoryHistory); + }); + + describe('injectAsyncReducer', () => { + it('given a store, it should provide a function to inject a reducer', () => { + const injectReducer = injectAsyncReducer(store); + + injectReducer('test', reducer); + + const actual = store.getState().get('test'); + const expected = initialState; + + expect(actual.toJS()).toEqual(expected.toJS()); + }); + + it('should throw if passed invalid name', () => { + let result = false; + + const injectReducer = injectAsyncReducer(store); + + try { + injectReducer('', reducer); + } catch (err) { + result = err.name === 'Invariant Violation'; + } + + try { + injectReducer(999, reducer); + } catch (err) { + result = err.name === 'Invariant Violation'; + } + + expect(result).toEqual(true); + }); + + it('should throw if passed invalid reducer', () => { + let result = false; + + const injectReducer = injectAsyncReducer(store); + + try { + injectReducer('bad', 'nope'); + } catch (err) { + result = err.name === 'Invariant Violation'; + } + + try { + injectReducer('coolio', 12345); + } catch (err) { + result = err.name === 'Invariant Violation'; + } + + expect(result).toEqual(true); + }); + }); + + describe('injectAsyncSagas', () => { + it('given a store, it should provide a function to inject a saga', () => { + const injectSagas = injectAsyncSagas(store); + + injectSagas(sagas); + + const actual = store.getState().get('test'); + const expected = initialState.merge({ reduced: 'yup' }); + + expect(actual.toJS()).toEqual(expected.toJS()); + }); + + it('should throw if passed invalid saga', () => { + let result = false; + + const injectSagas = injectAsyncSagas(store); + + try { + injectSagas({ testSaga }); + } catch (err) { + result = err.name === 'Invariant Violation'; + } + + try { + injectSagas(testSaga); + } catch (err) { + result = err.name === 'Invariant Violation'; + } + + expect(result).toEqual(true); + }); + }); + }); +}); diff --git a/app/utils/tests/request.test.js b/app/utils/tests/request.test.js new file mode 100644 index 0000000..63741e6 --- /dev/null +++ b/app/utils/tests/request.test.js @@ -0,0 +1,66 @@ +/** + * Test the request function + */ + +import request from '../request'; +import sinon from 'sinon'; +import expect from 'expect'; + +describe('request', () => { + // Before each test, stub the fetch function + beforeEach(() => { + sinon.stub(window, 'fetch'); + }); + + // After each test, restore the fetch function + afterEach(() => { + window.fetch.restore(); + }); + + describe('stubbing successful response', () => { + // Before each test, pretend we got a successful response + beforeEach(() => { + const res = new Response('{"hello":"world"}', { + status: 200, + headers: { + 'Content-type': 'application/json', + }, + }); + + window.fetch.returns(Promise.resolve(res)); + }); + + it('should format the response correctly', (done) => { + request('/thisurliscorrect') + .catch(done) + .then((json) => { + expect(json.data.hello).toEqual('world'); + done(); + }); + }); + }); + + describe('stubbing error response', () => { + // Before each test, pretend we got an unsuccessful response + beforeEach(() => { + const res = new Response('', { + status: 404, + statusText: 'Not Found', + headers: { + 'Content-type': 'application/json', + }, + }); + + window.fetch.returns(Promise.resolve(res)); + }); + + it('should catch errors', (done) => { + request('/thisdoesntexist') + .then((json) => { + expect(json.err.response.status).toEqual(404); + expect(json.err.response.statusText).toEqual('Not Found'); + done(); + }); + }); + }); +}); diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..8b9a2a8 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,44 @@ +# http://www.appveyor.com/docs/appveyor-yml + +# Set build version format here instead of in the admin panel +version: "{build}" + +# Do not build on gh tags +skip_tags: true + +# Test against these versions of Node.js +environment: + + matrix: + # Node versions to run + - nodejs_version: "5.0" + +# Fix line endings in Windows. (runs before repo cloning) +init: + - git config --global core.autocrlf input + +# Install scripts--runs after repo cloning +install: + # Install chrome + - choco install -y googlechrome + # Install the latest stable version of Node + - ps: Install-Product node $env:nodejs_version + - npm -g install npm + - set PATH=%APPDATA%\npm;%PATH% + - npm install + +# Disable automatic builds +build: off + +# Post-install test scripts +test_script: + # Output debugging info + - node --version + - npm --version + # run build and run tests + - npm run build + +# remove, as appveyor doesn't support secure variables on pr builds +# so `COVERALLS_REPO_TOKEN` cannot be set, without hard-coding in this file +#on_success: +#- npm run coveralls diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..bca063d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,128 @@ +# Documentation + +## Table of Contents + +- [General](general) + - [**CLI Commands**](general/commands.md) + - [Tool Configuration](general/files.md) + - [Server Configurations](general/server-configs.md) + - [Deployment](general/deployment.md) *(currently Heroku specific)* + - [FAQ](general/faq.md) + - [Gotchas](general/gotchas.md) + - [Remove](general/remove.md) +- [Testing](testing) + - [Unit Testing](testing/unit-testing.md) + - [Component Testing](testing/component-testing.md) + - [Remote Testing](testing/remote-testing.md) +- [CSS](css) + - [PostCSS](css/postcss.md) + - [CSS Modules](css/css-modules.md) + - [sanitize.css](css/sanitize.md) +- [JS](js) + - [Redux](js/redux.md) + - [ImmutableJS](js/immutablejs.md) + - [reselect](js/reselect.md) + - [redux-saga](js/redux-saga.md) + - [i18n](js/i18n.md) + - [routing](js/routing.md) + +## Overview + +### Quickstart + +1. First, let's kick the tyres by launching the sample _Repospective_ app + bundled with this project to demo some of its best features: + + ```Shell + npm run setup && npm start + ``` + +1. Open [localhost:3000](http://localhost:3000) to see it in action. + + - Add a Github username to see Redux and Redux Sagas in action: effortless + async state updates and side effects are now yours :) + - Edit the file at `./app/containers/HomePage/index.js` so that the text of + the ` +); +``` + +## Integrating Global CSS + +Because class names in CSS Modules are locally scoped by default, there is some +additional setup and consideration that must be taken to work correctly with +traditional global CSS. + +Let's use [Bootstrap](http://getbootstrap.com/) as an example. First of all, +because we are in the React environment, it is widely recommended to not use +the Javascript code that is packaged with Bootstrap, but rather to re-write that +code in a React-friendly way. Thankfully +[react-bootstrap](https://react-bootstrap.github.io/) exists which provides +components built using the native Bootstrap CSS classes. But because these +components are built using the native global CSS, even with react-bootstrap +there is the need to deal with global CSS. As an additional constraint for +this example, let's use npm and webpack to manage our dependencies so that +there is no need to manually add any script tags to `index.html`. + +### Preparation +Edit `package.json` and make the following modifications +``` + "dllPlugin": { + ... + "exclude": [ + "bootstrap-css-only", + ... + ], + ... + }, + "dependencies": { + ... + "bootstrap-css-only": "3.3.6", + "react-bootstrap": "0.30.0", + ... + }, +``` +The `exclude` configuration change is necessary to ensure that the dllPlugin build +process does not attempt to parse the global CSS. If you do not do this +there will be an error during the build process and you will not be able to +run the application. + +Now edit `internals/config.js` and make the following modifications +```javascript +const ReactBoilerplate = { + /* ... */ + dllPlugin: { + defaults: { + /* ... */ + exclude: [ + 'bootstrap-css-only', + /* ... */ + ], + + /* ... */ +}; +``` + +And finally edit `app/app.js`, and add the following after the line `import 'sanitize.css/sanitize.css';` +```javascript +import 'bootstrap-css-only/css/bootstrap.min.css'; +``` + +### Usage + +There are multiple approaches you can use to apply and override the global CSS. + +You can apply the global styles directly. +```javascript +
+``` + +You can apply global styles implicitly via `react-bootstrap`. +```javascript + +``` + +You can override global styles in your CSS module. +```css +:global .container-fluid { + margin-left: 20px; +} +``` + +Or you can add overrides via another local scope and +[classnames](https://github.com/JedWatson/classnames). +```css +.localContainer { + margin-left: 20px; +} +``` +```javascript +import styles from './styles.css'; +import classNames from 'classnames'; +
+``` + +Doing the same via `react-bootstrap`. +```javascript +import styles from './styles.css'; + +``` + +--- + +_Don't like this feature? [Click here](remove.md)_ diff --git a/docs/css/postcss.md b/docs/css/postcss.md new file mode 100644 index 0000000..9804338 --- /dev/null +++ b/docs/css/postcss.md @@ -0,0 +1,42 @@ +# PostCSS + +PostCSS is a modular CSS preprocessor based on JavaScript. It comes pre- +configured with the plugins listed below. + +See the [official documentation](https://github.com/postcss/postcss) for more +information! + +## Plugins + +This boilerplate bundles a few of the most useful PostCSS plugins by default: + +- [`postcss-focus`](https://github.com/postcss/postcss-focus): Adds a `:focus` + selector to every `:hover` selector for keyboard accessibility. +- [`autoprefixer`](https://github.com/postcss/autoprefixer): Prefixes your CSS + automatically for the last two versions of all major browsers and IE10+. +- [`cssnext`](https://github.com/moox/postcss-cssnext): Use tomorrow's CSS + features today. Transpiles CSS4 features down to CSS3. +- [`cssnano`](https://github.com/ben-eb/cssnano): Optimizes your CSS file. For a + full list of optimizations check [the official website](http://cssnano.co/optimisations/). + +For more awesome features that the PostCSS ecosystem offers, check out the +comprehensive, fully-searchable catalog of available plugins at [postcss.parts](http://postcss.parts). + +## Adding a new PostCSS plugin + +1. Add the plugin to your project (e.g. `npm install --save-dev postcss-super-plugin`). +2. Modify `internals/webpack/webpack.dev.babel.js`: + - Add `const postcssSuperPlugin = require('postcss-super-plugin');` + to `// PostCSS plugins` section. + - Find `postcss: () => [/* ... current set of plugins ... */]` and add + the new plugin to the list: `postcssPlugins: [/* ... */, postcssSuperPlugin()]`. +3. Restart the server (`CTRL+C`, `npm start`) for the new plugin to become available + (webpack does not pick config changes while running). + +Before installing a new plugin, make sure that you are not trying to add a feature +that is already available. It is likely that what you are looking for +[is supported by `cssnext`](http://cssnext.io/features/), which is a part of the boilerplate. + +--- + +_Don't like this feature? [Click here](remove.md)_ diff --git a/docs/css/remove.md b/docs/css/remove.md new file mode 100644 index 0000000..040934b --- /dev/null +++ b/docs/css/remove.md @@ -0,0 +1,23 @@ +## Removing CSS modules + +To remove this feature from your setup, stop importing `.css` files in your +components and delete the `modules` option from the `css-loader` declaration in +[`webpack.prod.babel.js`](/internals/webpack/webpack.prod.babel.js) and +[`webpack.base.babel.js`](/internals/webpack/webpack.base.babel.js)! + +## Removing PostCSS + +To remove PostCSS, delete the `postcssPlugins` option and remove all occurences +of the `postcss-loader` from + +- [`webpack.dev.babel.js`](/internals/webpack/webpack.dev.babel.js) +- [`webpack.prod.babel.js`](/internals/webpack/webpack.prod.babel.js) +- [`webpack.base.babel.js`](/internals/webpack/webpack.base.babel.js) + +When that is done - and you've verified that everything is still working - remove +all related dependencies from [`package.json`](/package.json)! + +## Removing `sanitize.css` + +Delete [lines 44 and 45 in `app.js`](../../app/app.js#L44-L45) and remove it +from the `dependencies` in [`package.json`](../../package.json)! diff --git a/docs/css/sanitize.md b/docs/css/sanitize.md new file mode 100644 index 0000000..747ba2c --- /dev/null +++ b/docs/css/sanitize.md @@ -0,0 +1,17 @@ +# `sanitize.css` + +Sanitize.css makes browsers render elements more in +line with developer expectations (e.g. having the box model set to a cascading +`box-sizing: border-box`) and preferences (its defaults can be individually +overridden). + +It was selected over older projects like `normalize.css` and `reset.css` due +to its greater flexibility and better alignment with CSSNext features like CSS +variables. + +See the [official documentation](https://github.com/10up/sanitize.css) for more +information. + +--- + +_Don't like this feature? [Click here](remove.md)_ diff --git a/docs/css/sass.md b/docs/css/sass.md new file mode 100644 index 0000000..1d047d8 --- /dev/null +++ b/docs/css/sass.md @@ -0,0 +1,44 @@ +# Can I use Sass with this boilerplate? + +Yes, although we advise against it and **do not support this**. We selected +PostCSS over Sass because its approach is more powerful: instead of trying to +give a styling language programmatic abilities, it pulls logic and configuration +out into JS where we believe those features belong. + +As an alternative, consider installing a PostCSS plugin called [`PreCSS`](https://github.com/jonathantneal/precss): +it lets you use familiar syntax - $variables, nesting, mixins, etc. - but retain +the advantages (speed, memory efficiency, extensibility, etc) of PostCSS. + +If you _really_ still want (or need) to use Sass then... + +1. Change `internals/webpack/webpack.base.babel.js` so that line 22 reads + ```JavaScript + test: /\.s?css$/, + ``` + + This means that both `.scss` and `.css` will be picked up by the compiler + +1. Update each of + + - `internals/webpack/webpack.dev.babel.js` + - `internals/webpack/webpack.prod.babel.js` + + changing the config option for `cssLoaders` to + + ```JavaScript + cssLoaders: 'style-loader!css-loader?modules&importLoaders=1&sourceMap!postcss-loader!sass-loader', + ``` + + - `internals/webpack/webpack.test.babel.js` + + ```diff + loaders: [ + { test: /\.json$/, loader: 'json-loader' }, + { test: /\.css$/, loader: 'null-loader' }, ++ { test: /\.scss$/, loader: ['style-loader', 'css-loader', 'sass-loader'] }, + ... + ``` + + Then run `npm i -D sass-loader node-sass` + +...and you should be good to go! diff --git a/docs/css/stylelint.md b/docs/css/stylelint.md new file mode 100644 index 0000000..4fb4753 --- /dev/null +++ b/docs/css/stylelint.md @@ -0,0 +1,9 @@ +# stylelint + +stylelint catches bugs and helps keep you and your team on consistent with the +standards and conventions you define. + +We've pre-configured it to extend [stylelint-config-standard](https://github.com/stylelint/stylelint-config-standard) +but you can (and should!) adapt it to your house style. + +See the [official documentation](http://stylelint.io/) for more information! diff --git a/docs/general/README.md b/docs/general/README.md new file mode 100644 index 0000000..8f97ceb --- /dev/null +++ b/docs/general/README.md @@ -0,0 +1,125 @@ +# Introduction + +The JavaScript ecosystem evolves at incredible speed: staying current can feel +overwhelming. So, instead of you having to stay on top of every new tool, +feature and technique to hit the headlines, this project aims to lighten the +load by providing a curated baseline of the most valuable ones. + +Using React Boilerplate, you get to start your app with our community's current +ideas on what represents optimal developer experience, best practice, most +efficient tooling and cleanest project structure. + +- [**CLI Commands**](commands.md) +- [Tool Configuration](files.md) +- [Server Configurations](server-configs.md) +- [Deployment](deployment.md) *(currently Heroku specific)* +- [FAQ](faq.md) +- [Gotchas](gotchas.md) + +# Feature overview + +## Quick scaffolding + +Automate the creation of components, containers, routes, selectors and sagas - +and their tests - right from the CLI! + +Run `npm run generate` in your terminal and choose one of the parts you want +to generate. They'll automatically be imported in the correct places and have +everything set up correctly. + +> We use [plop] to generate new components, you can find all the logic and +templates for the generation in `internals/generators`. + +[plop]: https://github.com/amwmedia/plop + +## Instant feedback + +Enjoy the best DX and code your app at the speed of thought! Your saved changes +to the CSS and JS are reflected instantaneously without refreshing the page. +Preserve application state even when you update something in the underlying code! + +## Predictable state management + +We use Redux to manage our applications state. We have also added optional +support for the [Chrome Redux DevTools Extension] – if you have it installed, +you can see, play back and change your action history! + +[Chrome Redux DevTools Extension]: https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd + +## Next generation JavaScript + +Use ESNext template strings, object destructuring, arrow functions, JSX syntax +and more, today. This is possible thanks to Babel with the `es2015`, `stage-0` +and `react` presets! + +## Next generation CSS + +Write composable CSS that's co-located with your components using [CSS modules] +for complete modularity. Unique generated class names keep the specificity low +while eliminating style clashes. Ship only the styles that are used on the +visible page for the best performance. + +[CSS modules]: ../css/css-modules.md + +## Industry-standard routing + +It's natural to want to add pages (e.g. `/about`) to your application, and +routing makes this possible. Thanks to [react-router] with [react-router-redux], +that's as easy as pie and the url is auto-synced to your application state! + +[react-router]: https://github.com/reactjs/react-router +[react-router-redux]: https://github.com/reactjs/react-router-redux + +# Optional extras + +_Don't like any of these features? [Click here](remove.md)_ + +## Offline-first + +The next frontier in performant web apps: availability without a network +connection from the instant your users load the app. This is done with a +ServiceWorker and a fallback to AppCache, so this feature even works on older +browsers! + +> All your files are included automatically. No manual intervention needed +thanks to Webpack's [`offline-plugin`](https://github.com/NekR/offline-plugin) + +### Add To Homescreen + +After repeat visits to your site, users will get a prompt to add your application +to their homescreen. Combined with offline caching, this means your web app can +be used exactly like a native application (without the limitations of an app store). + +The name and icon to be displayed are set in the `app/manifest.json` file. +Change them to your project name and icon, and try it! + +## Performant Web Font Loading + +If you simply use web fonts in your project, the page will stay blank until +these fonts are downloaded. That means a lot of waiting time in which users +could already read the content. + +[FontFaceObserver](https://github.com/bramstein/fontfaceobserver) adds a class +to the `body` when the fonts have loaded. (see [`app.js`](../../app/app.js#L26-L36) +and [`App/styles.css`](../../app/containers/App/styles.css)) + +### Adding a new font + +1. Either add the `@font-face` declaration to `App/styles.css` or add a `` +tag to the [`index.html`](../../app/index.html). (Don't forget to remove the `` +for Open Sans from the [`index.html`](../../app/index.html)!) + +2. In `App/styles.css`, specify your initial `font-family` in the `body` tag +with only web-save fonts. In the `body.jsFontLoaded` tag, specify your +`font-family` stack with your web font. + +3. In `app.js` add a `Observer` for your font. + +## Image optimization + +Images often represent the majority of bytes downloaded on a web page, so image +optimization can often be a notable performance improvement. Thanks to Webpack's +[`image-loader`](https://github.com/tcoopman/image-webpack-loader), every PNG, JPEG, GIF and SVG images +is optimized. + +See [`image-loader`](https://github.com/tcoopman/image-webpack-loader) to customize optimizations options. diff --git a/docs/general/commands.md b/docs/general/commands.md new file mode 100644 index 0000000..67eb9d6 --- /dev/null +++ b/docs/general/commands.md @@ -0,0 +1,190 @@ +# Command Line Commands + +## Initialization + +```Shell +npm run setup +``` + +Initializes a new project with this boilerplate. Deletes the `react-boilerplate` +git history, installs the dependencies and initializes a new repository. + +> Note: This command is self-destructive, once you've run it the init script is +gone forever. This is for your own safety, so you can't delete your project's +history irreversibly by accident. + +## Development + +```Shell +npm run start +``` + +Starts the development server running on `http://localhost:3000` + +## Cleaning + +```Shell +npm run clean +``` + +Deletes the example app, replacing it with the smallest amount of boilerplate +code necessary to start writing your app! + +> Note: This command is self-destructive, once you've run it you cannot run it +again. This is for your own safety, so you can't delete portions of your project +irreversibly by accident. + +## Generators + +```Shell +npm run generate +``` + +Allows you to auto-generate boilerplate code for common parts of your +application, specifically `component`s, `container`s, and `route`s. You can +also run `npm run generate ` to skip the first selection. (e.g. `npm run +generate container`) + +## Server + +### Development + +```Shell +npm start +``` + +Starts the development server and makes your application accessible at +`localhost:3000`. Tunnels that server with `ngrok`, which means the website +accessible anywhere! Changes in the application code will be hot-reloaded. + +### Production + +```Shell +npm run start:prod +``` + +Starts the production server, configured for optimal performance: assets are +minified and served gzipped. + +### Port + +To change the port the app is accessible at pass the `--port` option to the command +with `--`. E.g. to make the app visible at `localhost:5000`, run the following: +`npm start -- --port 5000` + +## Building + +```Shell +npm run build +``` + +Preps your app for deployment. Optimizes and minifies all files, piping them to +a folder called `build`. Upload the contents of `build` to your web server to +see your work live! + +## Testing + +See the [testing documentation](../testing/README.md) for detailed information +about our testing setup! + +## Unit testing + +```Shell +npm run test +``` + +Tests your application with the unit tests specified in the `*test.js` files +throughout the application. +All the `test` commands allow an optional `-- --grep string` argument to filter +the tests ran by Karma. Useful if you need to run a specific test only. + +```Shell +# Run only the Button component tests +npm run test:watch -- --grep Button +``` + +### Browsers + +To choose the browser to run your unit tests in (Chrome by default), run one of +the following commands: + +#### Firefox + +```Shell +npm run test:firefox +``` + +#### Safari + +```Shell +npm run test:safari +``` + +#### Internet Explorer + +*Windows only!* + +```Shell +npm run test:ie +``` + +### Watching + +```Shell +npm run test:watch +``` + +Watches changes to your application and reruns tests whenever a file changes. + +### Remote testing + +```Shell +npm run start:tunnel +``` +Starts the development server and tunnels it with `ngrok`, making the website +available on the entire world. Useful for testing on different devices in different locations! + +### Performance testing + +```Shell +npm run pagespeed +``` + +With the remote server running (i.e. while `npm run start:prod` is running in +another terminal session), enter this command to run Google PageSpeed Insights +and get a performance check right in your terminal! + +### Dependency size test + +```Shell +npm run analyze +``` + +This command will generate a `stats.json` file from your production build, which +you can upload to the [webpack analyzer](https://webpack.github.io/analyse/). This +analyzer will visualize your dependencies and chunks with detailed statistics +about the bundle size. + +## Linting + +```Shell +npm run lint +``` + +Lints your JavaScript and CSS. + +### JavaScript + +```Shell +npm run lint:js +``` + +Only lints your JavaScript. + +### CSS + +```Shell +npm run lint:css +``` + +Only lints your CSS. diff --git a/docs/general/deployment.md b/docs/general/deployment.md new file mode 100644 index 0000000..2751640 --- /dev/null +++ b/docs/general/deployment.md @@ -0,0 +1,19 @@ +# Deployment + +## Heroku + +### Easy 5-Step Deployment Process + +*Step 1:* Create a Procfile with the following line: `web: npm run start:prod`. We are doing this because heroku runs `npm run start` by default, so we need this setting to override the default run command. + +*Step 2:* Install heroku's buildpack on your heroku app by running the following command: `heroku buildpacks:set https://github.com/heroku/heroku-buildpack-nodejs#v90 -a [your app name]`. Make sure to replace `#v90` with whatever the latest buildpack is which you can [find here](https://github.com/heroku/heroku-buildpack-nodejs/releases). + +*Step 3:* Add this line to your Package.json file in the scripts area: `"postinstall": "npm run build:clean",`. This is because Heroku runs this as part of their build process (more of which you can [read about here](https://devcenter.heroku.com/articles/nodejs-support#build-behavior)). + +*Step 4:* Run `heroku config:set NPM_CONFIG_PRODUCTION=false` so that Heroku can compile the NPM Modules included in your devDependencies (since many of these packages are required for the build process). + +*Step 5:* Follow the standard Heroku deploy process at this point: + +1. `git add .` +2. `git commit -m 'Made some epic changes as per usual'` +3. `git push heroku master` diff --git a/docs/general/faq.md b/docs/general/faq.md new file mode 100644 index 0000000..fb01fd7 --- /dev/null +++ b/docs/general/faq.md @@ -0,0 +1,202 @@ +# Frequently Asked Questions + +## Where are Babel, ESLint and stylelint configured? + +In package.json + +## Where are the files coming from when I run `npm start`? + +In development Webpack compiles your application runs it in-memory. Only when +you run `npm run build` will it write to disk and preserve your bundled +application across computer restarts. + +## How do I fix `Error: listen EADDRINUSE 127.0.0.1:3000`? + +This simply means that there's another process already listening on port 3000. +The fix is to kill the process and rerun `npm start`. + +### OS X / Linux: + +1. Find the process id (PID): + ```Shell + ps aux | grep node + ``` + > This will return the PID as the value following your username: + > ```Shell + > janedoe 29811 49.1 2.1 3394936 356956 s004 S+ 4:45pm 2:40.07 node server + > ``` + +1. Then run + ```Shell + kill -9 YOUR_PID + ``` + > e.g. given the output from the example above, `YOUR_PID` is `29811`, hence + that would mean you would run `kill -9 29811` + +### Windows + +1. Find the process id (PID): + ```Shell + netstat -a -o -n + ``` + + > This will return a list of running processes and the ports they're + listening on: + > ``` + > Proto Local Address Foreign Address State PID + > TCP 0.0.0.0:25 0.0.0.0:0 Listening 4196 + > ... + > TCP 0.0.0.0:3000 0.0.0.0:0 Listening 28344 + ``` + +1. Then run + ```Shell + taskkill /F /PID YOUR_PID + ``` + > e.g. given the output from the example above, `YOUR_PID` is `28344`, hence + that would mean you would run `taskkill /F /PID 28344` + +## Local webfonts not working for development + +In development mode CSS sourcemaps require that styling is loaded by blob://, +resulting in browsers resolving font files relative to the main document. + +A way to use local webfonts in development mode is to add an absolute +output.publicPath in webpack.dev.babel.js, with protocol. + +```javascript +// webpack.dev.babel.js + +output: { + publicPath: 'http://127.0.0.1:3000/', + /* … */ +}, +``` + +## Non-route containers + +> Note: Container will always be nested somewhere below a route. Even if there's dozens of components +in between, somewhere up the tree will be route. (maybe only "/", but still a route) + +### Where do I put the reducer? + +While you can include the reducer statically in `reducers.js`, we don't recommend this as you lose +the benefits of code splitting. Instead, add it as a _composed reducer_. This means that you +pass actions onward to a second reducer from a lower-level route reducer like so: + + +```JS +// Main route reducer + +function myReducerOfRoute(state, action) { + switch (action.type) { + case SOME_OTHER_ACTION: + return someOtherReducer(state, action); + } +} +``` + +That way, you still get the code splitting at route level, but avoid having a static `combineReducers` +call that includes all of them by default. + +*See [this and the following lesson](https://egghead.io/lessons/javascript-redux-reducer-composition-with-arrays?course=getting-started-with-redux) of the egghead.io Redux course for more information about reducer composition!* + +### How do I run the saga? + +Since a container will always be within a route, one we can simply add it to the exported array in +`sagas.js` of the route container somewhere up the tree: + +```JS +// /containers/SomeContainer/sagas.js + +import { someOtherSagaFromNestedContainer } from './containers/SomeNestedContainer/sagas'; + +function* someSaga() { /* … */ } + +export default [ + someSaga, + someOtherSagaFromNestedContainer, +]; +``` + +Or, if you have multiple sagas in the nested container: + + +```JS +// /containers/SomeContainer/sagas.js + +import nestedContainerSagas from './containers/SomeNestedContainer/sagas'; + +function* someSaga() { /* … */ } + +export default [ + someSaga, + ...nestedContainerSagas, +]; +``` + +## Using this boilerplate with WebStorm + +WebStorm is a powerful IDE, and why not also use it as debugger tool? Here is the steps + +1. [Install JetBrain Chrome Extension](https://chrome.google.com/webstore/detail/jetbrains-ide-support/hmhgeddbohgjknpmjagkdomcpobmllji) +2. [Setting up the PORT](https://www.jetbrains.com/help/webstorm/2016.1/using-jetbrains-chrome-extension.html) +3. Change WebPack devtool config to `source-map` [(This line)](https://github.com/mxstbr/react-boilerplate/blob/56eb5a0ec4aa691169ef427f3a0122fde5a5aa24/internals/webpack/webpack.dev.babel.js#L65) +4. Run web server (`npm run start`) +5. Create Run Configuration (Run > Edit Configurations) +6. Add new `JavaScript Debug` +7. Setting up URL +8. Start Debug (Click the green bug button) +9. Edit Run Configuration Again +10. Mapping Url as below picture + * Map your `root` directory with `webpack://.` (please note the last dot) + * Map your `build` directory with your root path (e.g. `http://localhost:3000`) +11. Hit OK and restart debugging session + +![How to debug using WebStorm](webstorm-debug.png) + +### Troubleshooting + +1. You miss the last `.` (dot) in `webpack://.` +2. The port debugger is listening tool and the JetBrain extension is mismatch. + +### Enable ESLint + +ESLint help making all developer follow the same coding format. Please also setting up in your IDE, otherwise, you will fail ESLint test. +1. Go to WebStorm Preference +2. Search for `ESLint` +3. Click `Enable` + +![Setting up ESLint](webstorm-eslint.png) + +## Use CI with bitbucket pipelines + +Your project is on bitbucket? Take advantage of the pipelines feature (Continuous Integration) by creating a 'bitbucket-pipelines.yml' file at the root of the project and use the following code to automatically test your app at each commit: + +```YAML +image: gwhansscheuren/bitbucket-pipelines-node-chrome-firefox + +pipelines: + default: + - step: + script: + - node --version + - npm --version + - npm install + - npm test +``` + +## I'm using Node v0.12 and the server doesn't work? + +We settled on supporting the last three major Node.js versions for the boilerplate – at the moment +of this writing those are v4, v5 and v6. We **highly recommend upgrading to a newer Node.js version**! + +If you _have_ to use Node.js 0.12, you can hack around the server not running by using `babel-cli` to +run the server: `npm install babel-cli`, and then replace all instances of `node server` in the `"scripts"` +in the `package.json` with `babel server`! + +## Have another question? + +Submit an [issue](https://github.com/mxstbr/react-boilerplate/issues), +hop onto the [Gitter channel](https://gitter.im/mxstbr/react-boilerplate) +or contact Max direct on [twitter](https://twitter.com/mxstbr)! diff --git a/docs/general/files.md b/docs/general/files.md new file mode 100644 index 0000000..3b874d4 --- /dev/null +++ b/docs/general/files.md @@ -0,0 +1,34 @@ +# Configuration: A Glossary + +A guide to the configuration files for this project: where they live and what +they do. + +## The root folder + +* `.editorconfig`: Sets the default configuration for certain files across editors. (e.g. indentation) + +* `.gitattributes`: Normalizes how `git`, the version control system this boilerplate uses, handles certain files. + +* `.gitignore`: Tells `git` to ignore certain files and folders which don't need to be version controlled, like the build folder. + +* `.travis.yml` and `appveyor.yml`: Continuous Integration configuration
+ This boilerplate uses [Travis CI](https://travis-ci.com) for Linux environments + and [AppVeyor](https://www.appveyor.com/) for Windows platforms, but feel free + to swap either out for your own choice of CI. + +* `package.json`: Our `npm` configuration file has three functions: + + 1. It's where Babel, ESLint and stylelint are configured + 1. It's the API for the project: a consistent interface for all its controls + 1. It lists the project's package dependencies + + Baking the config in is a slightly unusual set-up, but it allows us to keep + the project root as uncluttered and grokkable-at-a-glance as possible. + +## The `./internals` folder + +This is where the bulk of the tooling configuration lives, broken out into +recognisable units of work. + +Feel free to change anything you like but don't be afraid to [ask upfront](https://gitter.im/mxstbr/react-boilerplate) +whether you should: build systems are easy to break! diff --git a/docs/general/gotchas.md b/docs/general/gotchas.md new file mode 100644 index 0000000..4a8bc64 --- /dev/null +++ b/docs/general/gotchas.md @@ -0,0 +1,23 @@ +# Gotchas + +These are some things to be aware of when using this boilerplate. + +## Special images in HTML files + +If you specify your images in the `.html` files using the `` tag, everything +will work fine. The problem comes up if you try to include images using anything +except that tag, like meta tags: + +```HTML + +``` + +The webpack `html-loader` does not recognise this as an image file and will not +transfer the image to the build folder. To get webpack to transfer them, you +have to import them with the file loader in your JavaScript somewhere, e.g.: + +```JavaScript +import 'file?name=[name].[ext]!../img/yourimg.png'; +``` + +Then webpack will correctly transfer the image to the build folder. diff --git a/docs/general/remove.md b/docs/general/remove.md new file mode 100644 index 0000000..1ce1432 --- /dev/null +++ b/docs/general/remove.md @@ -0,0 +1,49 @@ +### Removing offline access + +**Careful** about removing this, as there is no real downside to having your +application available when the users network connection isn't perfect. + +To remove offline capability, delete the `offline-plugin` from the +[`package.json`](../../package.json), remove the import of the plugin in +[`app.js`](../../app/app.js) and remove the plugin from the +[`webpack.prod.babel.js`](../../internals/webpack/webpack.prod.babel.js). + +### Removing add to homescreen functionality + +Delete [`manifest.json`](../../app/manifest.json) and remove the +`` tag from the +[`index.html`](../../app/index.html). + +### Removing performant web font loading + +**Careful** about removing this, as perceived performance might be highly impacted. + +To remove `FontFaceObserver`, don't import it in [`app.js`](../../app/app.js) and +remove it from the [`package.json`](../../package.json). + +### Removing image optimization + +To remove image optimization, delete the `image-webpack-loader` from the +[`package.json`](../../package.json), and remove the `image-loader` from [`webpack.base.babel.js`](../../internals/webpack/webpack.base.babel.js): +``` +… +{ + test: /\.(jpg|png|gif)$/, + loaders: [ + 'file-loader', + 'image-webpack?{progressive:true, optimizationLevel: 7, interlaced: false, pngquant:{quality: "65-90", speed: 4}}', + ], +} +… +``` + +Then replace it with classic `file-loader`: + +``` +… +{ + test: /\.(jpg|png|gif)$/, + loader: 'file-loader', +} +… +``` diff --git a/docs/general/server-configs.md b/docs/general/server-configs.md new file mode 100644 index 0000000..bede372 --- /dev/null +++ b/docs/general/server-configs.md @@ -0,0 +1,18 @@ +# Server Configurations + +## Apache + +This boilerplate includes a `.htaccess` file that does two things: + +1. Redirect all traffic to HTTPS because ServiceWorker only works for encrypted + traffic. +1. Rewrite all pages (e.g. `yourdomain.com/subpage`) to `yourdomain.com/index.html` + to let `react-router` take care of presenting the correct page. + +> Note: For performance reasons you should probably adapt it to run as a static + `.conf` file (typically under `/etc/apache2/sites-enabled` or similar) so that + your server doesn't have to apply its rules dynamically per request) + +## Nginx + +Also it includes a `.nginx.conf` file that does the same on Nginx server. diff --git a/docs/general/webstorm-debug.png b/docs/general/webstorm-debug.png new file mode 100644 index 0000000..468079a Binary files /dev/null and b/docs/general/webstorm-debug.png differ diff --git a/docs/general/webstorm-eslint.png b/docs/general/webstorm-eslint.png new file mode 100644 index 0000000..075f8ed Binary files /dev/null and b/docs/general/webstorm-eslint.png differ diff --git a/docs/js/README.md b/docs/js/README.md new file mode 100644 index 0000000..74d903b --- /dev/null +++ b/docs/js/README.md @@ -0,0 +1,37 @@ +# JavaScript + +## State management + +This boilerplate manages application state using [Redux](redux.md), makes it +immutable with [`ImmutableJS`](immutablejs.md) and keeps access performant +via [`reselect`](reselect.md). + +For managing asynchronous flows (e.g. logging in) we use [`redux-saga`](redux-saga.md). + +For routing, we use [`react-router` in combination with `react-router-redux`](routing.md). + +We include a generator for components, containers, sagas, routes and selectors. +Run `npm run generate` to choose from the available generators, and automatically +add new parts of your application! + +> Note: If you want to skip the generator selection process, + `npm run generate ` also works. (e.g. `npm run generate route`) + +### Learn more + +- [Redux](redux.md) +- [ImmutableJS](immutablejs.md) +- [reselect](reselect.md) +- [redux-saga](redux-saga.md) +- [react-intl](i18n.md) +- [routing](routing.md) + +## Architecture: `components` and `containers` + +We adopted a split between stateless, reusable components called (wait for it...) +`components` and stateful parent components called `containers`. + +### Learn more + +See [this article](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) +by Dan Abramov for a great introduction to this approach. diff --git a/docs/js/i18n.md b/docs/js/i18n.md new file mode 100644 index 0000000..7f2841c --- /dev/null +++ b/docs/js/i18n.md @@ -0,0 +1,105 @@ +# `i18n` + +`react-intl` is a library to manage internationalization and pluralization support +for your react application. This involves multi-language support for both the static text but also things like variable numbers, words or names that change with application state. `react-intl` provides an incredible amount of mature facility to preform these very tasks. + +The complete `react-intl` docs can be found here: + +https://github.com/yahoo/react-intl/wiki + +## Usage + +Below we see a `messages.js` file for the `Footer` component example. A `messages.js` file should be included in any simple or container component that wants to use internationalization. You can add this support when you scaffold your component using this boilerplates scaffolding `plop` system. + +All default English text for the component is contained here (e.g. `This project is licensed under the MIT license.`), and is tagged with an ID (e.g. `boilerplate.components.Footer.license.message`) in addition to it's object definition id (e.g. `licenseMessage`). + +This is set in `react-intl`'s `defineMessages` function which is then exported for use in the component. You can read more about `defineMessages` here: + +https://github.com/yahoo/react-intl/wiki/API#definemessages + +```js +/* + * Footer Messages + * + * This contains all the text for the Footer component. + */ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + licenseMessage: { + id: 'boilerplate.components.Footer.license.message', + defaultMessage: 'This project is licensed under the MIT license.', + }, + authorMessage: { + id: 'boilerplate.components.Footer.author.message', + defaultMessage: ` + Made with love by {author}. + `, + }, +}); +``` + +Below is the example `Footer` component. Here we see the component including the `messages.js` file, which contains all the default component text, organized with ids (and optionally descriptions). We are also importing the `FormattedMessage` component, which will display a given message from the `messages.js` file in the selected language. + +You will also notice a more complex use of `FormattedMessage` for the author message where alternate or variable values (i.e. `author: Max Stoiber,`) are being injected, in this case it's a react component. + +```js +import React from 'react'; + +import messages from './messages'; +import A from 'components/A'; +import styles from './styles.css'; +import { FormattedMessage } from 'react-intl'; + +function Footer() { + return ( +
+
+

+ +

+
+
+

+ Max Stoiber, + }} + /> +

+
+
+ ); +} + +export default Footer; +``` + +## Extracting i18n JSON files + +You can extract all i18n language within each component by running the following command: + +``` + npm run extract-intl +``` + +This will extract all language into i18n JSON files in `app/translations`. + +## Adding A Language + +You can add a language by running the generate command: + +``` + npm run generate language +``` + +Then enter the two character i18n standard language specifier (e.g. "fr", "de", "es" - without quotes). This will add in the necessary JSON language file and import statements for the language. Note, it is up to you to fill in the translations for the language. + +## Removing i18n and react-intl + +You can remove `react-intl` modules by first removing the `IntlProvider` object from the `app/app.js` file and by either removing or not selecting the i18n text option during component scaffolding. + +The packages associated with `react-intl` are: + - react-intl + - babel-plugin-react-intl diff --git a/docs/js/immutablejs.md b/docs/js/immutablejs.md new file mode 100644 index 0000000..2dfebe2 --- /dev/null +++ b/docs/js/immutablejs.md @@ -0,0 +1,76 @@ +# ImmutableJS + +Immutable data structures can be deeply compared in no time. This allows us to +efficiently determine if our components need to rerender since we know if the +`props` changed or not! + +Check out the [official documentation](https://facebook.github.io/immutable-js/) +for a good explanation of the more intricate benefits it has. + +## Usage + +In our reducers, we make the initial state an immutable data structure with the +`fromJS` function. We pass it an object or an array, and it takes care of +converting it to a compatible one. (Note: the conversion is performed deeply so +that even arbitrarily nested arrays/objects are immutable stuctures too!) + +```JS +import { fromJS } from 'immutable'; + +const initialState = fromJS({ + myData: 'Hello World!', +}); +``` + +To react to an incoming actions our reducers can use the `.set` and the `.setIn` +functions. + +```JS +import { SOME_ACTION } from './actions'; + +// […] + +function myReducer(state = initialState, action) { + switch (action.type) { + case SOME_ACTION: + return state.set('myData', action.payload); + default: + return state; + } +} +``` + +We use [`reselect`](./reselect.md) to efficiently cache our computed application +state. Since that state is now immutable, we need to use the `.get` and `.getIn` +functions to select the part we want. + +```JS +const myDataSelector = (state) => state.get('myData'); + +export default myDataSelector; +``` + +To learn more, check out [`reselect.md`](reselect.md)! + +## Advanced Usage + +ImmutableJS provide many immutable structures like `Map`, `Set` and `List`. But the downside to using ImmutableJS data structures is that they are not normal JavaScript data structures. + +That means you must use getters to access properties : for instance you'll do `map.get("property")` instead of `map.property`, and `array.get(0)` instead of `array[0]`. It's not natural and your code becoming bigger, you finish by not knowing anymore if you are working with a JavaScript object or an Immutable one. While it's possible to be clear where you are using immutable objects, you still pass them through the system into places where it's not clear. This makes reasoning about functions harder. + +The `Record` structure tries to get rid of this drawback. `Record` is like a `Map` whose shape is fixed : you can't later add a new property after the record is created. The benefit of `Record` is that you can now, along with others .get, .set and .merge methods, use the dot notation to access properties, which is a good point to write simpler code. + +The creation of a record is less simple. You got to first create the `Record` shape. With the example above, to create your initial state, you'll write : + +```JS +//the shape +const StateRecord = Record({ + myData: 'Hello World!', +}); + +const initialState = new StateRecord({}); // initialState is now a new StateRecord instance + // initialized with myData set by default as 'Hello World!' +``` + +Now, if you want to access `myData`, you can just write `state.myData` in your reducer code. + diff --git a/docs/js/redux-saga.md b/docs/js/redux-saga.md new file mode 100644 index 0000000..f449fe6 --- /dev/null +++ b/docs/js/redux-saga.md @@ -0,0 +1,77 @@ +# `redux-saga` + +`redux-saga` is a library to manage side effects in your application. It works +beautifully for data fetching, concurrent computations and a lot more. +[Sebastien Lorber](https://twitter.com/sebastienlorber) put it best: + +> Imagine there is widget1 and widget2. When some button on widget1 is clicked, + then it should have an effect on widget2. Instead of coupling the 2 widgets + together (ie widget1 dispatch an action that targets widget2), widget1 only + dispatch that its button was clicked. Then the saga listen for this button + click and then update widget2 by dispatching a new event that widget2 is aware of. +> +> This adds a level of indirection that is unnecessary for simple apps, but make + it more easy to scale complex applications. You can now publish widget1 and + widget2 to different npm repositories so that they never have to know about + each others, without having them to share a global registry of actions. The 2 + widgets are now bounded contexts that can live separately. They do not need + each others to be consistent and can be reused in other apps as well. **The saga + is the coupling point between the two widgets that coordinate them in a + meaningful way for your business.** + +_Note: It is well worth reading the [source](https://stackoverflow.com/questions/34570758/why-do-we-need-middleware-for-async-flow-in-redux/34623840#34623840) +of this quote in its entirety!_ + +To learn more about this amazing way to handle concurrent flows, start with the +[official documentation](https://github.com/yelouafi/redux-saga) and explore +some examples! (read [this comparison](https://stackoverflow.com/questions/34930735/pros-cons-of-using-redux-saga-with-es6-generators-vs-redux-thunk-with-es7-async/34933395) if you're used to `redux-thunk`) + +## Usage + +Sagas are associated with a container, just like actions, constants, selectors +and reducers. If your container already has a `sagas.js` file, simply add your +saga to that. If your container does not yet have a `sagas.js` file, add one with +this boilerplate structure: + +```JS +import { take, call, put, select } from 'redux-saga/effects'; + +// Your sagas for this container +export default [ + sagaName, +]; + +// Individual exports for testing +export function* sagaName() { + +} +``` + +Then, in your `routes.js`, add injection for the newly added saga: + +```JS +getComponent(nextState, cb) { + const importModules = Promise.all([ + System.import('containers/YourComponent/reducer'), + System.import('containers/YourComponent/sagas'), + System.import('containers/YourComponent'), + ]); + + const renderRoute = loadModule(cb); + + importModules.then(([reducer, sagas, component]) => { + injectReducer('home', reducer.default); + injectSagas(sagas.default); // Inject the saga + + renderRoute(component); + }); + + importModules.catch(errorLoading); +}, +``` + +Now add as many sagas to your `sagas.js` file as you want! + +--- + +_Don't like this feature? [Click here](remove.md)_ diff --git a/docs/js/redux.md b/docs/js/redux.md new file mode 100644 index 0000000..6111068 --- /dev/null +++ b/docs/js/redux.md @@ -0,0 +1,41 @@ +# Redux + +If you haven't worked with Redux, it's highly recommended (possibly indispensable!) +to read through the (amazing) [official documentation](http://redux.js.org) +and/or watch this [free video tutorial series](https://egghead.io/series/getting-started-with-redux). + +## Usage + +See above! As minimal as Redux is, the challenge it addresses - app state +management - is a complex topic that is too involved to properly discuss here. + +## Removing redux + +There are a few reasons why we chose to bundle redux with React Boilerplate, the +biggest being that it is widely regarded as the current best Flux implementation +in terms of architecture, support and documentation. + +You may feel differently! This is completely OK :) + +Below are a few reasons you might want to remove it: + +### I'm just getting started and Flux is hard + +You're under no obligation to use Redux or any other Flux library! The complexity +of your application will determine the point at which you need to introduce it. + +Here are a couple of great resources for taking a minimal approach: + +- [Misconceptions of Tooling in JavaScript](http://javascriptplayground.com/blog/2016/02/the-react-webpack-tooling-problem) +- [Learn Raw React — no JSX, no Flux, no ES6, no Webpack…](http://jamesknelson.com/learn-raw-react-no-jsx-flux-es6-webpack/) + +### It's overkill for my project! + +See above. + +### I prefer `(Alt|MobX|SomethingElse)`! + +React Boilerplate is a baseline for _your_ app: go for it! + +If you feel that we should take a closer look at supporting your preference +out of the box, please let us know. diff --git a/docs/js/remove.md b/docs/js/remove.md new file mode 100644 index 0000000..328cc5f --- /dev/null +++ b/docs/js/remove.md @@ -0,0 +1,24 @@ +## Removing `redux-saga` + +**We don't recommend removing `redux-saga`**, as we strongly feel that it's the +way to go for most redux based applications. + +If you really want to get rid of it, delete the `sagas/` folder, remove the +`import` and the `sagaMiddleware` from the `store.js` and finally remove it from +the `package.json`. Then you should be good to go with whatever side-effect +management library you want to use! + +## Removing `reselect` + +To remove `reselect`, delete the `app/selectors` folder, remove it from your +dependencies in `package.json` and then write your `mapStateToProps` functions +like you normally would! + +You'll also need to hook up the history directly to the store. Change the const +`history` in `app/app.js` to the following: + +```js +const history = syncHistoryWithStore(browserHistory, store, { + selectLocationState: (state) => state.get('route').toJS(), +}); +``` diff --git a/docs/js/reselect.md b/docs/js/reselect.md new file mode 100644 index 0000000..db7f705 --- /dev/null +++ b/docs/js/reselect.md @@ -0,0 +1,78 @@ +# `reselect` + +reselect memoizes ("caches") previous state trees and calculations based on said +tree. This means repeated changes and calculations are fast and efficient, +providing us with a performance boost over standard `mapStateToProps` +implementations. + +The [official documentation](https://github.com/reactjs/reselect) +offers a good starting point! + +## Usage + +There are two different kinds of selectors, simple and complex ones. + +### Simple selectors + +Simple selectors are just that: they take the application state and select a +part of it. + +```javascript +const mySelector = (state) => state.get('someState'); + +export { + mySelector, +}; +``` + +### Complex selectors + +If we need to, we can combine simple selectors to build more complex ones which +get nested state parts with reselect's `createSelector` function. We import other +selectors and pass them to the `createSelector` call: + +```javascript +import { createSelector } from 'reselect'; +import mySelector from 'mySelector'; + +const myComplexSelector = createSelector( + mySelector, + (myState) => myState.get('someNestedState') +); + +export { + myComplexSelector, +}; +``` + +These selectors can then either be used directly in our containers as +`mapStateToProps` functions or be nested with `createSelector` once again: + +```javascript +export default connect(createSelector( + myComplexSelector, + (myNestedState) => ({ data: myNestedState }) +))(SomeComponent); +``` + +### Adding a new selector + +If you have a `selectors.js` file next to the reducer which's part of the state +you want to select, add your selector to said file. If you don't have one yet, +add a new one into your container folder and fill it with this boilerplate code: + +```JS +import { createSelector } from 'reselect'; + +const selectMyState = () => createSelector( + +); + +export { + selectMyState, +}; +``` + +--- + +_Don't like this feature? [Click here](remove.md)_ diff --git a/docs/js/routing.md b/docs/js/routing.md new file mode 100644 index 0000000..8cacfa2 --- /dev/null +++ b/docs/js/routing.md @@ -0,0 +1,164 @@ +# Routing via `react-router` and `react-router-redux` + +`react-router` is the de-facto standard routing solution for react applications. +The thing is that with redux and a single state tree, the URL is part of that +state. `react-router-redux` takes care of synchronizing the location of our +application with the application state. + +(See the [`react-router-redux` documentation](https://github.com/reactjs/react-router-redux) +for more information) + +## Usage + +To add a new route, use the generator with `npm run generate route`. + +This is what a standard (generated) route looks like for a container: + +```JS +{ + path: '/', + name: 'home', + getComponent(nextState, cb) { + const importModules = Promise.all([ + System.import('containers/HomePage') + ]); + + const renderRoute = loadModule(cb); + + importModules.then(([component]) => { + renderRoute(component); + }); + + importModules.catch(errorLoading); + }, +} +``` + +To go to a new page use the `push` function by `react-router-redux`: + +```JS +import { push } from 'react-router-redux'; + +dispatch(push('/some/page')); +``` + +## Child Routes +`npm run generate route` does not currently support automatically generating child routes if you need them, but they can be easily created manually. + +For example, if you have a route called `about` at `/about` and want to make a child route called `team` at `/about/our-team` you can just add that child page to the parent page's `childRoutes` array like so: + +```JS +/* your app's other routes would already be in this array */ +{ + path: '/about', + name: 'about', + getComponent(nextState, cb) { + const importModules = Promise.all([ + System.import('containers/AboutPage'), + ]); + + const renderRoute = loadModule(cb); + + importModules.then(([component]) => { + renderRoute(component); + }); + + importModules.catch(errorLoading); + }, + childRoutes: [ + { + path: '/about/our-team', + name: 'team', + getComponent(nextState, cb) { + const importModules = Promise.all([ + System.import('containers/TeamPage'), + ]); + + const renderRoute = loadModule(cb); + + importModules.then(([component]) => { + renderRoute(component); + }); + + importModules.catch(errorLoading); + }, + }, + ] +} +``` + +## Dynamic routes + +To go to a dynamic route such as 'post/:slug' eg 'post/cool-new-post', firstly add the route to your `routes.js`, as per documentation: + +```JS +path: '/posts/:slug', +name: 'post', +getComponent(nextState, cb) { + const importModules = Promise.all([ + System.import('containers/Post/reducer'), + System.import('containers/Post/sagas'), + System.import('containers/Post'), + ]); + + const renderRoute = loadModule(cb); + + importModules.then(([reducer, sagas, component]) => { + injectReducer('post', reducer.default); + injectSagas(sagas.default); + renderRoute(component); + }); + + importModules.catch(errorLoading); +}, +``` + +###Container: + +```JSX + +``` + +Clickable link with payload (you could use push if needed). + +###Action: + +```JS +export function getPost(slug) { + return { + type: LOAD_POST, + slug, + }; +} + +export function postLoaded(post) { + return { + type: LOAD_POST_SUCCESS, + podcast, + }; +} +``` + +###Saga: + +```JS +const { slug } = yield take(LOAD_POST); +yield call(getXhrPodcast, slug); + +export function* getXhrPodcast(slug) { + const requestURL = `http://your.api.com/api/posts/${slug}`; + const post = yield call(request, requestURL); + if (!post.err) { + yield put(postLoaded(post)); + } else { + yield put(postLoadingError(post.err)); + } +} +``` + +Wait (`take`) for the LOAD_POST constant, which contains the slug payload from the `getPost()` function in actions.js. + +When the action is fired then dispatch the `getXhrPodcast()` function to get the response from your api. On success dispatch the `postLoaded()` action (`yield put`) which sends back the response and can be added into the reducer state. + + +You can read more on [`react-router`'s documentation](https://github.com/reactjs/react-router/blob/master/docs/API.md#props-3). diff --git a/docs/testing/README.md b/docs/testing/README.md new file mode 100644 index 0000000..21905c4 --- /dev/null +++ b/docs/testing/README.md @@ -0,0 +1,28 @@ +# Testing + +- [Unit Testing](unit-testing.md) +- [Component Testing](component-testing.md) +- [Remote Testing](remote-testing.md) + +Testing your application is a vital part of serious development. There are a few +things you should test. If you've never done this before start with [unit testing](unit-testing.md). +Move on to [component testing](component-testing.md) when you feel like you +understand that! + +We also support [remote testing](remote-testing.md) your local application, +which is quite awesome, so definitely check that out! + +## Usage with this boilerplate + +To test your application started with this boilerplate do the following: + +1. Sprinkle `.test.js` files directly next to the parts of your application you + want to test. (Or in `test/` subdirectories, it doesn't really matter as long + as they are directly next to those parts and end in `.test.js`) + +1. Write your unit and component tests in those files. + +1. Run `npm run test` in your terminal and see all the tests pass! (hopefully) + +There are a few more commands related to testing, checkout the [commands documentation](../general/commands.md#testing) +for the full list! diff --git a/docs/testing/component-testing.md b/docs/testing/component-testing.md new file mode 100644 index 0000000..9bceb33 --- /dev/null +++ b/docs/testing/component-testing.md @@ -0,0 +1,160 @@ +# Component testing + +[Unit testing your Redux actions and reducers](unit-testing.md) is nice, but you +can do even more to make sure nothing breaks your application. Since React is +the _view_ layer of your app, let's see how to test Components too! + + + +- [Shallow rendering](#shallow-rendering) +- [Enzyme](#enzyme) + + + +## Shallow rendering + +React provides us with a nice add-on called the Shallow Renderer. This renderer +will render a React component **one level deep**. Lets take a look at what that +means with a simple ` + ); +} + +export default Button; +``` + +_Note: This is a [state**less** ("dumb") component](../js/README.md#architecture-components-and-containers)_ + +It might be used in another component like this: + +```javascript +// HomePage.react.js + +import Button from './Button.react'; + +class HomePage extends React.Component { + render() { + return( + + ); + } +} +``` + +_Note: This is a [state**ful** ("smart") component](../js/README.md#architecture-components-and-containers)!_ + +When rendered normally with the standard `ReactDOM.render` function, this will +be the HTML output +(*Comments added in parallel to compare structures in HTML from JSX source*): + +```html + +``` + +Conversely, when rendered with the shallow renderer, we'll get a String +containing this "HTML": + +```html + +``` + +If we test our `Button` with the normal renderer and there's a problem +with the `CheckmarkIcon` then the test for the `Button` will fail as well... +but finding the culprit will be hard. Using the _shallow_ renderer, we isolate +the problem's cause since we don't render any other components other than the +one we're testing! + +The problem with the shallow renderer is that all assertions have to be done +manually, and you cannot do anything that needs the DOM. + +Thankfully, [AirBnB](https://twitter.com/AirbnbEng) has open sourced their +wrapper around the React shallow renderer and jsdom, called `enzyme`. `enzyme` +is a testing utility that gives us a nice assertion/traversal/manipulation API. + +## Enzyme + +Lets test our ` + ); + expect( + renderedComponent.find("button").node + ).toExist(); +}); +``` + +Nice! If somebody breaks our button component by having it render an `` tag +or something else we'll immediately know! Let's do something a bit more advanced +now, and check that our ` + ); + expect( + renderedComponent.contains(text) + ).toEqual(true); +}); +``` + +Great! Onwards to our last and most advanced test: checking that our `