feat : pwa-studio

This commit is contained in:
Jordan Morin
2019-03-07 09:41:12 +01:00
parent 66521f72b0
commit d3381d4728
962 changed files with 90474 additions and 1 deletions
Submodule www/website/www/pwa-studio deleted from fefd8d5b89
+31
View File
@@ -0,0 +1,31 @@
{
"plugins": [
"syntax-dynamic-import",
"transform-object-rest-spread",
"transform-class-properties",
"transform-react-jsx",
["transform-runtime", {
"helpers": true,
"polyfill": false, // polyfills will be handled by preset-env
"regenerator": false
}]
],
"presets": [
["env", {
"targets": {
"browsers": ["> 5%"]
},
"modules": false
}]
],
"env": {
"test": {
"plugins": [
"transform-es2015-modules-commonjs",
"transform-object-rest-spread",
"transform-class-properties",
"transform-react-jsx"
]
}
}
}
@@ -0,0 +1,86 @@
version: 2
workflows:
version: 2
build-deploy:
jobs:
- build:
filters:
branches:
ignore: master
- master:
filters:
branches:
only: master
# This key means nothing to CircleCI; it's just a place to keep anchored
# configuration nodes for reuse.
common_settings:
docker: &docker_setup
- image: 'circleci/node:10.14.1'
# Cache a bunch of stuff
# - npm's local tarball cache
save_cache: &savecache
paths:
- ~/.ssh
- ~/.npm
- /root/.npm
key: 'v3-npm-cache-{{ checksum "package-lock.json" }}'
install_latest_npm: &install_latest_npm
name: Ensure NPM is up to date
command: sudo npm install -g npm@latest
install_packages: &install_packages
name: Install NPM packages from lockfile
command: npm ci --ignore-scripts && npx lerna bootstrap --hoist --no-ci
full_build: &full_build
name: Full Build
command: 'cp packages/venia-concept/.env.dist packages/venia-concept/.env && npm run build'
test_result_path: &test_result_path
path: "test-results"
artifact_storage_path: &artifact_storage_path
path: "packages/venia-concept/dist"
jobs:
master:
docker: *docker_setup
steps:
- checkout
- run: *install_latest_npm
- run: *install_packages
- run:
name: Test Suites
command: 'npm run test:ci'
- run:
name: Coveralls Coverage Analysis
command: npm run coveralls
- run: *full_build
- store_test_results: *test_result_path
- store_artifacts: *artifact_storage_path
build:
docker: *docker_setup
steps:
- checkout
- restore_cache:
keys:
- 'v3-npm-cache-{{ checksum "package-lock.json" }}'
- run: *install_latest_npm
- run: *install_packages
- save_cache: *savecache
- run:
name: Test Suites and Coverage
# Test failures should not stop Danger, so hide the exit code.
command: 'npm run test:ci && npm run coveralls || true'
- run: *full_build
- run:
name: DangerCI
command: npm run danger
- run:
name: Bundle size analysis
command: npm run bundlesize
- store_test_results: *test_result_path
- store_artifacts: *artifact_storage_path
+16
View File
@@ -0,0 +1,16 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[{package.json,*.yml,*.md}]
indent_size = 2
indent_style = space
[*.md]
trim_trailing_whitespace = false
@@ -0,0 +1,3 @@
__fixtures__
dist
pwa-devdocs
+6
View File
@@ -0,0 +1,6 @@
const config = {
parser: 'babel-eslint',
extends: ['@magento']
};
module.exports = config;
@@ -0,0 +1 @@
package-lock.json -diff
+73
View File
@@ -0,0 +1,73 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance, race,
religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or
advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic
address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
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.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at pwa@magento.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
+104
View File
@@ -0,0 +1,104 @@
# Contributing
Thank you for your interest in contributing to the PWA Studio project! Before you start contributing, please take a moment to read through the following guidelines:
- [Code of Conduct]
- [Support]
To contribute to this repository, start by forking the [official repository] and following the installation instructions in the README file.
## Pull Request checklist
- PR commits should contain [meaningful commit messages]
- To help with reviews, your PR should only create/revise a single feature or fix a single issue.
- If your PR fixes a bug, please provide a step-by-step description of how to reproduce the bug.
- If your PR addresses an existing issue, please reference that issue in the title or description.
- If your PR adds a feature with a public API, please add or update documentation in the README for the relevant package.
**Note:**
_As a developer, please write developer-facing documentation in README or other Markdown files in the relevant package files. Do not add documentation directly to the `pwa-devdocs` folder. That folder is maintained by the Technical Writing team, who will revise and proofread your documentation and migrate it to the devdocs site._
## Contribution process
Magento maintains a public roadmap for this and other [Magento Research] repositories in each project's issue board.
Any and all community participation in this backlog is encouraged and appreciated.
Even foundational infrastructure stories are available for a generous developer to take on.
To get started, look for issues tagged with the **[help wanted]** labels.
These issues are ready for community ownership.
**Note:**
_We also accept unsolicited new issues/features and pull request, but priority is given to issues in our roadmap that community developers have been kind enough to take on._
### Claiming an issue on the roadmap
If you are interested in taking ownership of a roadmap feature or issue, we ask that you go through the following process.
This helps us organize and forecast the progress of our projects.
#### Step 1: Add an issue comment
Add a comment on an issue expressing your interest in taking ownership of it.
Make sure your GitHub profile includes an email address so we can contact you privately.
#### Step 2: Meet with a maintainer
A maintainer will contact you and ask to set up a real-time meeting to discuss the issue you are interested in owning.
This meeting can be in person, video chat, audio chat, or text chat.
In general this meeting is brief but can vary with the complexity of an issue.
For larger issues, we may schedule follow-up meetings.
During this meeting, we provide you with any additional materials or resources you need to work on the issue.
#### Step 3: Provide an estimate
We ask that you provide us an estimate of how long it will take you to complete the issue.
If you require more time to provide a time frame for completion, you are allowed to take up to five business days to think about it.
If you can't get back to us by that time, we understand!
As a community developer, you are helping us out in addition to your regular job.
We will un-assign you from this issue, but please feel free to contribute to another issue.
#### Step 4: Work on the issue
After you provide an estimate, the issue is now "in progress", and
you officially become a member of the [Magento Research] organization.
If you need more time to work on the issue, please contact us as soon as possible.
We may request an update on your progress, but we are willing to accommodate.
If the deadline you provided to us passes and we have not heard from you, we will wait one week before un-assigning you from the issue.
#### Step 5: Create a pull request
When you finish working on an issue, create a pull request with the issue number included in the title or body.
This starts the (brief) code review process.
After we accept and merge your contribution, you become an official contributor!
Official contributors are invited to our backlog grooming sessions and have direct influence over the product roadmap.
We hope this guide paints a clear picture of your duties and expectations in the contribution process. Thank you in advance for helping with our research projects!
## Report an issue
Create a [GitHub issue] and put an **X** in the appropriate box to report an issue with the project.
Provide as much detail as you can in each section to help us triage and process the issue.
### Issue types
- Bug - An error, flaw, or failure in the code
- Feature suggestion - A missing feature you would like to see implemented in the project
- Other - Any other type of task related to the project
**Note:**
_Please avoid creating GitHub issues asking for help on bugs in your project that are outside the scope of this project._
[code of conduct]: CODE_OF_CONDUCT.md
[support]: SUPPORT.md
[official repository]: https://github.com/magento-research/pwa-studio
[meaningful commit messages]: https://chris.beams.io/posts/git-commit/
[github issue]: https://github.com/magento-research/pwa-studio/issues/new
[magento research]: https://github.com/magento-research
[help wanted]: https://github.com/magento-research/pwa-studio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22
@@ -0,0 +1,59 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: bug
assignees: ''
---
<!--
Thank you for taking the time to report this issue!
GitHub Issues should only be created for problems/topics related to this project's codebase.
Before submitting this issue, please make sure you are complying with our Code of Conduct:
https://github.com/magento-research/pwa-studio/blob/develop/.github/CODE_OF_CONDUCT.md
Issues that do not comply with our Code of Conduct or do not contain enough information may be closed at the maintainers' discretion.
Feel free to remove this section before creating this issue.
-->
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Please complete the following device information:**
- Device: [e.g. iPhone6, PC]
- OS: [e.g. iOS8.1, Windows 10]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
- Magento Version
- NPM version `npm -v`
- Node Version `node -v`
**Please let us know what packages this bug is in regards to:**
- [ ] `venia-concept`
- [ ] `pwa-buildpack`
- [ ] `peregrine`
- [ ] `pwa-devdocs`
- [ ] `upward-js`
- [ ] `upward-spec`
**Additional context**
Add any other context about the problem here.
**Possible solutions**
Add any ideas about possible solutions to the problem here.
@@ -0,0 +1,40 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE]"
labels: enhancement
assignees: ''
---
<!--
Thank you for taking the time to report this issue!
GitHub Issues should only be created for problems/topics related to this project's codebase.
Before submitting this issue, please make sure you are complying with our Code of Conduct:
https://github.com/magento-research/pwa-studio/blob/develop/.github/CODE_OF_CONDUCT.md
Issues that do not comply with our Code of Conduct or do not contain enough information may be closed at the maintainers' discretion.
Feel free to remove this section before creating this issue.
-->
**Please let us know what packages this feature is in regards to:**
- [ ] `venia-concept`
- [ ] `pwa-buildpack`
- [ ] `peregrine`
- [ ] `pwa-devdocs`
- [ ] `upward-js`
- [ ] `upward-spec`
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -0,0 +1,53 @@
<!--
Thank you for your contribution!
Before submitting this pull request, please make sure you have read our Contribution Guidelines and your PR meets our contribution standards:
https://github.com/magento-research/pwa-studio/blob/master/.github/CONTRIBUTION.md
Please fill out as much information as you can about your PR to help speed up the review process.
If your PR addresses an existing GitHub Issue, please refer to it in the title or Additional Information section to make the connection.
We may ask you for changes in your PR in order to meet the standards set in our Contribution Guidelines. PRs that do not comply with our guidelines may be closed at the maintainers' discretion.
Feel free to remove this section before creating this PR.
-->
## Description
<!--- Provide a general summary of your changes in the Title above -->
<!--- Describe your changes in detail here: -->
## Related Issue
<!--- This project only accepts pull requests related to open issues -->
<!--- If suggesting a new feature or change, please discuss it in an issue first -->
<!--- If fixing a bug, there should be an issue describing it with steps to reproduce -->
<!--- Please link to the issue here with the following wording: Closes #<issue> -->
Closes #ISSUENUM.
## Motivation and Context
<!--- Why is this change required? What problem does it solve? -->
## How Has This Been Tested?
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->
## Screenshots (if appropriate):
## Proposed Labels for Change Type/Package
<!--- What types of changes does your code introduce? Let us know if this is a -->
<!--- BUG, FEATURE, DOCUMENTATION, or TEST change. -->
<!--- What packages are modified by this code? Let us know if this applies to -->
<!--- peregrine, pwa-buildpack, upward-js, upward-spec, venia-concept or pwa-devdocs -->
## Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [ ] I have read the **CONTRIBUTING** document.
- [ ] I have linked an issue to this PR.
- [ ] I have indicated the change type and relevant package(s).
- [ ] I have updated the documentation accordingly.
- [ ] I have added tests to cover my changes.
- [ ] All new and existing tests passed.
- [ ] All CI checks are green (linting, build/deploy, etc).
- [ ] At least one core contributor has approved this PR.
+10
View File
@@ -0,0 +1,10 @@
# Support
Need help with something? Please use the following resources to get the help you need:
- Documentation website - [PWA DevDocs]
- Chat with us on **Slack** - [#pwa channel]
- Send us an Email: pwa@magento.com
[pwa devdocs]: https://magento-research.github.io/pwa-studio/
[#pwa channel]: https://magentocommeng.slack.com/messages/C71HNKYS2
+13
View File
@@ -0,0 +1,13 @@
node_modules
npm-debug.log
.DS_Store
.vscode
coverage
test-results
dist
storybook-dist
.idea
test-report.xml
test-results.json
lerna-debug.log
.env
+1
View File
@@ -0,0 +1 @@
save-prefix="~"
@@ -0,0 +1,4 @@
coverage
package-lock.json
dist
pwa-devdocs
+48
View File
@@ -0,0 +1,48 @@
Open Software License ("OSL") v. 3.0
This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work:
Licensed under the Open Software License version 3.0
1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following:
1. to reproduce the Original Work in copies, either alone or as part of a collective work;
2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work;
3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License;
4. to perform the Original Work publicly; and
5. to display the Original Work publicly.
2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works.
3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work.
4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license.
5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c).
6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work.
7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer.
8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation.
9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c).
10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware.
11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License.
12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License.
13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable.
14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You.
16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under <insert your license name here>" or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process.
@@ -0,0 +1,48 @@
Academic Free License ("AFL") v. 3.0
This Academic Free License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work:
Licensed under the Academic Free License version 3.0
1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following:
1. to reproduce the Original Work in copies, either alone or as part of a collective work;
2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work;
3. to distribute or communicate copies of the Original Work and Derivative Works to the public, under any license of your choice that does not contradict the terms and conditions, including Licensor's reserved rights and remedies, in this Academic Free License;
4. to perform the Original Work publicly; and
5. to display the Original Work publicly.
2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works.
3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work.
4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license.
5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c).
6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work.
7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer.
8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation.
9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including "fair use" or "fair dealing"). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c).
10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware.
11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License.
12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License.
13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable.
14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You.
16. Modification of This License. This License is Copyright © 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Academic Free License" or "AFL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under <insert your license name here>" or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process.
+131
View File
@@ -0,0 +1,131 @@
# PWA Studio
[![CircleCI](https://circleci.com/gh/magento-research/pwa-studio.svg?style=svg)](https://circleci.com/gh/magento-research/pwa-studio) [![Coverage Status](https://coveralls.io/repos/github/magento-research/pwa-studio/badge.svg)](https://coveralls.io/github/magento-research/pwa-studio)
Magento PWA Studio is a collection of tools that lets developers build complex Progressive Web Applications on top of Magento 2 stores.
## Documentation
[PWA Studio documentation site][documentation site]
## Community Contributors
The PWA Studio project welcomes all codebase and documentation contributions.
We would like to recognize the following community members for their efforts on improving the PWA Studio project:
| [![mage2pratik-image]][mage2pratik] | [![vdiachenko-image]][vdiachenko] | [![jissereitsma-image]][jissereitsma] | [![rossmc-image]][rossmc] |
| :---: | :---: | :---: | :---: |
| [mage2pratik][] | [vdiachenko][] | [jissereitsma][] | [rossmc][] |
| [![bobmotor-image]][bobmotor] | [<img src="https://avatars1.githubusercontent.com/u/12770320?s=60&v=4" width="120px"/>][gavin2point0] | [![neeta-wagento-image]][neeta-wagento] | [![mtbottens-image]][mtbottens] |
| :---: | :---: | :---: | :---: |
| [bobmotor][] | [gavin2point0][] | [neeta-wagento][] | [mtbottens][] |
| [![Jakhotiya-image]][Jakhotiya] | [![JStein92-image]][JStein92] | [![bgkavinga-image]][bgkavinga] | [![philwinkle-image]][philwinkle] |
| :---: | :---: | :---: | :---: |
| [Jakhotiya][] | [JStein92][] | [bgkavinga][] | [philwinkle][] |
| [![bobbyshaw-image]][bobbyshaw] | [![matthewhaworth-image]][matthewhaworth] | [![shakyShane-image]][shakyShane] | [![Igloczek-image]][Igloczek] |
| :---: | :---: | :---: | :---: |
| [bobbyshaw][] | [matthewhaworth][] | [shakyShane][] | [Igloczek][] |
| [![mhhansen-image]][mhhansen] | [![rowan-m-image]][rowan-m] | [![artKozinets-image]][artKozinets] | [![camdixon-image]][camdixon] |
| :---: | :---: | :---: | :---: |
| [mhhansen][] | [rowan-m][] | [artKozinets][] | [camdixon][] |
For more information about contributing to this repository, see the [Contribution guide][].
## About this repository
To ease local development, testing, and versioning, the PWA Studio project uses a monorepo, with package management orchestrated by [lerna](https://github.com/lerna/lerna#about).
All packages are versioned in a single repo, but released to `npm` as independent packages.
## Lerna Packages
This repository includes the following packages managed by lerna:
* [venia-concept](packages/venia-concept) - Reference/Concept Storefront
* [pwa-buildpack](packages/pwa-buildpack/README.md) - Build tooling
* [peregrine](packages/peregrine/README.md) - eCommerce Component Library
* [upward-js](packages/upward-js) - Reference implementation of the UPWARD specification
* [upward-spec](packages/upward-spec) - UPWARD specification and test suite
## Other Packages
This repository also includes modules that are not managed by Lerna, because
they are not meant to be distributed via NPM, and/or they should not have their
dependencies centrally managed by Lerna.
* [pwa-devdocs](pwa-devdocs) - Project source for the [documentation site]
## Quick Setup
See the [Venia storefront setup][] topic for instructions on installing this project's dependencies and running the Venia storefront on top of an existing Magento backend.
## Troubleshooting
See our [Troubleshooting][] guide if you run into any problems.
If you have an issue that cannot be resolved, please [create an issue][].
## Things not to do
* Our monorepo is set up so that `npm install` can cross-link dependencies (such as Venia's dependency on Buildpack) without any extra tools. Do not run `lerna bootstrap`.
* All devDependencies are installed in the repository root. This means that **all scripts must be run from repository root**; otherwise, the locally installed CLI commands they use will not be available.
* Production dependencies are sometimes installed in child packages, but for some projects, such as Venia, it makes no sense to have production dependencies, because of bundling.
* Don't check in a big change to `package-lock.json`, and don't check in any `package-lock.json` files but the root one.
* Make sure to run `npm run prettier` and `npm run lint` before any commit you intend to push. You may want to set up a [Git hook] for this.
[documentation site]: https://magento-research.github.io/pwa-studio/
[CircleCI]: https://circleci.com/gh/magento-research/pwa-studio.svg?style=svg
[Coverage Status]: https://coveralls.io/repos/github/magento-research/pwa-studio/badge.svg?branch=master
[Greenkeeper badge]: https://badges.greenkeeper.io/magento-research/pwa-studio.svg
[Contribution guide]: .github/CONTRIBUTING.md
[Git hook]: <https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks>
[Venia storefront setup]: https://magento-research.github.io/pwa-studio/venia-pwa-concept/setup/
[Troubleshooting]: https://magento-research.github.io/pwa-studio/pwa-buildpack/troubleshooting/
[create an issue]: https://github.com/magento-research/pwa-studio/issues/new
[mage2pratik]: https://github.com/mage2pratik
[mage2pratik-image]: https://avatars0.githubusercontent.com/u/33807558?s=120&v=4
[vdiachenko]: https://github.com/vdiachenko
[vdiachenko-image]: https://avatars0.githubusercontent.com/u/7806034?s=120&v=4
[jissereitsma]: https://github.com/jissereitsma
[jissereitsma-image]: https://avatars0.githubusercontent.com/u/7670482?s=120&v=4
[rossmc]: https://github.com/rossmc
[rossmc-image]: https://avatars3.githubusercontent.com/u/2452991?s=120&v=4
[bobmotor]: https://github.com/bobmotor
[bobmotor-image]: https://avatars3.githubusercontent.com/u/9715167?s=120&v=4
[gavin2point0]: https://github.com/gavin2point0
[gavin2point0-image]: https://avatars1.githubusercontent.com/u/12770320?s=60&v=4
[neeta-wagento]: https://github.com/neeta-wagento
[neeta-wagento-image]: https://avatars3.githubusercontent.com/u/33098216?s=120&v=4
[mtbottens]: https://github.com/mtbottens
[mtbottens-image]: https://avatars0.githubusercontent.com/u/3620915?s=120&v=4
[Jakhotiya]: https://github.com/Jakhotiya
[Jakhotiya-image]: https://avatars1.githubusercontent.com/u/9327315?s=120&v=4
[JStein92]: https://github.com/JStein92
[JStein92-image]: https://avatars0.githubusercontent.com/u/27716099?s=120&v=4
[bgkavinga]: https://github.com/bgkavinga
[bgkavinga-image]: https://avatars2.githubusercontent.com/u/3830093?s=120&v=4
[philwinkle]: https://github.com/philwinkle
[philwinkle-image]: https://avatars3.githubusercontent.com/u/589550?s=120&v=4
[bobbyshaw]: https://github.com/bobbyshaw
[bobbyshaw-image]: https://avatars3.githubusercontent.com/u/553566?s=120&v=4
[matthewhaworth]: https://github.com/matthewhaworth
[matthewhaworth-image]: https://avatars2.githubusercontent.com/u/920191?s=120&v=4
[shakyShane]: https://github.com/shakyShane
[shakyShane-image]: https://avatars2.githubusercontent.com/u/1643522?s=120&v=4
[Igloczek]: https://github.com/Igloczek
[Igloczek-image]: https://avatars0.githubusercontent.com/u/5119280?s=120&v=4
[mhhansen]: https://github.com/mhhansen
[mhhansen-image]: https://avatars1.githubusercontent.com/u/1625755?s=120&v=4
[rowan-m]: https://github.com/rowan-m
[rowan-m-image]: https://avatars3.githubusercontent.com/u/108052?s=120&v=4
[artKozinets]: https://github.com/artKozinets
[artKozinets-image]: https://avatars0.githubusercontent.com/u/22525219?s=120&v=4
[camdixon]: https://github.com/camdixon
[camdixon-image]: https://avatars2.githubusercontent.com/u/4430359?s=120&v=4
+305
View File
@@ -0,0 +1,305 @@
const fs = require('fs');
const path = require('path');
const execa = require('execa');
const mkdirp = require('mkdirp');
const xml = require('xml');
const { fail, warn, markdown, danger } = require('danger');
const prettierVersion = require('prettier/package.json').version;
const eslintJUnitReporter = require('eslint/lib/formatters/junit');
const fromRoot = p => path.relative('', p);
const packageNames = {
'venia-concept': 'Venia',
'pwa-buildpack': 'Buildpack',
peregrine: 'Peregrine'
};
const pathToPackageName = filepath => {
const packageDir = path
.normalize(path.relative('packages', filepath))
.split(path.sep)[0];
return packageNames[packageDir] || packageDir;
};
const reportDir = './test-results/';
const reportFile = name => {
const subdir = path.join(reportDir, name);
mkdirp.sync(subdir);
return path.join(subdir, 'results.xml');
};
const fence = '```';
const codeFence = str => `${fence}\n${str.trim()}\n${fence}`;
function timer() {
const msPerSec = 1e3;
const nsPerMillisec = 1e6;
const formatTime = ([seconds, nanoseconds]) =>
Math.round(seconds * msPerSec + nanoseconds / nsPerMillisec) / msPerSec;
const startTime = process.hrtime();
let lastLap = startTime;
return {
lap() {
const lapTime = process.hrtime(lastLap);
lastLap = process.hrtime();
return formatTime(lapTime);
},
stop() {
return formatTime(process.hrtime(startTime));
}
};
}
function jUnitSuite(title) {
const stopwatch = timer();
let failureCount = 0;
let errorCount = 0;
const cases = [];
function testCase(name, type, message, trace) {
const tcAttrs = {
_attr: { classname: '', name, time: stopwatch.lap() }
};
return {
testcase: type
? [
tcAttrs,
{
[type]: trace
? { _attr: { message }, _cdata: trace }
: {
_attr: { message }
}
}
]
: [tcAttrs]
};
}
return {
pass(name) {
cases.push(testCase(name));
},
fail(name, message, trace) {
cases.push(testCase(name, 'failure', message, trace));
failureCount++;
},
error(name, message, trace) {
cases.push(testCase(name, 'error', message, trace));
errorCount++;
},
save(filename) {
const time = stopwatch.stop();
fs.writeFileSync(
filename,
xml({
testsuites: [
{
_attr: {
tests: cases.length,
failures: failureCount,
time
}
},
{
testsuite: [
{
_attr: {
name: title,
errors: errorCount,
failures: failureCount,
skipped: 0,
timestamp: new Date().toISOString(),
time,
tests: cases.length
}
},
...cases
]
}
]
}),
'utf8'
);
}
};
}
const tasks = [
function prettierCheck() {
const junit = jUnitSuite('Prettier');
let stdout, stderr;
try {
const result = execa.sync('npm', [
'run',
'--silent',
'prettier:check',
'--',
'--loglevel=debug'
]);
stdout = result.stdout;
stderr = result.stderr;
} catch (err) {
stdout = err.stdout;
stderr = err.stderr;
}
const failedFiles = stdout.split('\n').filter(s => s.trim());
// Prettier doesn't normally print the files it covered, but in debug
// mode, you can extract them with these regex (as of Prettier 1.13.5)
// This is a hack based on debug output not guaranteed to stay the same.
const errorLineStartRE = /^\[error\]\s*/;
const errors = stderr.match(/(\[error\].+?\n)+/gim);
const errorMap = {};
if (errors) {
errors.forEach(block => {
const lines = block.split('\n[error] ');
const firstLine = lines.shift();
if (errorLineStartRE.test(firstLine)) {
// parseable
const [name, message] = firstLine
.replace(errorLineStartRE, '')
.split(':')
.map(s => s.trim());
if (name && message) {
errorMap[name] = {
message,
trace: lines.join('\n')
};
}
}
});
}
const coveredFiles = stderr.match(
/\[debug\]\s*resolve config from '[^']+'\n/gim
);
if (!coveredFiles || coveredFiles.length === 0) {
let warning = 'Prettier did not appear to cover any files.';
if (prettierVersion !== '1.13.5') {
warning +=
'\nThis may be due to an unexpected change in debug output in a version of Prettier later than 1.13.5.';
}
warn(warning);
}
coveredFiles.forEach(line => {
const filename = line.match(/'([^']+)'/)[1];
if (errorMap[filename]) {
junit.error(
filename,
errorMap[filename].message,
errorMap[filename].trace
);
} else if (failedFiles.includes(filename)) {
junit.fail(filename, 'was not formatted with Prettier');
} else {
junit.pass(filename);
}
});
junit.save(reportFile('prettier'));
if (failedFiles.length > 0) {
fail(
'The following file(s) were not ' +
'formatted with **prettier**. Make sure to execute `npm run prettier` ' +
`locally prior to committing.\n${codeFence(stdout)}`
);
}
},
function eslintCheck() {
const stopwatch = timer();
let stdout;
try {
({ stdout } = execa.sync('npm', [
'run',
'--silent',
'lint',
'--',
'-f',
'json'
]));
} catch (err) {
({ stdout } = err);
}
const results = JSON.parse(stdout);
// TODO: build as XML DOM so we can customize
const eslintXml = eslintJUnitReporter(results);
const eslintXmlWithTime = eslintXml.replace(
/testsuite package="org\.eslint" time="0"/m,
`testsuite package="org.eslint" time="${stopwatch.stop()}"`
);
fs.writeFileSync(reportFile('eslint'), eslintXmlWithTime, 'utf8');
const errFiles = results
.filter(r => r.errorCount)
.map(r => fromRoot(r.filePath));
if (errFiles.length > 0) {
fail(
'The following file(s) did not pass **ESLint**. Execute ' +
'`npm run lint` locally for more details\n' +
codeFence(errFiles.join('\n'))
);
}
},
function unitTests() {
let summary;
try {
summary = require('./test-results.json');
} catch (e) {
execa.sync('npm', ['run', '-s', 'test:ci']);
summary = require('./test-results.json');
}
const failedTests = summary.testResults.filter(
t => t.status !== 'passed'
);
if (failedTests.length === 0) {
return;
}
// prettier-ignore
const failSummary = failedTests.map(t =>
`<details>
<summary>${fromRoot(t.name)}</summary>
<pre>${t.message}</pre>
</details>`
).join('\n');
fail(
'The following unit tests did _not_ pass 😔. ' +
'All tests must pass before this PR can be merged\n\n\n' +
failSummary
);
}
// function mergeJunitReports() {
// execa.sync('junit-merge', [
// '--dir',
// reportDir,
// '--out',
// reportFile('all-junit.xml')
// ]);
// }
// Disabled for now, but leaving in for future implementation.
// Can't use right now due to the lack of permissions granularity
// in GitHub
// async function addProjectLabels() {
// const allChangedFiles = [
// ...danger.git.created_files,
// ...danger.git.deleted_files,
// ...danger.git.modified_files
// ];
// const touchedPackages = allChangedFiles.reduce((touched, path) => {
// const matches = path.match(/packages\/([\w-]+)\//);
// return matches ? touched.add(matches[1]) : touched;
// }, new Set());
// if (!touchedPackages.size) return;
// await danger.github.api.issues.addLabels(
// Object.assign({}, danger.github.thisPR, {
// labels: Array.from(touchedPackages).map(s => `pkg:${s}`)
// })
// );
// }
];
(async () => {
for (const task of tasks) await task();
})();
+198
View File
@@ -0,0 +1,198 @@
/**
* Centralized Jest configuration file for all projects in repo.
* This file uses Jest `projects` configuration and a couple of undocumented
* hacks to get around some known issues in Jest configuration and coverage of
* monorepos.
*/
const path = require('path');
/**
* `configureProject()` makes a config object for use in the `projects` array.
*
* Each config object may use several root-relative paths to files in its
* package folder. Instead of repetitive strings, like:
*
* {
* name: 'peregrine',
* displayName: 'Peregrine',
* testMatch: '<rootDir>/packages/peregrine/**\/__tests__/*.(test|spec).js'
* setupFiles: [
* '<rootDir>/packages/peregrine/scripts/shim.js'
* '<rootDir>/packages/peregrine/scripts/fetch-mock.js'
* ]
* }
*
* Provide a convenience function via a callback, so the caller can provide
* a configuration function which receives a path builder.
*
* configureProject('peregrine', 'Peregrine', inPackage => ({
* setupFiles: [
* inPackage('scripts/shim.js'),
* inPackage('scripts/fetch-mock.js')
* ],
* }))
*
*/
// Reusable glob string for building `testMatch` patterns.
const testGlob = '/**/__tests__/*.(test|spec).js';
const configureProject = (dir, displayName, cb) =>
// Defaults that every project config must include.
// Jest should properly merge some of these in from the root configuration,
// but it doesn't: https://github.com/facebook/jest/issues/7268
Object.assign(
{
// Set all projects to use the repo root as `rootDir`,
// to work around https://github.com/facebook/jest/issues/7359
rootDir: __dirname,
// Use the dir as a unique "name" property to each config, to force
// Jest to use different `jest-resolve` instances for each project.
// This is an undocumented workaround:
// https://github.com/facebook/jest/issues/6887#issuecomment-417170450
name: dir,
// Displays in the CLI.
displayName,
// All projects run in the context of the repo root, so each project
// must specify manually that it only runs tests in its package
// directory.
testMatch: [path.join('<rootDir>', 'packages', dir, testGlob)],
// All project must clear mocks before every test,
clearMocks: true
},
// Pass a function which builds paths inside this project to a callback
// which returns any additional properties.
cb(path.join.bind(path, '<rootDir>', 'packages', dir))
);
const jestConfig = {
projects: [
configureProject('peregrine', 'Peregrine', inPackage => ({
// Expose jsdom to tests.
browser: true,
setupFiles: [
// Shim DOM properties not supported by jsdom
inPackage('scripts/shim.js'),
// Always mock `fetch` instead of doing real network calls
inPackage('scripts/fetch-mock.js')
],
// Set up Enzyme React 16 adapter for testing React components
setupTestFrameworkScriptFile: path.join(
'<rootDir>',
'scripts',
'jest-enzyme-setup.js'
),
// Give jsdom a real URL for router testing.
testURL: 'https://localhost/'
})),
configureProject('pwa-buildpack', 'Buildpack', () => ({
testEnvironment: 'node'
})),
configureProject('upward-js', 'Upward JS', () => ({
testEnvironment: 'node'
})),
configureProject('venia-concept', 'Venia Concept', inPackage => ({
// Expose jsdom to tests.
browser: true,
moduleNameMapper: {
// Peregrine imports a virtual module that must be mocked.
// It would be nice if Venia respected a mock in Peregrine,
// but it doesn't, so Venia tests will fail without this.
'^FETCH_ROOT_COMPONENT$': inPackage(
'__mocks__/virtualModule.js'
),
// Mock binary files to avoid excess RAM usage.
'\\.(jpg|jpeg|png)$': inPackage('__mocks__/fileMock.js'),
// CSS module classes are dynamically generated, but that makes
// it hard to test React components using DOM classnames.
// This mapping forces CSS Modules to return literal identies,
// so e.g. `classes.root` is always `"root"`.
'\\.css$': 'identity-obj-proxy',
'\\.svg$': 'identity-obj-proxy',
// Re-write imports to Peregrine to ensure they're not pulled
// from the build artifacts on disk in `dist`.
'^@magento/peregrine(/*(?:.+)*)':
'<rootDir>/packages/peregrine/src/$1'
},
// Reproduce the Webpack resolution config that lets Venia import
// from `src` instead of with relative paths:
modulePaths: [
inPackage(),
inPackage('node_modules'),
'<rootDir>/node_modules'
],
// Set up Enzyme React 16 adapter for testing React components
setupTestFrameworkScriptFile: path.join(
'<rootDir>',
'scripts',
'jest-enzyme-setup.js'
),
// Give jsdom a real URL for router testing.
testURL: 'https://localhost/',
transform: {
// Reproduce the Webpack `graphql-tag/loader` that lets Venia
// import `.graphql` files into JS.
'\\.(gql|graphql)$': 'jest-transform-graphql',
// Use the default babel-jest for everything else.
'.*': 'babel-jest'
},
// Normally babel-jest ignores node_modules and only transpiles the
// current package's source. This forces babel-jest to transpile
// Peregrine as well, when it's testing Venia. That way, Peregrine
// changes don't require a full compile.
transformIgnorePatterns: ['node_modules/(?!@magento/peregrine)']
})),
// Test any root CI scripts as well, to ensure stable CI behavior.
configureProject('scripts', 'CI Scripts', () => ({
testEnvironment: 'node',
testMatch: [`<rootDir>/scripts/${testGlob}`]
}))
],
// Include files with zero tests in overall coverage analysis by specifying
// coverage paths manually.
collectCoverage: true,
collectCoverageFrom: [
// Code directories
'packages/*/{src,lib}/**/*.js',
// Not node_modules
'!**/node_modules/**',
// Not __tests__, __helpers__, or __any_double_underscore_folders__
'!**/__[[:alpha:]]*__/**',
// Not this file itself
'!jest.config.js'
],
// Don't look for test files in these directories.
testPathIgnorePatterns: [
'dist',
'node_modules',
'__fixtures__',
'__helpers__',
'__snapshots__'
]
};
if (process.env.npm_lifecycle_event === 'test:ci') {
// Extract test filename from full path, for use in JUnit report attributes.
const testPathRE = /(^\/packages\/[^\/]+\/|\.spec|\/__tests?__)/g;
const testPathToFilePath = filepath => filepath.replace(testPathRE, '');
// Add JUnit reporter for use in CI.
jestConfig.reporters = [
'default',
[
'jest-junit',
{
suiteName: 'Jest unit and functional tests',
output: './test-results/jest/results.xml',
suiteNameTemplate: ({ displayName, filepath }) =>
`${displayName}: ${testPathToFilePath(
testPathToFilePath(filepath)
)}`,
classNameTemplate: ({ classname, title }) =>
classname !== title ? classname : '',
titleTemplate: '{title}'
}
]
];
}
module.exports = jestConfig;
+11
View File
@@ -0,0 +1,11 @@
{
"version": "2.0.0-rc.18",
"packages": [
"packages/peregrine",
"packages/pwa-buildpack",
"packages/upward-js",
"packages/upward-spec",
"packages/venia-concept"
],
"npmClient": "npm"
}
+6
View File
@@ -0,0 +1,6 @@
{
"version": 1,
"name": "magento-venia",
"alias": "magento-venia",
"public": true
}
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
{
"name": "@magento/pwa-studio",
"version": "2.0.0",
"private": true,
"author": "Magento Commerce",
"license": "SEE LICENSE IN LICENSE.txt",
"homepage": "https://github.com/magento-research/pwa-studio",
"bugs": {
"url": "https://github.com/magento-research/pwa-studio/issues"
},
"engines": {
"node": ">=10.14.1"
},
"scripts": {
"build": "npx lerna run --stream build -- -s",
"clean:all": "npx lerna run clean && npx lerna clean --yes && rimraf ./node_modules",
"clean:dist": "npx lerna run clean",
"coveralls": "cat ./coverage/lcov.info | coveralls",
"danger": "danger-ci",
"lint": "eslint '@(packages|scripts)/**/{*.js,package.json}' --ignore-pattern node_modules --ignore-pattern storybook-dist",
"now-build": "cp packages/venia-concept/.env.dist packages/venia-concept/.env && npm run -s build",
"now-start": "npm run -s stage:venia",
"prepare": "node ./scripts/update_repo_environment.js && npx lerna bootstrap --hoist",
"prettier": "prettier --write '@(packages|scripts)/**/*.@(js|css)' '*.js'",
"prettier:validate": "prettier-check '@(packages|scripts)/**/*.@(js|css)' '*.js'",
"prettier:check": "prettier --list-different '@(packages|scripts)/**/*.@(js|css)' '*.js'",
"stage:venia": "cd packages/venia-concept && npm start; cd - >/dev/null",
"stats:venia": "cd packages/venia-concept && npm run build:stats && webpack-bundle-analyzer dist/build-stats.json",
"storybook:venia": "cd packages/venia-concept && npm run storybook",
"test": "jest",
"test:ci": "jest --no-cache -i --json --outputFile=test-results.json",
"test:debug": "node --inspect-brk node_modules/.bin/jest -i --no-cache",
"test:dev": "jest --watch",
"validate-queries": "npx lerna run validate-queries -- -s",
"watch:all": "node scripts/watch-all.js",
"watch:buildpack": "cd packages/pwa-buildpack && npm run -s watch; cd - >/dev/null",
"watch:peregrine": "cd packages/peregrine && npm run -s watch; cd - >/dev/null",
"watch:venia": "cd packages/venia-concept && npm run -s watch; cd - >/dev/null",
"bundlesize": "bundlesize"
},
"bundlesize": [
{
"path": "./packages/venia-concept/dist/js/{client,vendor}.js",
"maxSize": "150 kB"
},
{
"path": "./packages/venia-concept/dist/js/[0-9]-*.js",
"maxSize": "21kB"
}
],
"husky": {
"hooks": {
"pre-push": "npm run prettier && npm run prettier:validate && npm run lint && npm test"
}
},
"devDependencies": {
"@babel/core": "~7.2.2",
"@magento/directive-parser": "~0.1.1",
"@magento/eslint-config": "~1.3.0",
"@storybook/addon-actions": "~3.4.2",
"@storybook/addons": "~3.4.6",
"@storybook/react": "~4.0.11",
"acorn": "~6.0.5",
"apollo-boost": "~0.1.20",
"apollo-cache-inmemory": "~1.3.9",
"apollo-cache-persist": "~0.1.1",
"apollo-client": "~2.4.5",
"apollo-link-context": "~1.0.9",
"apollo-server": "~2.0.5",
"babel-cli": "~6.26.0",
"babel-core": "~6.26.0",
"babel-eslint": "~8.2.3",
"babel-helper-module-imports": "~7.0.0-beta.3",
"babel-loader": "~7.1.5",
"babel-plugin-graphql-tag": "~1.6.0",
"babel-plugin-syntax-dynamic-import": "~6.18.0",
"babel-plugin-syntax-jsx": "~6.18.0",
"babel-plugin-transform-class-properties": "~6.24.1",
"babel-plugin-transform-es2015-modules-commonjs": "~6.26.0",
"babel-plugin-transform-object-rest-spread": "~6.26.0",
"babel-plugin-transform-react-jsx": "~6.24.1",
"babel-plugin-transform-react-remove-prop-types": "~0.4.13",
"babel-plugin-transform-runtime": "~6.23.0",
"babel-preset-env": "~1.6.1",
"babel-runtime": "~6.26.0",
"boxen": "~2.0.0",
"bundlesize": "~0.15.3",
"chalk": "~2.4.1",
"chokidar": "~2.0.4",
"contains-path": "~1.0.0",
"coveralls": "~3.0.1",
"css-loader": "~1.0.0",
"danger": "~3.9.0",
"debug": "~3.1.0",
"debug-error-middleware": "~1.3.0",
"dedent": "~0.7.0",
"devcert": "~1.0.0",
"dotenv": "~6.1.0",
"enzyme": "~3.7.0",
"enzyme-adapter-react-16": "~1.7.0",
"eslint": "~5.2.0",
"eslint-plugin-babel": "~5.1.0",
"eslint-plugin-graphql": "~3.0.1",
"eslint-plugin-jsx-a11y": "~6.1.2",
"eslint-plugin-node": "~7.0.1",
"eslint-plugin-package-json": "~0.1.3",
"eslint-plugin-react": "~7.11.1",
"execa": "~1.0.0",
"express": "~4.16.3",
"figures": "~2.0.0",
"file-loader": "~2.0.0",
"graphql": "~0.13.2",
"graphql-tag": "~2.10.0",
"hogan.js": "~3.0.2",
"http-proxy-middleware": "~0.19.0",
"husky": "~1.2.0",
"identity-obj-proxy": "~3.0.0",
"informed": "~1.10.8",
"intl": "~1.2.5",
"jest": "~23.6.0",
"jest-fetch-mock": "~2.0.1",
"jest-junit": "~5.1.0",
"jest-junit-reporter": "~1.1.0",
"jest-transform-graphql": "~2.1.0",
"js-yaml": "~3.12.0",
"keypress": "~0.2.1",
"lerna": "~3.4.0",
"lodash.debounce": "~4.0.8",
"memoize-one": "~4.0.3",
"memory-fs": "~0.4.1",
"mkdirp": "~0.5.1",
"morgan": "~1.9.0",
"multispinner": "~0.2.1",
"node-fetch": "~2.2.1",
"portscanner": "~2.2.0",
"prettier": "1.15.3",
"prettier-check": "~2.0.0",
"prop-types": "~15.6.2",
"react": "~16.6.1",
"react-apollo": "~2.2.4",
"react-dom": "~16.6.1",
"react-feather": "~1.1.5",
"react-redux": "~5.0.7",
"react-router-dom": "~4.4.0-beta.6",
"react-test-renderer": "~16.6.1",
"redux": "~4.0.1",
"redux-actions": "~2.6.4",
"redux-thunk": "~2.3.0",
"rimraf": "~2.6.2",
"storybook-readme": "~3.3.0",
"stream-snitch": "~0.0.3",
"strip-ansi": "~5.0.0",
"style-loader": "~0.23.0",
"supertest": "~3.1.0",
"tap-diff": "~0.1.1",
"tap-xunit": "~2.3.0",
"tape": "~4.9.1",
"terser": "~3.8.2",
"terser-webpack-plugin": "~1.1.0",
"wait-for-expect": "^1.1.0",
"webpack": "~4.25.1",
"webpack-babel-env-deps": "~1.4.3",
"webpack-bundle-analyzer": "~3.0.3",
"webpack-cli": "~3.1.2",
"webpack-dev-server": "^3.1.14",
"workbox-webpack-plugin": "~3.6.1",
"write-file-webpack-plugin": "~4.2.0",
"xml": "~1.0.1"
},
"dependencies": {}
}
@@ -0,0 +1,31 @@
{
"plugins": [
"syntax-dynamic-import",
"transform-object-rest-spread",
"transform-class-properties",
["transform-react-jsx"],
["transform-runtime", {
"helpers": true,
"polyfill": false, // polyfills will be handled by preset-env
"regenerator": false
}]
],
"presets": [
["env", {
"targets": {
"browsers": ["> 5%"]
},
"modules": false
}]
],
"env": {
"test": {
"plugins": [
"transform-es2015-modules-commonjs",
"transform-object-rest-spread",
"transform-class-properties",
["transform-react-jsx"]
]
}
}
}
@@ -0,0 +1,6 @@
const config = {
parser: 'babel-eslint',
extends: ['@magento']
};
module.exports = config;
@@ -0,0 +1,5 @@
.eslintrc.js
.storybook
jest.config.js
/{dist,src}/**/__{docs,helpers,mocks,stories,tests}__/**
/scripts
@@ -0,0 +1 @@
import 'storybook-readme/register';
@@ -0,0 +1,8 @@
import { configure } from '@storybook/react';
function loadStories() {
const context = require.context('../src', true, /__stories__\/.+\.js$/);
context.keys().forEach(context);
}
configure(loadStories, module);
@@ -0,0 +1,13 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
<a name="0.5.0"></a>
# 0.5.0 (2018-08-13)
### Features
* multicasting REST API client ([#164](https://github.com/magento-research/pwa-studio/issues/164)) ([2852e14](https://github.com/magento-research/pwa-studio/commit/2852e14)), closes [#140](https://github.com/magento-research/pwa-studio/issues/140)
* Simulators for async testing ([#24](https://github.com/magento-research/pwa-studio/issues/24)) ([a380da9](https://github.com/magento-research/pwa-studio/commit/a380da9))
@@ -0,0 +1,48 @@
Open Software License ("OSL") v. 3.0
This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work:
Licensed under the Open Software License version 3.0
1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following:
1. to reproduce the Original Work in copies, either alone or as part of a collective work;
2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work;
3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License;
4. to perform the Original Work publicly; and
5. to display the Original Work publicly.
2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works.
3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work.
4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license.
5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c).
6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work.
7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer.
8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation.
9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c).
10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware.
11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License.
12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License.
13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable.
14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You.
16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under <insert your license name here>" or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process.
@@ -0,0 +1,32 @@
# Peregrine
The Peregrine project is a collection of UI components for Magento PWA projects.
Use, extend, or remix these components to create a unique Magento PWA storefront.
See [Peregrine documentation]
## Install
Run the following command to install Peregrine using [NPM]:
```sh
npm install @magento/peregrine
```
## Contributing
The `peregrine` repository is an open source project that welcomes contributors of all skill levels.
If you want to contribute to this project, please review the [contribution guidelines] and follow our [code of conduct].
## License
This project is under the OSL-3.0 license - see the [LICENSE] file for details.
[Peregrine documentation]: https://magento-research.github.io/pwa-studio/peregrine/
[NPM]: https://www.npmjs.com/
[contribution guidelines]: .github/CONTRIBUTING.md
[code of conduct]: .github/CODE_OF_CONDUCT.md
[LICENSE]: LICENSE
@@ -0,0 +1,5 @@
# REST API Clients
Documentation has been moved to the [REST API client][] topic in the PWA devdocs site.
[REST API client]: https://magento-research.github.io/pwa-studio/peregrine/reference/rest-api-client/
@@ -0,0 +1,6 @@
# Peregrine Router
Documentation content has been moved to the [Router][] topic in the PWA devdocs site.
[Router]: https://magento-research.github.io/pwa-studio/peregrine/reference/router/
@@ -0,0 +1,46 @@
{
"name": "@magento/peregrine",
"version": "2.0.0-rc.18",
"publishConfig": {
"access": "public"
},
"description": "The core runtime of Magento PWA",
"main": "dist/index.js",
"scripts": {
"build": "npx babel src --out-dir dist --ignore '__tests__/,__mocks__/,__fixtures__/' --source-maps --copy-files",
"clean": "npx rimraf dist",
"prepublishOnly": "npx rimraf dist && npm run build",
"storybook": "start-storybook -p 9001 -c .storybook",
"storybook:build": "build-storybook -c .storybook -o storybook-dist",
"watch": "npm run -s build -- --watch"
},
"repository": "github:magento-research/pwa-studio",
"author": "Magento Commerce",
"license": "(OSL-3.0 OR AFL-3.0)",
"bugs": {
"url": "https://github.com/magento-research/pwa-studio/issues"
},
"homepage": "https://github.com/magento-research/pwa-studio/tree/master/packages/peregrine#readme",
"devDependencies": {
"@storybook/react": "~4.0.11",
"babel-cli": "~6.26.0",
"babel-runtime": "~6.26.0",
"react": "~16.6.1",
"react-dom": "~16.6.1",
"react-redux": "~5.0.7",
"react-router-dom": "~4.4.0-beta.6",
"redux": "~4.0.1",
"rimraf": "~2.6.2"
},
"peerDependencies": {
"babel-runtime": "^6.26.0",
"react": "^16.5.2",
"react-dom": "^16.5.2",
"react-redux": "^5.0.7",
"react-router-dom": ">=4.4.0-beta.6",
"redux": "^4.0.0",
"redux-thunk": "^2.3.0"
},
"module": "src/index.js",
"jsnext:main": "src/index.js"
}
@@ -0,0 +1 @@
global.fetch = require('jest-fetch-mock');
@@ -0,0 +1 @@
global.requestAnimationFrame = callback => setTimeout(callback, 0);
@@ -0,0 +1,13 @@
import { Component } from 'react';
import { func, string } from 'prop-types';
export default class ContainerChild extends Component {
static propTypes = {
id: string.isRequired,
render: func.isRequired
};
render() {
return this.props.render();
}
}
@@ -0,0 +1,30 @@
# ContainerChild
The `ContainerChild` component is the only allowed child within a `Container` in
PWA Studio.
## Usage
```jsx
import { ContainerChild } from '@magento/peregrine';
<div data-mid="some.container.identifier">
<ContainerChild
id="another.unique.id"
render={() => <div>Used just like a normal render() method</div>}
/>
<ContainerChild
id="one.more.unique.id"
render={() => (
<div>Can render anything a normal component can render</div>
)}
/>
</div>;
```
## Props
| Prop Name | Required? | Description |
| --------- | :-------: | ------------------------------------------------------------------------------------------------------------------: |
| `id` | ✅ | A string identifier that modules/extensions can use to inject content relative to this component within a Container |
| `render` | ✅ | A [render prop](https://reactjs.org/docs/render-props.html) that should return the children to render |
@@ -0,0 +1,22 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import ContainerChild from '..';
import docs from '../__docs__/ContainerChild.md';
import { withReadme } from 'storybook-readme';
const stories = storiesOf('ContainerChild', module);
stories.add(
'default',
withReadme(docs, () => (
<ContainerChild
id="example.container.child.id"
render={() => (
<div>
An example ContainerChild component, rendering its children
from the "render" prop
</div>
)}
/>
))
);
@@ -0,0 +1,14 @@
import React from 'react';
import ContainerChild from '..';
import { shallow } from 'enzyme';
test('Renders content from render prop', () => {
const wrapper = shallow(
<ContainerChild
id="foo.bar"
render={() => <div>Hello World</div>}
processed={true}
/>
);
expect(wrapper.equals(<div>Hello World</div>)).toBe(true);
});
@@ -0,0 +1 @@
export { default } from './ContainerChild';
@@ -0,0 +1,17 @@
# Item
The `Item` component is a direct child of the `Items` fragment.
## Usage
See `List`.
## Props
Prop Name | Required? | Description
--------- | :-------: | :----------
`classes` | ❌ | A classname object.
`hasFocus` | ❌ | Whether the element currently has browser focus
`isSelected` | ❌ | Whether the item is currently selected
`item` | ✅ | A data object. If `item` is a string, it will be rendered as a child
`render` | ✅ | A [render prop](https://reactjs.org/docs/render-props.html). Also accepts a tagname (e.g., `"div"`)
@@ -0,0 +1,15 @@
# Items
The `Items` component is a direct child of the `List` component. As a fragment, it returns its children directly, with no wrapping element.
## Usage
See `List`.
## Props
Prop Name | Required? | Description
--------- | :-------: | :----------
`items` | ✅ | An iterable that yields `[key, item]` pairs, such as an ES2015 `Map`
`renderItem` | ❌ | A [render prop](https://reactjs.org/docs/render-props.html). Also accepts a tagname (e.g., `"div"`)
`selectionModel` | ❌ | A string specifying whether to use a `radio` or `checkbox` selection model
@@ -0,0 +1,44 @@
# List
The `List` component maps a collection of data objects into an array of elements. It also manages the selection and focus of those elements.
## Usage
```jsx
import { List } from '@magento/peregrine';
const simpleData = new Map()
.set('s', 'Small')
.set('m', 'Medium')
.set('l', 'Large')
<List
classes={{ root: 'foo' }}
items={simpleData}
render={'ul'}
renderItem={'li'}
/>
const complexData = new Map()
.set('s', { id: 's', value: 'Small' })
.set('m', { id: 'm', value: 'Medium' })
.set('l', { id: 'l', value: 'Large' })
<List
classes={{ root: 'bar' }}
items={complexData}
render={props => (<ul>{props.children}</ul>)}
renderItem={props => (<li>{props.item.value}</li>)}
/>
```
## Props
Prop Name | Required? | Description
--------- | :-------: | :----------
`classes` | ❌ | A classname hash
`items` | ✅ | An iterable that yields `[key, item]` pairs, such as an ES2015 `Map`
`render` | ✅ | A [render prop](https://reactjs.org/docs/render-props.html) for the list element. Also accepts a tagname (e.g., `"div"`)
`renderItem` | ❌ | A [render prop](https://reactjs.org/docs/render-props.html) for the list item elements. Also accepts a tagname (e.g., `"div"`)
`onSelectionChange` | ❌ | A callback fired when the selection state changes
`selectionModel` | ❌ | A string specifying whether to use a `radio` or `checkbox` selection model
@@ -0,0 +1,15 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { withReadme } from 'storybook-readme';
import Item from '..';
import docs from '../__docs__/item.md';
const stories = storiesOf('Item', module);
stories.add(
'default',
withReadme(docs, () => (
<Item classes={{ root: 'foo' }} item={{ id: 's', value: 'Small' }} />
))
);
@@ -0,0 +1,19 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { withReadme } from 'storybook-readme';
import Items from '..';
import docs from '../__docs__/items.md';
const data = {
s: { id: 's', value: 'Small' },
m: { id: 'm', value: 'Medium' },
l: { id: 'l', value: 'Large' }
};
const stories = storiesOf('Items', module);
stories.add(
'default',
withReadme(docs, () => <Items items={Object.entries(data)} />)
);
@@ -0,0 +1,44 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { withReadme } from 'storybook-readme';
import List from '..';
import docs from '../__docs__/list.md';
const stories = storiesOf('List', module);
// simple example with string values
const simpleData = new Map()
.set('s', 'Small')
.set('m', 'Medium')
.set('l', 'Large');
stories.add(
'simple',
withReadme(docs, () => (
<List
classes={{ root: 'foo' }}
items={simpleData}
render={'ul'}
renderItem={'li'}
/>
))
);
// complex example with object values
const complexData = new Map()
.set('s', { id: 's', value: 'Small' })
.set('m', { id: 'm', value: 'Medium' })
.set('l', { id: 'l', value: 'Large' });
stories.add(
'complex',
withReadme(docs, () => (
<List
classes={{ root: 'bar' }}
items={complexData}
render={props => <ul>{props.children}</ul>}
renderItem={props => <li>{props.item.value}</li>}
/>
))
);
@@ -0,0 +1,73 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Item } from '../index.js';
const classes = {
root: 'abc'
};
test('renders a div by default', () => {
const props = { item: 'a', itemIndex: 1 };
const wrapper = shallow(<Item {...props} />).dive();
expect(wrapper.type()).toEqual('div');
});
test('renders a provided tagname', () => {
const props = { item: 'a', render: 'p', itemIndex: 1 };
const wrapper = shallow(<Item {...props} />).dive();
expect(wrapper.type()).toEqual('p');
});
test('renders a provided component', () => {
const Span = () => <span />;
const props = { item: 'a', render: Span, itemIndex: 1 };
const wrapper = shallow(<Item {...props} />);
expect(wrapper.type()).toEqual(Span);
expect(wrapper.dive().type()).toEqual('span');
});
test('passes only rest props to basic `render`', () => {
const props = { classes, item: 'a', render: 'p', itemIndex: 1 };
const wrapper = shallow(<Item {...props} data-id="b" />).dive();
expect(wrapper.props()).toHaveProperty('data-id');
expect(wrapper.props()).not.toHaveProperty('classes');
expect(wrapper.props()).not.toHaveProperty('hasFocus');
expect(wrapper.props()).not.toHaveProperty('isSelected');
expect(wrapper.props()).not.toHaveProperty('item');
expect(wrapper.props()).not.toHaveProperty('render');
});
test('passes custom and rest props to composite `render`', () => {
const Span = () => <span />;
const itemIndex = 1;
const props = { classes, item: 'a', render: Span, itemIndex };
const wrapper = shallow(<Item {...props} data-id="b" />);
expect(wrapper.props()).toHaveProperty('data-id');
expect(wrapper.props()).toHaveProperty('classes');
expect(wrapper.props()).toHaveProperty('hasFocus');
expect(wrapper.props()).toHaveProperty('isSelected');
expect(wrapper.props()).toHaveProperty('item');
expect(wrapper.props()).toHaveProperty('itemIndex');
expect(wrapper.prop('itemIndex')).toBe(itemIndex);
expect(wrapper.props()).not.toHaveProperty('render');
});
test('passes `item` as `children` if `item` is a string', () => {
const props = { item: 'a', render: 'p', itemIndex: 1 };
const wrapper = shallow(<Item {...props} />).dive();
expect(wrapper.text()).toEqual('a');
});
test('does not pass `children` if `item` is not a string', () => {
const props = { item: { id: 1 }, render: 'p', itemIndex: 1 };
const wrapper = shallow(<Item {...props} />).dive();
expect(wrapper.text()).toBe('');
});
@@ -0,0 +1,252 @@
import React, { Fragment } from 'react';
import { shallow } from 'enzyme';
import { Items } from '..';
const items = [
{
id: '001',
name: 'Test Product 1',
small_image: '/test/product/1.png',
price: {
regularPrice: {
amount: {
value: 100
}
}
}
},
{
id: '002',
name: 'Test Product 2',
small_image: '/test/product/2.png',
price: {
regularPrice: {
amount: {
value: 100
}
}
}
}
];
test('renders a fragment', () => {
const props = { items };
const wrapper = shallow(<Items {...props} />);
expect(wrapper.type()).toEqual(Fragment);
});
test('renders a child for each item', () => {
const props = { items };
const wrapper = shallow(<Items {...props} />);
expect(wrapper.children()).toHaveLength(items.length);
});
test('renders basic children of type `renderItem`', () => {
const elementType = 'li';
const props = { items, renderItem: elementType };
const wrapper = shallow(<Items {...props} />);
expect.assertions(items.length);
wrapper.children().forEach(node => {
expect(
node
.dive()
.dive()
.type()
).toEqual(elementType);
});
});
test('renders composite children of type `renderItem`', () => {
const Span = () => <span />;
const props = { items, renderItem: Span };
const wrapper = shallow(<Items {...props} />);
expect.assertions(items.length);
wrapper.children().forEach(node => {
expect(node.dive().type()).toEqual(Span);
});
});
test('passes correct props to each child', () => {
const elementType = 'li';
const props = { items, renderItem: elementType };
const wrapper = shallow(<Items {...props} />);
wrapper.children().forEach((node, i) => {
const item = items[i];
const key = item.id;
expect(node.key()).toEqual(key);
expect(node.props()).toMatchObject({
item,
itemIndex: i,
render: props.renderItem,
hasFocus: false,
isSelected: false,
onBlur: wrapper.instance().handleBlur,
onClick: expect.any(Function),
onFocus: expect.any(Function)
});
});
});
test('uses keys generated by `getItemKey` if provided', () => {
const identity = x => x;
const tags = ['a', 'b', 'c'];
const props = { items: tags, getItemKey: identity };
const wrapper = shallow(<Items {...props} />);
wrapper.children().forEach((node, i) => {
expect(node.key()).toEqual(tags[i]);
});
});
test('indicates the child at index `cursor` has focus', () => {
const props = { items };
const wrapper = shallow(<Items {...props} />);
const state = { cursor: 1, hasFocus: true };
wrapper.setState(state);
wrapper.children().forEach((node, i) => {
const item = items[i];
expect(node.props()).toMatchObject({
item,
hasFocus: i === state.cursor,
isSelected: false
});
});
});
test('indicates no child has focus if the list is not focused', () => {
const props = { items };
const wrapper = shallow(<Items {...props} />);
const state = { cursor: 1, hasFocus: false };
wrapper.setState(state);
wrapper.children().forEach((node, i) => {
const item = items[i];
expect(node.props()).toMatchObject({
item,
hasFocus: false,
isSelected: false
});
});
});
test('indicates whether a child is selected', () => {
const props = { items };
const wrapper = shallow(<Items {...props} />);
const selection = new Set().add('002');
wrapper.setState({ selection });
wrapper.children().forEach((node, i) => {
const item = items[i];
const key = item.id;
expect(node.props()).toMatchObject({
item,
hasFocus: false,
isSelected: selection.has(key)
});
});
});
test('updates `hasFocus` on child blur', () => {
const props = { items };
const wrapper = shallow(<Items {...props} />);
wrapper.setState({ hasFocus: true });
wrapper.childAt(0).simulate('blur');
expect(wrapper.state('hasFocus')).toBe(false);
});
test('updates `cursor` and `hasFocus` on child focus', () => {
const props = { items };
const wrapper = shallow(<Items {...props} />);
const index = 0;
wrapper.childAt(index).simulate('focus');
expect(wrapper.state()).toMatchObject({
cursor: index,
hasFocus: true
});
});
test('updates radio `selection` on child click', () => {
const props = { items };
const wrapper = shallow(<Items {...props} />);
expect(wrapper.state('selection')).toEqual(new Set());
wrapper.childAt(0).simulate('click');
expect(wrapper.state('selection')).toEqual(new Set(['001']));
wrapper.childAt(1).simulate('click');
expect(wrapper.state('selection')).toEqual(new Set(['002']));
wrapper.childAt(0).simulate('click');
expect(wrapper.state('selection')).toEqual(new Set(['001']));
});
test('updates checkbox `selection` on child click', () => {
const props = { items, selectionModel: 'checkbox' };
const wrapper = shallow(<Items {...props} />);
expect(wrapper.state('selection')).toEqual(new Set());
wrapper.childAt(0).simulate('click');
expect(wrapper.state('selection')).toEqual(new Set(['001']));
wrapper.childAt(1).simulate('click');
expect(wrapper.state('selection')).toEqual(new Set(['001', '002']));
wrapper.childAt(0).simulate('click');
expect(wrapper.state('selection')).toEqual(new Set(['002']));
});
test('calls `syncSelection` after updating selection', () => {
const props = { items };
const wrapper = shallow(<Items {...props} />);
const spy = jest.spyOn(wrapper.instance(), 'syncSelection');
wrapper.childAt(0).simulate('click');
expect(spy).toHaveBeenCalled();
});
test('calls `onSelectionChange` after updating selection', () => {
const onSelectionChange = jest.fn();
const props = { items, onSelectionChange };
const wrapper = shallow(<Items {...props} />);
wrapper.childAt(0).simulate('click');
expect(onSelectionChange).toHaveBeenCalledWith(wrapper.state('selection'));
});
test('memoizes child click handlers', () => {
const props = { items };
const instance = shallow(<Items {...props} />).instance();
expect(instance.getClickHandler(0)).not.toBe(instance.getClickHandler(1));
expect(instance.getClickHandler(0)).toBe(instance.getClickHandler(0));
});
test('memoizes child focus handlers', () => {
const props = { items };
const instance = shallow(<Items {...props} />).instance();
expect(instance.getFocusHandler(0)).not.toBe(instance.getFocusHandler(1));
expect(instance.getFocusHandler(0)).toBe(instance.getFocusHandler(0));
});
@@ -0,0 +1,127 @@
import React, { Fragment } from 'react';
import { shallow } from 'enzyme';
import List from '..';
const classes = {
root: 'abc'
};
const items = [
{
id: '001',
name: 'Test Product 1',
small_image: '/test/product/1.png',
price: {
regularPrice: {
amount: {
value: 100
}
}
}
},
{
id: '002',
name: 'Test Product 2',
small_image: '/test/product/2.png',
price: {
regularPrice: {
amount: {
value: 100
}
}
}
}
];
test('renders a div by default', () => {
const props = { classes };
const wrapper = shallow(<List {...props} />).dive();
expect(wrapper.type()).toEqual('div');
expect(wrapper.prop('className')).toEqual(classes.root);
});
test('renders a provided tagname', () => {
const props = { classes, render: 'ul' };
const wrapper = shallow(<List {...props} />).dive();
expect(wrapper.type()).toEqual('ul');
expect(wrapper.prop('className')).toEqual(classes.root);
});
test('renders a provided component', () => {
const Nav = () => <nav />;
const props = { render: Nav };
const wrapper = shallow(<List {...props} />);
expect(wrapper.type()).toEqual(Nav);
expect(wrapper.dive().type()).toEqual('nav');
});
test('passes only rest props to basic `render`', () => {
const props = { classes, items, render: 'ul', renderItem: 'li' };
const wrapper = shallow(<List {...props} data-id="b" />).dive();
expect(wrapper.props()).toHaveProperty('data-id');
expect(wrapper.props()).not.toHaveProperty('classes');
expect(wrapper.props()).not.toHaveProperty('items');
expect(wrapper.props()).not.toHaveProperty('onSelectionChange');
expect(wrapper.props()).not.toHaveProperty('selectionModel');
expect(wrapper.props()).not.toHaveProperty('render');
expect(wrapper.props()).not.toHaveProperty('renderItem');
});
test('passes custom and rest props to composite `render`', () => {
const Nav = () => <nav />;
const props = { classes, items, render: Nav, renderItem: 'a' };
const wrapper = shallow(<List {...props} data-id="b" />);
expect(wrapper.props()).toHaveProperty('data-id');
expect(wrapper.props()).toHaveProperty('classes');
expect(wrapper.props()).toHaveProperty('items');
expect(wrapper.props()).toHaveProperty('onSelectionChange');
expect(wrapper.props()).toHaveProperty('selectionModel');
expect(wrapper.props()).not.toHaveProperty('render');
expect(wrapper.props()).not.toHaveProperty('renderItem');
});
test('renders a fragment as `children`', () => {
const props = { classes, items };
const wrapper = shallow(<List {...props} />);
expect(
wrapper
.childAt(0)
.dive()
.type()
).toEqual(Fragment);
});
test('passes correct props through to `Items`', () => {
const Item = () => <li />;
const selectionModel = 'checkbox';
const props = { items, renderItem: Item, selectionModel };
const wrapper = shallow(<List {...props} />);
expect(wrapper.childAt(0).props()).toMatchObject(props);
});
test('calls `onSelectionChange` on selection change', () => {
const onSelectionChange = jest.fn();
const selection = new Set();
const props = { items, onSelectionChange };
const wrapper = shallow(<List {...props} />);
wrapper.instance().handleSelectionChange(selection);
expect(onSelectionChange).toHaveBeenCalledWith(selection);
});
test('does not throw if `onSelectionChange` is not provided', () => {
const selection = new Set();
const props = { items };
const wrapper = shallow(<List {...props} />);
const cb = () => wrapper.instance().handleSelectionChange(selection);
expect(cb).not.toThrow();
});
@@ -0,0 +1,3 @@
export { default } from './list';
export { default as Items } from './items';
export { default as Item } from './item';
@@ -0,0 +1,54 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import fromRenderProp from '../util/fromRenderProp';
class Item extends Component {
static propTypes = {
classes: PropTypes.shape({
root: PropTypes.string
}),
hasFocus: PropTypes.bool,
isSelected: PropTypes.bool,
item: PropTypes.any.isRequired,
itemIndex: PropTypes.number.isRequired,
render: PropTypes.oneOfType([PropTypes.func, PropTypes.string])
.isRequired
};
static defaultProps = {
classes: {},
hasFocus: false,
isSelected: false,
render: 'div'
};
get children() {
const { item } = this.props;
const isString = typeof item === 'string';
return isString ? item : null;
}
render() {
const {
classes,
hasFocus,
isSelected,
item,
itemIndex,
render,
...restProps
} = this.props;
const customProps = { classes, hasFocus, isSelected, item, itemIndex };
const Root = fromRenderProp(render, Object.keys(customProps));
return (
<Root className={classes.root} {...customProps} {...restProps}>
{this.children}
</Root>
);
}
}
export default Item;
@@ -0,0 +1,104 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import memoize from '../util/unaryMemoize';
import iterable from '../validators/iterable';
import ListItem from './item';
const removeFocus = () => ({
hasFocus: false
});
const updateCursor = memoize(index => () => ({
cursor: index,
hasFocus: true
}));
const updateSelection = memoize(key => (prevState, props) => {
const { selectionModel } = props;
let selection;
if (selectionModel === 'radio') {
selection = new Set().add(key);
}
if (selectionModel === 'checkbox') {
selection = new Set(prevState.selection);
if (selection.has(key)) {
selection.delete(key);
} else {
selection.add(key);
}
}
return { selection };
});
class Items extends Component {
static propTypes = {
getItemKey: PropTypes.func.isRequired,
items: iterable.isRequired,
renderItem: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
selectionModel: PropTypes.oneOf(['checkbox', 'radio'])
};
static defaultProps = {
getItemKey: ({ id }) => id,
selectionModel: 'radio'
};
state = {
cursor: null,
hasFocus: false,
selection: new Set()
};
render() {
const { getItemKey, items, renderItem } = this.props;
const { cursor, hasFocus, selection } = this.state;
const children = Array.from(items, (item, index) => {
const key = getItemKey(item, index);
return (
<ListItem
key={key}
item={item}
itemIndex={index}
render={renderItem}
hasFocus={hasFocus && cursor === index}
isSelected={selection.has(key)}
onBlur={this.handleBlur}
onClick={this.getClickHandler(key)}
onFocus={this.getFocusHandler(index)}
/>
);
});
return <Fragment>{children}</Fragment>;
}
syncSelection() {
const { selection } = this.state;
const { onSelectionChange } = this.props;
if (onSelectionChange) {
onSelectionChange(selection);
}
}
handleBlur = () => {
this.setState(removeFocus);
};
getClickHandler = memoize(key => () => {
this.setState(updateSelection(key), this.syncSelection);
});
getFocusHandler = memoize(index => () => {
this.setState(updateCursor(index));
});
}
export default Items;
@@ -0,0 +1,75 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import fromRenderProp from '../util/fromRenderProp';
import iterable from '../validators/iterable';
import Items from './items';
class List extends Component {
static propTypes = {
classes: PropTypes.shape({
root: PropTypes.string
}),
getItemKey: PropTypes.func.isRequired,
items: iterable.isRequired,
render: PropTypes.oneOfType([PropTypes.func, PropTypes.string])
.isRequired,
renderItem: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
onSelectionChange: PropTypes.func,
selectionModel: PropTypes.oneOf(['checkbox', 'radio'])
};
static defaultProps = {
classes: {},
getItemKey: ({ id }) => id,
items: [],
render: 'div',
renderItem: 'div',
selectionModel: 'radio'
};
render() {
const {
classes,
getItemKey,
items,
render,
renderItem,
onSelectionChange,
selectionModel,
...restProps
} = this.props;
const customProps = {
classes,
getItemKey,
items,
onSelectionChange,
selectionModel
};
const Root = fromRenderProp(render, Object.keys(customProps));
return (
<Root className={classes.root} {...customProps} {...restProps}>
<Items
items={items}
getItemKey={getItemKey}
renderItem={renderItem}
selectionModel={selectionModel}
onSelectionChange={this.handleSelectionChange}
/>
</Root>
);
}
handleSelectionChange = selection => {
const { onSelectionChange } = this.props;
if (onSelectionChange) {
onSelectionChange(selection);
}
};
}
export default List;
@@ -0,0 +1,15 @@
import React, { Component } from 'react';
import { MagentoRouteHandler, RouteConsumer } from '../Router';
export default class Page extends Component {
render() {
const { props } = this;
return (
<RouteConsumer>
{context => <MagentoRouteHandler {...props} {...context} />}
</RouteConsumer>
);
}
}
@@ -0,0 +1,36 @@
import React from 'react';
import { mount } from 'enzyme';
import { RouteProvider } from '../../Router';
import Page from '../Page';
jest.mock('../../Router/MagentoRouteHandler');
const context = { one: 1 };
test('renders `MagentoRouteHandler` with context as props', () => {
// we need to test context consumer, so we can't shallow render
const wrapper = mount(
<RouteProvider value={context}>
<Page />
</RouteProvider>
);
expect(wrapper.find('MagentoRouteHandler').props()).toEqual(context);
});
test('passes props to `MagentoRouteHandler`', () => {
const props = { two: 2 };
// we need to test context consumer, so we can't shallow render
const wrapper = mount(
<RouteProvider value={context}>
<Page {...props} />
</RouteProvider>
);
expect(wrapper.find('MagentoRouteHandler').props()).toEqual({
...props,
...context
});
});
@@ -0,0 +1 @@
export { default } from './Page';
@@ -0,0 +1,45 @@
import React, { PureComponent, Fragment } from 'react';
import { number, string, shape } from 'prop-types';
import patches from '../util/intlPatches';
export default class Price extends PureComponent {
static propTypes = {
value: number.isRequired,
currencyCode: string.isRequired,
classes: shape({
currency: string,
integer: string,
decimal: string,
fraction: string
})
};
static defaultProps = {
classes: {}
};
render() {
const { value, currencyCode, classes } = this.props;
const parts = patches.toParts.call(
Intl.NumberFormat(undefined, {
style: 'currency',
currency: currencyCode
}),
value
);
const children = parts.map((part, i) => {
const partClass = classes[part.type];
const key = `${i}-${part.value}`;
return (
<span key={key} className={partClass}>
{part.value}
</span>
);
});
return <Fragment>{children}</Fragment>;
}
}
@@ -0,0 +1,28 @@
# Price
The `Price` component is used anywhere a price is rendered in PWA Studio.
Formatting of prices and currency symbol selection is handled entirely by the ECMAScript Internationalization API available in modern browsers. A [polyfill](https://www.npmjs.com/package/intl) will need to be loaded for any JavaScript runtime that does not have [`Intl.NumberFormat.prototype.formatToParts`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat/formatToParts).
## Usage
```jsx
import Price from '@peregrine/Price';
import cssModule from './my-pricing-styles';
<Price value={100.99} currencyCode="USD" classes={cssModule} />;
/*
<span className="curr">$</span>
<span className="int">88</span>
<span className="dec">.</span>
<span className="fract">81</span>
*/
```
## Props
| Prop Name | Required? | Description |
| -------------- | :-------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `classes` | ❌ | A classname object. |
| `value` | ✅ | Numeric price |
| `currencyCode` | ✅ | A string of with any currency code supported by [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat) |
@@ -0,0 +1,45 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { withReadme } from 'storybook-readme';
import Price from '../Price';
import docs from '../__docs__/Price.md';
const stories = storiesOf('Price', module);
stories.add(
'USD',
withReadme(docs, () => <Price value={100.99} currencyCode="USD" />)
);
stories.add(
'EUR',
withReadme(docs, () => <Price value={100.99} currencyCode="EUR" />)
);
stories.add(
'JPY',
withReadme(docs, () => <Price value={100.99} currencyCode="JPY" />)
);
stories.add(
'Custom Styles',
withReadme(docs, () => {
const classes = {
currency: 'curr',
integer: 'int',
decimal: 'dec',
fraction: 'fract'
};
return (
<div>
<style>{`
.curr { color: green; font-weight: bold; }
.int { color: red; }
.dec { color: black; }
.fract { color: blue; }
`}</style>
<Price value={100.99} currencyCode="USD" classes={classes} />
</div>
);
})
);
@@ -0,0 +1,59 @@
import React, { Fragment } from 'react';
import { shallow } from 'enzyme';
import Price from '../Price';
import IntlPolyfill from 'intl';
if (!global.Intl.NumberFormat.prototype.formatToParts) {
global.Intl = IntlPolyfill;
require('intl/locale-data/jsonp/en.js');
}
test('Renders a USD price', () => {
const wrapper = shallow(<Price value={100.99} currencyCode="USD" />);
expect(
wrapper.equals(
<Fragment>
<span>$</span>
<span>100</span>
<span>.</span>
<span>99</span>
</Fragment>
)
).toBe(true);
});
test('Renders a EUR price', () => {
const wrapper = shallow(<Price value={100.99} currencyCode="EUR" />);
expect(
wrapper.equals(
<Fragment>
<span></span>
<span>100</span>
<span>.</span>
<span>99</span>
</Fragment>
)
).toBe(true);
});
test('Allows custom classnames for each part', () => {
const classes = {
currency: 'curr',
integer: 'int',
decimal: 'dec',
fraction: 'fract'
};
const wrapper = shallow(
<Price value={88.81} currencyCode="USD" classes={classes} />
);
expect(
wrapper.equals(
<Fragment>
<span className="curr">$</span>
<span className="int">88</span>
<span className="dec">.</span>
<span className="fract">81</span>
</Fragment>
)
).toBe(true);
});
@@ -0,0 +1 @@
export { default } from './Price';
@@ -0,0 +1,274 @@
import M2ApiResponseError from './M2ApiResponseError';
import * as MulticastCache from './MulticastCache';
import { BrowserPersistence } from '../../util/';
// TODO: headers are locked right now, add configurability
const withDefaultHeaders = headerAdditions => {
const headers = new Headers({
'Content-type': 'application/json',
Accept: 'application/json'
});
if (headerAdditions) {
if (headerAdditions instanceof Headers) {
/* istanbul ignore next: current phantomJS doesn't support */
if (headerAdditions.entries) {
for (const [name, value] of headerAdditions) {
headers.append(name, value);
}
} else if (headerAdditions.forEach) {
// cover legacy case for old test environments
headerAdditions.forEach((name, value) => {
headers.append(name, value);
});
/* istanbul ignore next: should never happen, trivial to test*/
} else {
console.warn(
'Could not use headers object supplied to M2ApiRequest',
headerAdditions
);
}
} else {
for (const [name, value] of Object.entries(headerAdditions)) {
headers.append(name, value);
}
}
}
return headers;
};
/**
* All [fetch options](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) are passed through, with the addition of:
* @typedef {Object} M2ApiRequestOptions
* @property {boolean} [multicast] Override default multicast detection
*/
/**
* A request to the Magento 2 REST API. Returns a Promise created by a network
* fetch, but can potentially reuse prior requests if they qualify for
* multicast. Can abort an outstanding fetch request.
*
* @param {M2ApiRequestOptions} opts - All other [fetch options](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) will be passed through to `fetch`.
*/
class M2ApiRequest {
constructor(resourceUrl, opts = {}) {
const storage = new BrowserPersistence();
const signin_token = storage.getItem('signin_token');
this.controller = new AbortController();
this.resourceUrl = resourceUrl;
// merge headers specially
this.opts = {
// can be overridden
method: 'GET',
signal: this.controller.signal,
credentials: 'include',
...opts,
// cannot be overridden, only appended to
headers: withDefaultHeaders(
new Headers({
authorization: signin_token ? `Bearer ${signin_token}` : ''
})
)
};
}
/**
* Execute the request. Must be run before {@link M2ApiRequest#getResponse}
* or {@link M2ApiRequest#cancel} can be called.
*/
run() {
if (this._isMulticastable()) {
this._promise = this._fetchMulticast();
} else {
this._promise = this._fetch();
}
}
/**
* Get the promise for the network operation. Can only be called after
* `.run()` is called.
* For multicast requests, will return a promise for a new copy of the
* response every time it is called, since a Body can only be used once.
* Exists so that requests can reuse the promises from other requests.
* @returns {Promise} Promise for the result of the request.
*/
getResponse() {
if (!this._promise) {
throw new Error(
'M2ApiRequest#getResponse() called before M2ApiRequest#run(), so no promise exists yet'
);
}
if (this._isMulticastable()) {
return this._promise.then(res => res.clone());
} else {
return this._promise;
}
}
/**
* Abort the network operation. Multicasted requests catch the AbortError
* and attempt to reuse a more recent matching request from cache. Other
* requests will pass the AbortError rejection through to the consumer.
*/
abortRequest() {
this.controller.abort();
}
/**
* Check if this request intends to override prior requests to the same
* resource. Rolling requests will take the place of prior outstanding
* requests, to ensure the freshest resource at the cost of additional
* network calls.
*
* The current logic for rolling requests is determined by the `cache`
* option. [Cache modes](https://developer.mozilla.org/en-US/docs/Web/API/Request/cache)
* `reload` and `no-store` both indicate complete cache bypass. This
* logically implies that the user has just changed server state and wants
* to force retrieve an updated resource, so multicasting a prior request
* would not be appropriate--the response may not reflect the more recent
* change.
* @returns {boolean} True if the request is rolling.
*/
isRolling() {
return this.opts.cache === 'no-store' || this.opts.cache === 'reload';
}
/**
* Make sure not to multicast POST requests which have a nonempty body,
* since they are unsafe and non-idempotent, so each call may mutate
* server-side state.
*
* In the M2 REST API, some POST requests have no body, and those tend
* to be idempotent.
*
* The `multicast` boolean option to the constructor can be used to
* override this, either to force `false` or `true`.
*
* @private
*/
_isMulticastable() {
return this.opts.hasOwnProperty('multicast')
? this.opts.multicast
: !(this.opts.method === 'POST' && this.opts.body);
}
/**
* Use the Fetch API to place a request to the M2 REST API.
* Exposed on prototype for testing only.
* @private
*/
/* istanbul ignore next */
_transport(...args) {
return window.fetch(...args);
}
/**
* Use the AbortController API to make a cancelable fetch request.
* Reject on HTTP errors.
* @private
*/
_fetch() {
return this._transport(this.resourceUrl, this.opts)
.then(
// When the network operation completes, remove from cache
// as a side effect.
res => {
MulticastCache.remove(this);
return res;
},
e => {
MulticastCache.remove(this);
throw e;
}
)
.then(response => {
// WHATWG fetch will only reject in the unlikely event
// of an error prior to opening the HTTP request.
// It pays no attention to HTTP status codes.
// But the response object does have an `ok` boolean
// corresponding to status codes in the 2xx range.
// An M2ApiRequest will reject, passing server errors
// to the client, in the event of an HTTP error code.
if (!response.ok) {
return (
response
// The response may or may not be JSON.
// Let M2ApiResponseError handle it.
.text()
// Throw a specially formatted error which
// includes the original context of the request,
// and formats the server response.
.then(bodyText => {
throw new M2ApiResponseError({
method: this.opts.method,
resourceUrl: this.resourceUrl,
response,
bodyText
});
})
);
}
return response;
});
}
/**
* Get a network operation matching this request, either by finding
* one in the MulticastCache, or by launching a new one (and caching
* it in the MulticastCache).
* @private
*/
_fetchMulticast() {
// Does an inflight request exist that could be reused here?
// That is, does it have the same method, resourceUrl, and body and it
// appears idempotent and safe ?
const inflightMatch = MulticastCache.match(this);
// Is this request meant to override an existing inflight request?
const rolling = this.isRolling();
if (inflightMatch && !rolling) {
// Reuse the request!
return inflightMatch.getResponse();
}
// Either there is no match, or this is a rolling request
// and we must override the match.
// Cache this request for future use.
MulticastCache.store(this);
const promise = this._fetch().catch(error => {
// Rolling requests cause prior matching requests to abort.
// Their consumers will get an unexpected error unless we
// swallow the AbortError we expect, and replace it with
// the promise from our rolling request.
if (error.name === 'AbortError') {
const replacedInFlightMatch = MulticastCache.match(this);
if (replacedInFlightMatch) {
// There is a rolling request in the cache to override!
return replacedInFlightMatch.getResponse();
}
}
throw error;
});
if (rolling && inflightMatch) {
inflightMatch.abortRequest();
}
return promise;
}
}
export default M2ApiRequest;
/**
* Place a request to the Magento 2 REST API and return a Promise for the
* response.
* @param (string) resourceUrl The URL of the resource to request.
* @param {M2ApiRequestOptions} opts Options to be passed to [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters), with the addition of the `multicast` option.
* @returns {Promise} A promise for the parsed REST request.
*/
export function request(resourceUrl, opts) {
const req = new M2ApiRequest(resourceUrl, opts);
req.run();
const promise = req.getResponse();
if (opts && opts.parseJSON === false) {
return promise;
}
return promise.then(res => res.json());
}
@@ -0,0 +1,37 @@
export default class M2ApiResponseError extends Error {
constructor({ method, resourceUrl, response, bodyText }, ...args) {
let body = ``;
try {
const { message, trace, ...rest } = JSON.parse(bodyText);
if (message) {
body += `Message:\n\n ${message}\n`;
}
const addl = Object.entries(rest);
if (addl.length > 0) {
body += `\nAdditional info:\n\n${JSON.stringify(
rest,
null,
4
)}\n\n`;
}
if (trace) {
body += `Magento PHP stack trace: \n\n${trace}`;
}
body += '\n';
} catch (e) {
body = bodyText;
}
super(
`${method} ${resourceUrl} responded ${response.status} ${
response.statusText
}: \n\n${body}`,
...args
);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, M2ApiResponseError);
}
this.response = response;
this.method = method;
this.resourceUrl = resourceUrl;
}
}
@@ -0,0 +1,69 @@
/**
* Network requests that have not yet fulfilled, available for sharing with
* other idempotent and safe M2ApiRequests for the same resource.
* Resource matching is determined by a string composite [method, path, body].
*
* (M2ApiRequests know not to use this cache for create operations, except for
* singleton create operations like createGuestCart, which have no body.)
* @module MulticastCache
*/
/**
* String keyed map of in-flight requests. When a request completes,
* it should be removed.
* @private
*/
const inflight = new Map();
/**
* Reference cache to reduce repetitive requestToKey() calls.
* @private
*/
const keyCache = new WeakMap();
/**
*
* @private
* @param {M2ApiRequest} req
* @return string Key for use in inflight cache.
*/
function requestToKey(req) {
let key = keyCache.get(req);
if (!key) {
const { method, body } = req.opts;
const parts = [method, req.resourceUrl];
if (body) {
parts.push(body);
}
key = parts.join('|||');
keyCache.set(req, key);
}
return key;
}
/**
* Returns any inflight request with the same key as the supplied request.
* May be the same request itself!
* @param {M2ApiRequest} req The request to match.
* @return {M2ApiRequest} A request with the same method, body, and resourceUrl..
*/
export function match(req) {
return inflight.get(requestToKey(req));
}
/**
* Store a request for potential future multicast.
* Adds a callback to delete the request when it has settled.
* @param {M2ApiRequest} req The request to store.
*/
export function store(req) {
inflight.set(requestToKey(req), req);
}
/**
* Remove a request from cache if it exists there.
* @param {M2ApiRequest} req
*/
export function remove(req) {
if (match(req) === req) {
inflight.delete(requestToKey(req));
}
}
@@ -0,0 +1,344 @@
import M2ApiRequest, { request } from '../M2ApiRequest';
const mockFetch = jest.fn();
const responseJson = req => req.getResponse().then(res => res.json());
M2ApiRequest.prototype._transport = mockFetch;
function mockFetchReturned({
status = 200,
statusText = 'OK',
text,
json,
delay = 0
}) {
mockFetch.mockImplementationOnce(
(_, { signal }) =>
new Promise((resolve, reject) => {
let body = json
? JSON.stringify(typeof json === 'function' ? json() : json)
: typeof text === 'function'
? text()
: text;
const timeout = setTimeout(
() =>
resolve(
new Response(body, {
status,
statusText
})
),
delay
);
signal.onabort = () => {
clearTimeout(timeout);
const e = new Error('Aborted');
e.name = 'AbortError';
reject(e);
};
})
);
}
function mockFetchRejected(e, { delay = 0 } = {}) {
mockFetch.mockImplementationOnce(
() => new Promise((_, reject) => setTimeout(() => reject(e), delay))
);
}
afterEach(() => {
mockFetch.mockReset();
});
test('runs fetch and returns a promise for response object', async () => {
mockFetchReturned({
json: {
some: 'data'
}
});
const req = new M2ApiRequest('fake-path');
req.run();
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
'fake-path',
expect.objectContaining({
headers: expect.any(Headers),
credentials: 'include',
signal: expect.any(AbortSignal)
})
);
await expect(responseJson(req)).resolves.toEqual({
some: 'data'
});
});
test('returns a rejected promise when http response is not 2xx', async () => {
mockFetchReturned({
status: 500,
statusText: 'Server Yuck',
text: JSON.stringify({
error: {
message: 'That sucked',
stack: 'php\nstack\ntrace'
}
})
});
const req = new M2ApiRequest('fake-path');
req.run();
await expect(req.getResponse()).rejects.toThrowError(
'GET fake-path responded 500 Server Yuck'
);
});
test('throws an error if .run() has not been called', () => {
const req = new M2ApiRequest('somewhere');
expect(() => req.getResponse()).toThrowErrorMatchingSnapshot();
});
test('throws an error if underlying transport throws an error', async () => {
mockFetchRejected(new Error('Something weird happened'));
const req = new M2ApiRequest('somewhere');
req.run();
await expect(req.getResponse()).rejects.toThrow('Something weird happened');
});
test('can be aborted', async () => {
mockFetchReturned({
json: 'never gets to you',
delay: 500
});
const req = new M2ApiRequest('somewhere', {
method: 'POST',
body: 'something'
});
req.run();
req.abortRequest();
await expect(req.getResponse()).rejects.toThrowErrorMatchingSnapshot();
});
test('multicasts a request that appears safe and idempotent', async () => {
jest.useFakeTimers();
const uniqueId = Math.random().toString(16);
mockFetchReturned({
json: uniqueId,
delay: 1000
});
const req = new M2ApiRequest('some-empty-post', {
method: 'POST'
});
req.run();
const subsequentReq = new M2ApiRequest('some-empty-post', {
method: 'POST'
});
subsequentReq.run();
jest.runAllTimers();
expect(mockFetch).toHaveBeenCalledTimes(1);
const result = await responseJson(req);
expect(result).toEqual(uniqueId);
expect(result).toEqual(await responseJson(subsequentReq));
jest.useRealTimers();
});
test('does not multicast a settled request', async () => {
jest.useFakeTimers();
mockFetchReturned({
json: 'response1',
delay: 100
});
mockFetchReturned({
json: 'response2',
delay: 1000
});
const req = new M2ApiRequest('some-cacheable-operation');
req.run();
jest.advanceTimersByTime(500);
expect(await responseJson(req)).toEqual('response1');
const subsequentReq = new M2ApiRequest('some-cacheable-operation');
subsequentReq.run();
expect(mockFetch).toHaveBeenCalledTimes(2);
jest.runAllTimers();
expect(await responseJson(subsequentReq)).toEqual('response2');
jest.useRealTimers();
});
test('does not multicast a request that is clearly not idempotent/safe', async () => {
jest.useFakeTimers();
mockFetchReturned({
json: 'response1',
delay: 1000
});
mockFetchReturned({
json: 'response2',
delay: 200
});
const req = new M2ApiRequest('some-create-operation', {
method: 'POST',
body: 'do stuff'
});
req.run();
const subsequentReq = new M2ApiRequest('some-create-operation', {
method: 'POST',
body: 'do stuff'
});
subsequentReq.run();
expect(mockFetch).toHaveBeenCalledTimes(2);
jest.advanceTimersByTime(500);
expect(await responseJson(subsequentReq)).toEqual('response2');
jest.runAllTimers();
expect(await responseJson(req)).toEqual('response1');
jest.useRealTimers();
});
test('multicasts an unsafe request if `multicast` option is true', async () => {
jest.useFakeTimers();
mockFetchReturned({
json: 'response1',
delay: 1000
});
mockFetchReturned({
json: 'response2',
delay: 200
});
const req = new M2ApiRequest('some-create-operation', {
method: 'POST',
body: 'do stuff',
multicast: true
});
req.run();
const subsequentReq = new M2ApiRequest('some-create-operation', {
method: 'POST',
body: 'do stuff',
multicast: true
});
subsequentReq.run();
expect(mockFetch).toHaveBeenCalledTimes(1);
// observe that the second mock was set to resolve faster, but multicast
// reuses the first mock
let subsequentReqResolved = false;
subsequentReq.getResponse().then(() => {
subsequentReqResolved = true;
});
jest.advanceTimersByTime(500);
expect(subsequentReqResolved).toBe(false);
jest.runAllTimers();
expect(await responseJson(req)).toEqual('response1');
expect(await responseJson(subsequentReq)).toEqual('response1');
expect(subsequentReqResolved).toBe(true);
jest.useRealTimers();
});
test('does not multicast a safe request if `multicast` option is false', async () => {
mockFetchReturned({
json: 'updated1'
});
mockFetchReturned({
json: 'updated2'
});
const req = new M2ApiRequest('resource-to-update', {
method: 'PUT',
body: 'new value',
multicast: false
});
req.run();
const subsequentReq = new M2ApiRequest('resource-to-update', {
method: 'PUT',
body: 'new value',
multicast: false
});
subsequentReq.run();
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(await responseJson(req)).toEqual('updated1');
expect(await responseJson(subsequentReq)).toEqual('updated2');
});
test('if cache is set to reload or no-store, aborts and replaces a matching multicast', async () => {
mockFetchReturned({
json: {
shouldBeOverridden: true
},
delay: 50
});
mockFetchReturned({
json: {
shouldOverride: true
},
delay: 100
});
const req = new M2ApiRequest('slow-resource');
req.run();
const rolls = new M2ApiRequest('slow-resource', {
cache: 'no-store'
});
rolls.run();
await expect(responseJson(rolls)).resolves.toHaveProperty('shouldOverride');
await expect(responseJson(req)).resolves.toHaveProperty('shouldOverride');
});
test('multicasts can be manually aborted', async () => {
mockFetchReturned({
json: 'never gets to you',
delay: 500
});
const req = new M2ApiRequest('somewhere');
req.run();
req.abortRequest();
await expect(responseJson(req)).rejects.toThrowErrorMatchingSnapshot();
});
test('headers can be updated with object literal', async () => {
mockFetchReturned({
json: {
some: 'otherdata'
}
});
await expect(
request('somewhere', {
cache: 'reload',
headers: {
'If-Modified-Since': new Date().toISOString()
}
})
).resolves.toEqual({
some: 'otherdata'
});
});
test('headers can be updated with Header instance', async () => {
mockFetchReturned({
json: {
some: 'otherdata'
}
});
await expect(
request('somewhere', {
cache: 'reload',
headers: new Headers({
Accept: 'text/plain'
})
})
).resolves.toEqual({
some: 'otherdata'
});
});
test('convenience method creates, runs, and promises a request', async () => {
mockFetchReturned({
json: {
some: 'otherdata'
}
});
await expect(request('somewhere', { cache: 'reload' })).resolves.toEqual({
some: 'otherdata'
});
});
test('convenience method returns a response if parseJSON is false', async () => {
mockFetchReturned({
json: {
some: 'otherdata'
}
});
const response = await request('somewhere', { parseJSON: false });
expect(response).toBeInstanceOf(Response);
await expect(response.json()).resolves.toEqual({
some: 'otherdata'
});
});
@@ -0,0 +1,62 @@
import M2ApiResponseError from '../M2ApiResponseError';
test('pretty prints a JSON response', () => {
const { message } = new M2ApiResponseError({
method: 'GET',
resourceUrl: 'bad-path',
response: {
status: 500,
statusText: 'Just the worst'
},
bodyText: JSON.stringify({
message: 'Server error 1',
trace: 'Server error 1 trace'
})
});
expect(message).toMatchSnapshot();
});
test('handles random extra properties', () => {
const { message } = new M2ApiResponseError({
method: 'GET',
resourceUrl: 'bad-path',
response: {
status: 500,
statusText: 'Just the worst'
},
bodyText: JSON.stringify({
message: 'Server error 1',
trace: 'Server error 1 trace',
randomProp: 12
})
});
expect(message).toMatchSnapshot();
});
test('recovers when error properties cannot be parsed', () => {
const { message } = new M2ApiResponseError({
method: 'GET',
resourceUrl: 'bad-path',
response: {
status: 500,
statusText: 'Just the worst'
},
bodyText: '<p>I am unparseable</p>'
});
expect(message).toMatchSnapshot();
});
test('does not call Error.captureStackTrace if unavailable', () => {
const capture = Error.captureStackTrace;
Error.captureStackTrace = null;
new M2ApiResponseError({
method: 'GET',
resourceUrl: 'bad-path',
response: {
status: 500,
statusText: 'Just the worst'
},
bodyText: '<p>I am unparseable</p>'
});
Error.captureStackTrace = capture;
});
@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`can be aborted 1`] = `"Aborted"`;
exports[`multicasts can be manually aborted 1`] = `"Aborted"`;
exports[`throws an error if .run() has not been called 1`] = `"M2ApiRequest#getResponse() called before M2ApiRequest#run(), so no promise exists yet"`;
@@ -0,0 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`handles random extra properties 1`] = `
"GET bad-path responded 500 Just the worst:
Message:
Server error 1
Additional info:
{
\\"randomProp\\": 12
}
Magento PHP stack trace:
Server error 1 trace
"
`;
exports[`pretty prints a JSON response 1`] = `
"GET bad-path responded 500 Just the worst:
Message:
Server error 1
Magento PHP stack trace:
Server error 1 trace
"
`;
exports[`recovers when error properties cannot be parsed 1`] = `
"GET bad-path responded 500 Just the worst:
<p>I am unparseable</p>"
`;
@@ -0,0 +1 @@
export { default, request } from './M2ApiRequest';
@@ -0,0 +1,3 @@
import * as Magento2 from './Magento2';
export { Magento2 };
@@ -0,0 +1,168 @@
import React, { Component } from 'react';
import { func, shape, string } from 'prop-types';
import fetchRootComponent from 'FETCH_ROOT_COMPONENT';
import resolveUnknownRoute from './resolveUnknownRoute';
const InternalError = Symbol('InternalError');
const NotFound = Symbol('NotFound');
const mountedInstances = new WeakSet();
export default class MagentoRouteHandler extends Component {
static propTypes = {
apiBase: string.isRequired,
children: func,
location: shape({
pathname: string.isRequired
}).isRequired
};
state = {
componentMap: new Map(),
errorState: {
hasError: false,
internalError: false,
notFound: false
}
};
// TODO: Add the ability to customize the cache name
async addToCache(urls) {
if (!window.caches) {
throw new Error(
'Current environment does not support CacheStorage at window.caches.'
);
}
const myCache = await window.caches.open(
`workbox-runtime-${location.origin}/`
);
await myCache.addAll(urls);
}
componentDidMount() {
const { pathname } = this.props.location;
const isSearch = pathname === '/search.html';
mountedInstances.add(this);
if (!isSearch) {
this.getRouteComponent(pathname);
}
}
componentDidUpdate() {
const { props, state } = this;
const { pathname } = props.location;
const isKnown = state.componentMap.has(pathname);
const isSearch = pathname === '/search.html';
// `NOTFOUND` component needs a unique id
// currently it is set to -1
const isNotFoundComponent = isKnown
? state.componentMap.get(pathname).id === -1
: false;
const shouldReloadRoute = isNotFoundComponent && navigator.onLine;
if ((!isKnown && !isSearch) || shouldReloadRoute) {
this.getRouteComponent();
}
}
componentWillUnmount() {
mountedInstances.delete(this);
}
async getRouteComponent() {
const {
apiBase,
location: { pathname }
} = this.props;
try {
// try to resolve the route
// if this throws, we essentially have a 500 Internal Error
const resolvedRoute = await resolveUnknownRoute({
apiBase,
route: pathname
});
const { type, id } = resolvedRoute;
// if resolution and destructuring succeed but return no match
// then we have a straightforward 404 Not Found
if (!type || !id) {
throw new Error('404');
}
// at this point we should have a matching RootComponent
// if this throws, we essentially have a 500 Internal Error
const RootComponent = await fetchRootComponent(type);
// associate the matching RootComponent with this location
this.setRouteComponent(pathname, RootComponent, { id });
} catch ({ message }) {
const symbol = message === '404' ? NotFound : InternalError;
// we don't have a matching RootComponent, but we've checked for one
// so associate the appropriate error case with this location
this.setRouteComponent(pathname, symbol);
}
}
setRouteComponent(pathname, RootComponent, meta) {
if (!mountedInstances.has(this)) {
// avoid setState if component is not mounted for any reason
return;
}
this.addToCache([pathname]).catch(e => {
if (process.env.NODE_ENV === 'development') {
console.warn(`Could not add ${pathname} to cache:`, e);
}
});
this.setState(({ componentMap }) => ({
componentMap: new Map(componentMap).set(pathname, {
RootComponent,
...meta
}),
errorState: {
hasError: typeof RootComponent === 'symbol',
internalError: RootComponent === InternalError,
notFound: RootComponent === NotFound
}
}));
}
renderChildren(loading) {
const { props, state } = this;
const { children } = props;
const { errorState } = state;
return typeof children === 'function'
? children({ ...errorState, loading })
: null;
}
render() {
const { props, state } = this;
const { pathname } = props.location;
const { componentMap, errorState } = state;
// if we have no record of this pathname, we're still loading
// and we have no RootComponent, so render children
if (!componentMap.has(pathname)) {
return this.renderChildren(true);
}
// if we're in an error state, we're not loading anymore
// but we have no RootComponent, so render children
if (errorState.hasError) {
return this.renderChildren(false);
}
// otherwise we do have a RootComponent, so render it
const { RootComponent, ...routeProps } = componentMap.get(pathname);
return <RootComponent {...routeProps} key={pathname} />;
}
}
@@ -0,0 +1,34 @@
import React, { Component, createContext } from 'react';
import { BrowserRouter, Route } from 'react-router-dom';
import { func, object, string } from 'prop-types';
export const { Consumer, Provider } = createContext();
export default class MagentoRouter extends Component {
static propTypes = {
apiBase: string.isRequired,
routerProps: object,
using: func // e.g., BrowserRouter, MemoryRouter
};
static defaultProps = {
routerProps: {},
using: BrowserRouter
};
render() {
const { apiBase, children, routerProps, using: Router } = this.props;
return (
<Router {...routerProps}>
<Route>
{routeProps => (
<Provider value={{ apiBase, ...routeProps }}>
{children}
</Provider>
)}
</Route>
</Router>
);
}
}
@@ -0,0 +1,7 @@
import React, { Component } from 'react';
export default class MagentoRouteHandler extends Component {
render() {
return <i>hello</i>;
}
}
@@ -0,0 +1,166 @@
import React from 'react';
import MagentoRouteHandler from '../MagentoRouteHandler';
import { shallow } from 'enzyme';
import resolveUnknownRoute from '../resolveUnknownRoute';
import fetchRootComponent from 'FETCH_ROOT_COMPONENT';
jest.mock('FETCH_ROOT_COMPONENT', () => jest.fn(), { virtual: true });
jest.mock('../resolveUnknownRoute');
const apiBase = 'https://store.com';
const children = jest.fn();
const location = { pathname: '/foo.html' };
const props = { apiBase, children, location };
const resolvedRoute = {
type: 'CMS_PAGE',
id: 2
};
beforeEach(() => {
children.mockClear();
});
afterEach(() => {
resolveUnknownRoute.mockRestore();
fetchRootComponent.mockRestore();
});
test('renders `loading` while loading', () => {
shallow(<MagentoRouteHandler {...props} />);
expect(children).toHaveBeenCalledTimes(1);
expect(children).toHaveBeenNthCalledWith(1, {
hasError: false,
internalError: false,
loading: true,
notFound: false
});
});
test('renders `null` while loading if `children` is not a function', () => {
const localProps = { ...props };
delete localProps.children;
shallow(<MagentoRouteHandler {...localProps} />);
expect(children).not.toHaveBeenCalled();
});
test('renders `internalError` if `resolveUnknownRoute` fails', async () => {
resolveUnknownRoute.mockRejectedValue(new Error());
shallow(<MagentoRouteHandler {...props} />);
await Promise.resolve(); // resolveUnknownRoute
expect(children).toHaveBeenCalledTimes(2);
expect(children).toHaveBeenNthCalledWith(1, {
hasError: false,
internalError: false,
loading: true,
notFound: false
});
expect(children).toHaveBeenNthCalledWith(2, {
hasError: true,
internalError: true,
loading: false,
notFound: false
});
});
test('renders `notFound` if resolved route is not matched', async () => {
resolveUnknownRoute.mockResolvedValue({ matched: false });
shallow(<MagentoRouteHandler {...props} />);
await Promise.resolve(); // resolveUnknownRoute
expect(children).toHaveBeenCalledTimes(2);
expect(children).toHaveBeenNthCalledWith(1, {
hasError: false,
internalError: false,
loading: true,
notFound: false
});
expect(children).toHaveBeenNthCalledWith(2, {
hasError: true,
internalError: false,
loading: false,
notFound: true
});
});
test('renders `internalError` if `fetchRootComponent` fails', async () => {
resolveUnknownRoute.mockResolvedValue(resolvedRoute);
fetchRootComponent.mockRejectedValue(new Error());
const wrapper = shallow(<MagentoRouteHandler {...props} />);
await Promise.resolve(); // resolveUnknownRoute
await Promise.resolve(); // fetchRootComponent
expect(wrapper.state('componentMap').size).toBe(1);
expect(children).toHaveBeenCalledTimes(2);
expect(children).toHaveBeenNthCalledWith(1, {
hasError: false,
internalError: false,
loading: true,
notFound: false
});
expect(children).toHaveBeenNthCalledWith(2, {
hasError: true,
internalError: true,
loading: false,
notFound: false
});
});
test('renders RootComponent if `fetchRootComponent` succeeds', async () => {
const RootComponent = () => <i />;
resolveUnknownRoute.mockResolvedValue(resolvedRoute);
fetchRootComponent.mockResolvedValue(RootComponent);
const wrapper = shallow(<MagentoRouteHandler {...props} />);
await Promise.resolve(); // resolveUnknownRoute
await Promise.resolve(); // fetchRootComponent
expect(children).toHaveBeenCalledTimes(1);
expect(children).toHaveBeenNthCalledWith(1, {
hasError: false,
internalError: false,
loading: true,
notFound: false
});
expect(wrapper.find(RootComponent)).toHaveLength(1);
});
test('skips `fetchRootComponent` if path is known', async () => {
const RootComponent = () => <i />;
resolveUnknownRoute.mockResolvedValue(resolvedRoute);
fetchRootComponent.mockResolvedValue(RootComponent);
const wrapper = shallow(<MagentoRouteHandler {...props} />);
await Promise.resolve(); // resolveUnknownRoute
await Promise.resolve(); // fetchRootComponent
// navigate to `bar`
wrapper.setProps({ ...props, location: { pathname: '/bar.html' } });
await Promise.resolve(); // resolveUnknownRoute
await Promise.resolve(); // fetchRootComponent
// navigate back to `foo`
wrapper.setProps(props);
await Promise.resolve(); // resolveUnknownRoute
await Promise.resolve(); // fetchRootComponent
expect(children).toHaveBeenCalledTimes(2);
expect(fetchRootComponent).toHaveBeenCalledTimes(2);
expect(wrapper.find(RootComponent)).toHaveLength(1);
});
@@ -0,0 +1,67 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import { MemoryRouter } from 'react-router-dom';
import MagentoRouter, { Consumer as RouteConsumer } from '../Router';
const apiBase = 'https://store.com';
const initialEntries = ['/some-product.html'];
const routerProps = { initialEntries };
test('renders a single, catch-all route', () => {
const routesWrapper = shallow(
<MagentoRouter using={MemoryRouter} apiBase={apiBase} />
).find('Route');
expect(routesWrapper.length).toBe(1);
expect(routesWrapper.prop('path')).toBeUndefined();
});
test('passes `config` and route props to context provider', () => {
const fn = jest.fn();
const props = { apiBase, using: MemoryRouter };
// we need to test context consumer, so we can't shallow render
mount(
<MagentoRouter {...props}>
<RouteConsumer>{fn}</RouteConsumer>
</MagentoRouter>
);
expect(fn).toHaveBeenCalledWith(
expect.objectContaining({
apiBase,
history: expect.anything(), // from Route
location: expect.anything(), // from Route
match: expect.anything() // from Route
})
);
});
test('passes `routerProps` to router, not context provider', () => {
const fn = jest.fn();
const props = { apiBase, routerProps, using: MemoryRouter };
// we need to test context consumer, so we can't shallow render
const wrapper = mount(
<MagentoRouter {...props}>
<RouteConsumer>{fn}</RouteConsumer>
</MagentoRouter>
);
expect(fn).toHaveBeenCalledWith(
expect.not.objectContaining({
initialEntries: expect.anything()
})
);
expect(wrapper.find('Router').instance().props).toEqual(
expect.objectContaining({})
);
expect(fn).toHaveBeenCalledWith(
expect.objectContaining({
history: expect.objectContaining({
length: initialEntries.length
})
})
);
});
@@ -0,0 +1,184 @@
import resolveUnknownRoute from '../resolveUnknownRoute';
const urlResolverRes = (type, id) =>
JSON.stringify({
data: {
urlResolver: { type, id }
}
});
const NotFoundManifest = {
NotFound: {
rootChunkID: -1,
rootModuleID: 100,
pageTypes: ['NOTFOUND']
}
};
const mockManifest = {
Category: {
rootChunkID: 2,
rootModuleID: 100,
pageTypes: ['CATEGORY']
},
Product: {
rootChunkID: 1,
rootModuleID: 99,
pageTypes: ['PRODUCT']
},
...NotFoundManifest
};
const cachedResponse = JSON.stringify({
'foo-bar.html': {
...JSON.parse(urlResolverRes('PRODUCT'))
}
});
const isOnline = _value => ({
get: () => _value,
set: v => (_value = v)
});
Object.defineProperty(navigator, 'onLine', isOnline(true));
function clearLocalStorage(item) {
localStorage.setItem(item, null);
}
beforeEach(() => {
navigator.onLine = true;
clearLocalStorage('urlResolve');
document.body.innerHTML = '';
resolveUnknownRoute.preloadDone = false;
fetch.resetMocks();
});
test('Preload path: resolves directly from preload element', async () => {
document.body.innerHTML =
'<script type="application/json" id="url-resolver">{ "type": "PRODUCT", "id": "VA-123" }</script>';
const res = await resolveUnknownRoute({
route: 'foo-bar.html',
apiBase: 'https://example.com'
});
expect(res).toMatchObject({
type: 'PRODUCT',
id: 'VA-123'
});
});
test('returns NOTFOUND when offline and requested content is not in cache ', async () => {
navigator.onLine = false;
fetch.mockResponseOnce(JSON.stringify(mockManifest));
const res = await resolveUnknownRoute({
route: 'foo-bar.html',
apiBase: 'https://store.com',
__tmp_webpack_public_path__: 'https://dev-server.com/pub'
});
expect(res).toHaveProperty('id', NotFoundManifest.NotFound.rootChunkID);
});
test('stores response of urlResolver in cache', async () => {
fetch.mockResponseOnce(urlResolverRes('PRODUCT'));
fetch.mockResponseOnce(JSON.stringify(mockManifest));
const url = 'foo-bar.html';
await resolveUnknownRoute({
route: url,
apiBase: 'https://store.com',
__tmp_webpack_public_path__: 'https://dev-server.com/pub'
});
expect(localStorage.getItem('urlResolve')).not.toBeNull();
});
test('does not call fetchRoute when response is cached', async () => {
localStorage.setItem('urlResolve', cachedResponse);
fetch.mockResponseOnce(JSON.stringify(mockManifest));
await resolveUnknownRoute({
route: 'foo-bar.html',
apiBase: 'https://store.com',
__tmp_webpack_public_path__: 'https://dev-server.com/pub'
});
expect(fetch).toHaveBeenCalledTimes(0);
});
test('calls fetchRoute when response is not cached', async () => {
fetch.mockResponseOnce(urlResolverRes('PRODUCT'));
fetch.mockResponseOnce(JSON.stringify(mockManifest));
await resolveUnknownRoute({
route: 'foo-bar.html',
apiBase: 'https://store.com',
__tmp_webpack_public_path__: 'https://dev-server.com/pub'
});
expect(fetch).toHaveBeenCalledTimes(1);
});
test('urlResolver path: resolve using fetch to GraphQL after one preload', async () => {
fetch.mockResponseOnce(urlResolverRes('PRODUCT', 'VA-11'));
document.body.innerHTML =
'<script type="application/json" id="url-resolver">{ "type": "CMS_PAGE", "id": "1" }</script>';
const preloadRes = await resolveUnknownRoute({
route: 'foo-bar.html',
apiBase: 'https://store.com'
});
expect(preloadRes).toMatchObject({
type: 'CMS_PAGE',
id: 1
});
expect(fetch).not.toHaveBeenCalled();
const res = await resolveUnknownRoute({
route: 'foo-bar.html',
apiBase: 'https://store.com'
});
expect(res).toMatchObject({
type: 'PRODUCT',
id: 'VA-11'
});
expect(fetch).toHaveBeenCalledTimes(1);
});
test('Preload path: skips if preload element not found', async () => {
fetch.mockResponseOnce(urlResolverRes('CATEGORY', 2));
const res = await resolveUnknownRoute({
route: 'foo-bar.html',
apiBase: 'https://store.com'
});
expect(res).toMatchObject({
type: 'CATEGORY',
id: 2
});
});
test('Preload path: skips if preload element unparseable', async () => {
document.body.innerHTML =
'<script type="application/json" id="url-resolver"> "type": "CMS_PAGE", "id": "1" }</script>';
fetch.mockResponseOnce(urlResolverRes('CATEGORY', 2));
const res = await resolveUnknownRoute({
route: 'foo-bar.html',
apiBase: 'https://store.com'
});
expect(res).toMatchObject({
type: 'CATEGORY',
id: 2
});
});
test('Preload path: casts numbers to number', async () => {
document.body.innerHTML =
'<script type="application/json" id="url-resolver">{ "type": "CMS_PAGE", "id": "1" }</script>';
const res = await resolveUnknownRoute({
route: 'foo-bar.html',
apiBase: 'https://store.com'
});
expect(res).toMatchObject({
type: 'CMS_PAGE',
id: 1
});
});
@@ -0,0 +1,6 @@
export { default as MagentoRouteHandler } from './MagentoRouteHandler';
export {
default,
Consumer as RouteConsumer,
Provider as RouteProvider
} from './Router';
@@ -0,0 +1,109 @@
/**
* @description Given a route string, resolves with the "standard route", along
* with the assigned Root Component (and its owning chunk) from the backend
* @param {{ route: string, apiBase: string, __tmp_webpack_public_path__: string}} opts
*/
const numRE = /^\d+$/;
export default async function resolveUnknownRoute(opts) {
const { route, apiBase } = opts;
if (!resolveUnknownRoute.preloadDone) {
resolveUnknownRoute.preloadDone = true;
const preloaded = document.getElementById('url-resolver');
if (preloaded) {
try {
const preload = JSON.parse(preloaded.textContent);
// UPWARD treats most values as strings, so explicitly cast
// numbers if they appear to be numbers
if (typeof preload.id === 'string' && numRE.test(preload.id)) {
return {
type: preload.type,
id: Number(preload.id)
};
}
return preload;
} catch (e) {
// istanbul ignore next: will never happen in test
if (process.env.NODE_ENV === 'development') {
console.error(
'Unable to read preload!',
preloaded.textContent,
e
);
}
}
}
}
return remotelyResolveRoute({
route,
apiBase
});
}
/**
* @description Checks if route is stored in localStorage, if not call `fetchRoute`
* @param {{ route: string, apiBase: string}} opts
* @returns {Promise<{type: "PRODUCT" | "CATEGORY" | "CMS_PAGE"}>}
*/
function remotelyResolveRoute(opts) {
let urlResolve = localStorage.getItem('urlResolve');
urlResolve = JSON.parse(urlResolve);
// If it exists in localStorage, use that value
// TODO: This can be handled by workbox once this issue is resolved in the
// graphql repo: https://github.com/magento/graphql-ce/issues/229
if ((urlResolve && urlResolve[opts.route]) || !navigator.onLine) {
if (urlResolve && urlResolve[opts.route]) {
return Promise.resolve(urlResolve[opts.route].data.urlResolver);
} else {
return Promise.resolve({
type: 'NOTFOUND',
id: -1
});
}
} else {
return fetchRoute(opts);
}
}
/**
* @description Calls the GraphQL API for results from the urlResolver query
* @param {{ route: string, apiBase: string}} opts
* @returns {Promise<{type: "PRODUCT" | "CATEGORY" | "CMS_PAGE"}>}
*/
function fetchRoute(opts) {
const url = new URL('/graphql', opts.apiBase);
return fetch(url, {
method: 'POST',
credentials: 'include',
headers: new Headers({
'Content-Type': 'application/json'
}),
body: JSON.stringify({
query: `
{
urlResolver(url: "${opts.route}") {
type
id
}
}
`.trim()
})
})
.then(res => res.json())
.then(res => {
storeURLResolveResult(res, opts);
return res.data.urlResolver;
});
}
// TODO: This can be handled by workbox once this issue is resolved in the
// graphql repo: https://github.com/magento/graphql-ce/issues/229
function storeURLResolveResult(res, opts) {
const storedRoute = localStorage.getItem('urlResolve');
const item = JSON.parse(storedRoute) || {};
item[opts.route] = res;
localStorage.setItem('urlResolve', JSON.stringify(item));
}
@@ -0,0 +1,7 @@
export default {
// https://webpack.js.org/api/module-variables/#__webpack_chunk_load__-webpack-specific-
loadChunk:
process.env.NODE_ENV === 'test' ? () => {} : __webpack_chunk_load__,
// https://webpack.js.org/api/module-variables/#__webpack_require__-webpack-specific-
require: process.env.NODE_ENV === 'test' ? () => {} : __webpack_require__
};
@@ -0,0 +1,10 @@
import * as RestApi from './RestApi';
import * as Util from './util';
export { default as ContainerChild } from './ContainerChild';
export { default as List, Items, Item } from './List';
export { default as Page } from './Page';
export { default as Price } from './Price';
export { default as Router } from './Router';
export { RestApi };
export { Util };
@@ -0,0 +1,52 @@
import React from 'react';
import { shallow } from 'enzyme';
import fromRenderProp, { filterProps } from '../fromRenderProp';
test('returns a component', () => {
const Div = fromRenderProp('div');
expect(Div).toBeInstanceOf(Function);
});
test('returns a basic component that renders', () => {
const Div = fromRenderProp('div');
const wrapper = shallow(<Div>foo</Div>);
expect(wrapper.prop('children')).toBe('foo');
});
test('returns a composite component that renders', () => {
const Foo = props => <div {...props} />;
const WrappedFoo = fromRenderProp(Foo);
const wrapper = shallow(<WrappedFoo>foo</WrappedFoo>);
expect(wrapper.prop('children')).toBe('foo');
});
test('excludes custom props for a basic component', () => {
const Div = fromRenderProp('div', ['foo']);
const wrapper = shallow(<Div foo="bar" />);
expect(wrapper.prop('foo')).toBeUndefined();
});
test('includes custom props for a composite component', () => {
const Foo = props => <div {...props} />;
const WrappedFoo = fromRenderProp(Foo, ['foo']);
const wrapper = shallow(<WrappedFoo foo="bar" />);
expect(wrapper.prop('foo')).toBe('bar');
});
test('`filterProps` returns an object', () => {
expect(filterProps()).toEqual({});
});
test('`filterProps` filters properties from an object', () => {
const input = { a: 0, b: 1 };
const output = { b: 1 };
const excludedProps = ['a'];
expect(filterProps(input, excludedProps)).toEqual(output);
});
@@ -0,0 +1,81 @@
import patches from '../intlPatches';
import IntlPolyfill from 'intl';
const patchedFormatter = cfg =>
new Proxy(Intl.NumberFormat(undefined, cfg), {
get(target, prop) {
if (prop === 'formatToParts') {
return false;
}
if (prop === 'resolvedOptions') {
return () => target.resolvedOptions();
}
return target[prop];
}
});
const standardFormatter = cfg => IntlPolyfill.NumberFormat(undefined, cfg);
require('intl/locale-data/jsonp/en.js');
const formatToPartsPatch = jest.spyOn(patches, 'formatToPartsPatch');
// IntlPolyfill behaves differently on Node 8 and Node 10, but only in small
// ways; namely, for an unrecognized currency, Node 10 inserts additional
// whitespace between the currency code and the first integer.
// This test shouldn't care about whitespace, so we pass everything through
// a filter function that strips those literals with whitespace.
const stripWhitespaceFromParts = parts =>
parts.filter(
({ type, value }) => !(type === 'literal' && /^\s*$/.test(value))
);
const callToParts = (formatter, config, num) =>
stripWhitespaceFromParts(patches.toParts.call(formatter(config), num));
const compareOutputs = (config, num) =>
expect(callToParts(patchedFormatter, config, num)).toEqual(
callToParts(standardFormatter, config, num)
);
test('does not use patch if native method exists', () => {
callToParts(
standardFormatter,
{ style: 'currency', currency: 'usd' },
12000
);
expect(formatToPartsPatch).not.toHaveBeenCalled();
});
test('matches grouped USD format if currency unrecognized', () =>
compareOutputs(
{
style: 'currency',
currency: 'YTT'
},
12000
));
test('matches USD format with no grouping', () =>
compareOutputs(
{
style: 'currency',
currency: 'USD',
useGrouping: false
},
12000
));
test('handles zero input', () =>
compareOutputs({ style: 'currency', currency: 'USD' }, 0));
test('fixes and rounds decimals', () =>
compareOutputs({ style: 'currency', currency: 'USD' }, 100.1285));
test('matches EUR format', () =>
compareOutputs(
{
style: 'currency',
currency: 'EUR',
useGrouping: false
},
100000.99
));
@@ -0,0 +1,22 @@
import memoize from '../unaryMemoize';
test('caches results', () => {
const fn = memoize(i => ({ i }));
const a = fn(0);
const b = fn(1);
const c = fn(0);
expect(a).not.toBe(b);
expect(a).toBe(c);
});
test('calls the function only on cache miss', () => {
const noop = jest.fn();
const fn = memoize(i => noop(i));
fn(0);
fn(0);
fn(1);
expect(noop).toHaveBeenCalledTimes(2);
});
@@ -0,0 +1,46 @@
import React from 'react';
// memoization cache
const cache = new Map();
export const filterProps = (props = {}, blacklist = []) =>
Object.entries(props).reduce((r, [k, v]) => {
if (!blacklist.includes(k)) {
r[k] = v;
}
return r;
}, {});
const fromRenderProp = (elementType, customProps = []) => {
const isComposite = typeof elementType === 'function';
// if `elementType` is a function, it's already a component
if (isComposite) {
return elementType;
}
// sort and de-dupe `customProps`
const uniqueCustomProps = Array.from(new Set([...customProps].sort()));
// hash arguments for memoization
const key = `${elementType}//${uniqueCustomProps.join(',')}`;
// only create a new component if not cached
// otherwise React will unmount on every render
if (!cache.has(key)) {
// create an SFC that renders a node of type `elementType`
// and filter any props that shouldn't be written to the DOM
const Component = props =>
React.createElement(
elementType,
filterProps(props, uniqueCustomProps)
);
Component.displayName = `fromRenderProp(${elementType})`;
cache.set(key, Component);
}
return cache.get(key);
};
export default fromRenderProp;
@@ -0,0 +1 @@
export { default as BrowserPersistence } from './simplePersistence';
@@ -0,0 +1,101 @@
/**
* A [ponyfill](https://github.com/sindresorhus/ponyfill) is an opt-in polyfill,
* which replaces missing builtins on older or non-compliant platforms, but
* without monkey-patching the native API.
*
* Many browsers do not yet support Intl.NumberFormat.prototype.formatToParts,
* which is tracked here: https://github.com/tc39/proposal-intl-formatToParts
*
* The polyfill available in that repository covers many edge cases, but it's
* more functionality than we need at this stage, it's not distributed over
* NPM, and it's 18kb. The below
* [ponyfill](https://github.com/sindresorhus/ponyfill) is not fully compliant,
* but it'll do for now.
*
* TODO: Replace with a formally maintained, but small-enough, ponyfill.
*/
const intlFormats = {
USD: {
symbol: '$',
decimal: '.',
groupDelim: ','
},
GBP: {
symbol: '£',
decimal: '.',
groupDelim: ','
},
EUR: {
symbol: '€',
decimal: '.',
groupDelim: ','
}
};
const IntlPatches = {
formatToPartsPatch({ currency, maximumFractionDigits, useGrouping }, num) {
const format = intlFormats[currency] || {
...intlFormats.USD,
symbol: currency
};
const { symbol, decimal, groupDelim } = format;
let [integer, fraction] = num
.toFixed(maximumFractionDigits)
.match(/\d+/g);
const parts = [{ type: 'currency', value: symbol }];
if (useGrouping !== false) {
const intParts = [];
const firstGroupLength = integer.length % 3;
if (firstGroupLength > 0) {
intParts.push(
JSON.stringify({
type: 'integer',
value: integer.slice(0, firstGroupLength)
})
);
integer = integer.slice(firstGroupLength);
}
const groups = integer.match(/\d{3}/g);
if (groups) {
intParts.push(
...groups.map(intPart =>
JSON.stringify({
type: 'integer',
value: intPart
})
)
);
}
const groupDelimJSON =
',' +
JSON.stringify({
type: 'group',
value: groupDelim
}) +
',';
const intAndGroupParts = JSON.parse(
`[${intParts.join(groupDelimJSON)}]`
);
parts.push(...intAndGroupParts);
} else {
parts.push({ type: 'integer', value: integer });
}
return parts.concat([
{ type: 'decimal', value: decimal },
{ type: 'fraction', value: fraction }
]);
},
toParts(num) {
return this.formatToParts
? this.formatToParts(num)
: IntlPatches.formatToPartsPatch(this.resolvedOptions(), num);
}
};
export default IntlPatches;
@@ -0,0 +1,59 @@
/**
* Persistence layer with expiration based on localStorage.
*/
class NamespacedLocalStorage {
constructor(localStorage, key) {
this.localStorage = localStorage;
this.key = key;
}
_makeKey(key) {
return `${this.key}__${key}`;
}
getItem(name) {
return this.localStorage.getItem(this._makeKey(name));
}
setItem(name, value) {
return this.localStorage.setItem(this._makeKey(name), value);
}
removeItem(name) {
return this.localStorage.removeItem(this._makeKey(name));
}
}
export default class BrowserPersistence {
static KEY = 'M2_VENIA_BROWSER_PERSISTENCE';
constructor() {
this.storage = new NamespacedLocalStorage(
window.localStorage,
this.constructor.KEY || BrowserPersistence.KEY
);
}
getItem(name) {
const now = Date.now();
const item = this.storage.getItem(name);
if (!item) {
return undefined;
}
const { value, ttl, timeStored } = JSON.parse(item);
if (ttl && now - timeStored > ttl * 1000) {
this.storage.removeItem(name);
return undefined;
}
return JSON.parse(value);
}
setItem(name, value, ttl) {
const timeStored = Date.now();
this.storage.setItem(
name,
JSON.stringify({
value: JSON.stringify(value),
timeStored,
ttl
})
);
}
removeItem(name) {
this.storage.removeItem(name);
}
}
@@ -0,0 +1,7 @@
const memoize = fn => {
const cache = new Map();
return x => (cache.has(x) ? cache.get(x) : cache.set(x, fn(x)).get(x));
};
export default memoize;
@@ -0,0 +1,70 @@
import iterable from '../iterable';
const name = 'MyComponent';
const props = {
b: null,
c: '',
d: [],
e: new Map(),
f: {}
};
test('returns nothing if prop is undefined', () => {
const result = iterable(props, 'a', name);
expect(result).toBeUndefined();
});
test('returns nothing if prop is `null`', () => {
const result = iterable(props, 'b', name);
expect(result).toBeUndefined();
});
test('returns an error if required and prop is undefined', () => {
const result = iterable.isRequired(props, 'a', name);
expect(result).toBeInstanceOf(Error);
});
test('returns an error if required and prop is `null`', () => {
const result = iterable.isRequired(props, 'b', name);
expect(result).toBeInstanceOf(Error);
});
test('returns nothing if prop is a string', () => {
const result = iterable(props, 'c', name);
expect(result).toBeUndefined();
});
test('returns nothing if prop is an array', () => {
const result = iterable(props, 'd', name);
expect(result).toBeUndefined();
});
test('returns nothing if prop is a Map', () => {
const result = iterable(props, 'e', name);
expect(result).toBeUndefined();
});
test('returns an error if prop is not iterable', () => {
const result = iterable(props, 'f', name);
expect(result).toBeInstanceOf(Error);
});
test('returns a proper error object', () => {
const result = iterable(props, 'f', name);
const thrower = () => {
throw result;
};
expect(thrower).toThrow(
'Invalid prop `f` of type `object` supplied to `MyComponent`, expected `iterable`.'
);
});
@@ -0,0 +1,27 @@
const isIterable = obj => typeof obj[Symbol.iterator] === 'function';
function optionalIterable(props, propName, componentName) {
const prop = props[propName];
const type = typeof prop;
if (prop != null && !isIterable(prop)) {
return new Error(
`Invalid prop \`${propName}\` of type \`${type}\` supplied to \`${componentName}\`, expected \`iterable\`.`
);
}
}
function requiredIterable(props, propName, componentName) {
const prop = props[propName];
const type = typeof prop;
if (prop == null || !isIterable(prop)) {
return new Error(
`Invalid prop \`${propName}\` of type \`${type}\` supplied to \`${componentName}\`, expected \`iterable\`.`
);
}
}
optionalIterable.isRequired = requiredIterable;
export default optionalIterable;
@@ -0,0 +1,17 @@
{
"presets": [
[
"env",
{
"targets": {
"node": [
"8"
]
}
}
]
],
"plugins": [
["transform-class-properties", { "spec": true }]
]
}
@@ -0,0 +1,21 @@
const rootPkg = require('../../package.json');
const rootModules = Object.keys(rootPkg.devDependencies).concat(
rootPkg.dependencies ? Object.keys(rootPkg.dependencies) : []
);
const uniqueRootModules = [...new Set(rootModules)];
const config = {
parser: 'babel-eslint',
parserOptions: {
sourceType: 'script'
},
extends: ['@magento', 'plugin:node/recommended'],
plugins: ['babel', 'node'],
settings: {
node: {
allowModules: uniqueRootModules
}
}
};
module.exports = config;
@@ -0,0 +1 @@
__tests__
@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "none",
"tabWidth": 4
}
@@ -0,0 +1,73 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
<a name="0.9.0"></a>
# 0.9.0 (2018-08-13)
### Bug Fixes
* disable buggy origin sub by default ([#23](https://github.com/magento-research/pwa-studio/issues/23)) ([fde4036](https://github.com/magento-research/pwa-studio/commit/fde4036))
* remove buggy sudo-prompt until fallback works ([#39](https://github.com/magento-research/pwa-studio/issues/39)) ([1a1da3e](https://github.com/magento-research/pwa-studio/commit/1a1da3e)), closes [#35](https://github.com/magento-research/pwa-studio/issues/35)
* update devcert dependency ([68c7cca](https://github.com/magento-research/pwa-studio/commit/68c7cca))
* **dev:** MagentoRootComponentsPlugin dedupe fix ([bb8b152](https://github.com/magento-research/pwa-studio/commit/bb8b152))
* **dev:** merge 'assets' and 'output' paths ([36d8157](https://github.com/magento-research/pwa-studio/commit/36d8157))
* **DevProxy:** put proxy after other middlewares ([#33](https://github.com/magento-research/pwa-studio/issues/33)) ([7fa577c](https://github.com/magento-research/pwa-studio/commit/7fa577c)), closes [#32](https://github.com/magento-research/pwa-studio/issues/32)
* **util:** fix run-as-root await rm bug ([#28](https://github.com/magento-research/pwa-studio/issues/28)) ([e6f0754](https://github.com/magento-research/pwa-studio/commit/e6f0754)), closes [magento-research/venia-pwa-concept#51](https://github.com/magento-research/venia-pwa-concept/issues/51)
### Features
* add protocol error detection to devproxy ([#22](https://github.com/magento-research/pwa-studio/issues/22)) ([7ff4aaa](https://github.com/magento-research/pwa-studio/commit/7ff4aaa))
* **data:** Add live GraphQL data to product detail page ([#90](https://github.com/magento-research/pwa-studio/issues/90)) ([77b6bd6](https://github.com/magento-research/pwa-studio/commit/77b6bd6)), closes [#52](https://github.com/magento-research/pwa-studio/issues/52) [#87](https://github.com/magento-research/pwa-studio/issues/87)
* **dev:** app shell detects env from proxy header ([ac5b0b0](https://github.com/magento-research/pwa-studio/commit/ac5b0b0))
* **dev:** log dev URL prominently after build ([#114](https://github.com/magento-research/pwa-studio/issues/114)) ([84fadde](https://github.com/magento-research/pwa-studio/commit/84fadde)), closes [#4](https://github.com/magento-research/pwa-studio/issues/4)
<a name="0.8.2"></a>
## [0.8.2](https://github.com/magento-research/pwa-buildpack/compare/v0.8.1...v0.8.2) (2018-05-26)
### Bug Fixes
* update devcert dependency ([ed4c1d5](https://github.com/magento-research/pwa-buildpack/commit/ed4c1d5))
<a name="0.8.1"></a>
## [0.8.1](https://github.com/magento-research/pwa-buildpack/compare/v0.7.1...v0.8.1) (2018-05-26)
### Bug Fixes
* remove buggy sudo-prompt until fallback works ([#39](https://github.com/magento-research/pwa-buildpack/issues/39)) ([99828aa](https://github.com/magento-research/pwa-buildpack/commit/99828aa)), closes [#35](https://github.com/magento-research/pwa-buildpack/issues/35)
<a name="0.7.1"></a>
## [0.7.1](https://github.com/magento-research/pwa-buildpack/compare/v0.7.0...v0.7.1) (2018-05-15)
### Bug Fixes
* **DevProxy:** put proxy after other middlewares ([#33](https://github.com/magento-research/pwa-buildpack/issues/33)) ([6378414](https://github.com/magento-research/pwa-buildpack/commit/6378414)), closes [#32](https://github.com/magento-research/pwa-buildpack/issues/32)
<a name="0.7.0"></a>
# [0.7.0](https://github.com/magento-research/pwa-buildpack/compare/v0.6.0...v0.7.0) (2018-05-03)
### Bug Fixes
* **util:** fix run-as-root await rm bug ([#28](https://github.com/magento-research/pwa-buildpack/issues/28)) ([8a71a9f](https://github.com/magento-research/pwa-buildpack/commit/8a71a9f)), closes [magento-research/venia-pwa-concept#51](https://github.com/magento-research/venia-pwa-concept/issues/51)
### Features
* add protocol error detection to devproxy ([#22](https://github.com/magento-research/pwa-buildpack/issues/22)) ([aa0e53c](https://github.com/magento-research/pwa-buildpack/commit/aa0e53c))
@@ -0,0 +1,49 @@
Open Software License ("OSL") v. 3.0
This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work:
Licensed under the Open Software License version 3.0
1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following:
1. to reproduce the Original Work in copies, either alone or as part of a collective work;
2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work;
3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License;
4. to perform the Original Work publicly; and
5. to display the Original Work publicly.
2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works.
3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work.
4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license.
5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c).
6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work.
7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer.
8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation.
9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c).
10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware.
11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License.
12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License.
13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable.
14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You.
16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under <insert your license name here>" or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process.
@@ -0,0 +1,7 @@
# pwa-buildpack
[![CircleCI](https://circleci.com/gh/magento-research/pwa-buildpack.svg?style=svg&circle-token=a34631f6c22f0bdd341f9773895f9441584d2e6a)](https://circleci.com/gh/magento-research/pwa-buildpack)
Build and development tools for Magento Progressive Web Apps
Visit the [PWA Devdocs](https://magento-research.github.io/pwa-studio/) site for guides and walkthroughs.

Some files were not shown because too many files have changed in this diff Show More