├── .babelrc ├── .editorconfig ├── .eslintrc.js ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── SUPPORT.md ├── .gitignore ├── .storybook ├── addons.js └── config.js ├── LICENSE ├── README.md ├── circle.yml ├── dangerfile.js ├── docs ├── Router.md └── Simulators.md ├── jest.config.js ├── package-lock.json ├── package.json ├── prettier.config.js ├── scripts ├── fetch-mock.js └── shim.js └── src ├── ContainerChild ├── ContainerChild.js ├── __docs__ │ └── ContainerChild.md ├── __stories__ │ └── ContainerChild.js ├── __tests__ │ └── ContainerChild.test.js └── index.js ├── List ├── __docs__ │ ├── item.md │ ├── items.md │ └── list.md ├── __stories__ │ ├── item.js │ ├── items.js │ └── list.js ├── __tests__ │ ├── item.spec.js │ ├── items.spec.js │ └── list.spec.js ├── index.js ├── item.js ├── items.js └── list.js ├── Peregrine ├── Peregrine.js ├── __tests__ │ └── Peregrine.test.js └── index.js ├── Price ├── Price.js ├── __docs__ │ └── Price.md ├── __stories__ │ └── Price.js ├── __tests__ │ └── Price.spec.js └── index.js ├── Router ├── MagentoRouteHandler.js ├── Router.js ├── __tests__ │ ├── MagentoRouteHandler.test.js │ ├── Router.test.js │ ├── fetchRootComponent.test.js │ └── resolveUnknownRoute.test.js ├── fetchRootComponent.js ├── index.js ├── resolveUnknownRoute.js └── webpackInterop.js ├── Simulators ├── DelayedValue.js ├── MultipleTimedRenders.js ├── SimulatorErrorBoundary.js ├── __tests__ │ ├── DelayedValue.spec.js │ ├── MultipleTimedRenders.spec.js │ └── schedule-callback-args.spec.js ├── index.js └── schedule-callback-args.js ├── __tests__ └── index.test.js ├── index.js ├── store ├── enhancers │ ├── exposeSlices.js │ └── index.js ├── index.js └── middleware │ ├── index.js │ └── log.js └── util ├── __tests__ ├── fromRenderProp.spec.js └── unaryMemoize.spec.js ├── fromRenderProp.js └── unaryMemoize.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-object-rest-spread", 4 | "transform-class-properties", 5 | ["transform-react-jsx", { "pragma": "createElement" }], 6 | ["transform-runtime", { 7 | "helpers": true, 8 | "polyfill": false, // polyfills will be handled by preset-env 9 | "regenerator": false 10 | }] 11 | ], 12 | "presets": [ 13 | ["env", { 14 | "targets": { 15 | "browsers": ["last 2 versions", "ie 11"] 16 | }, 17 | "modules": false 18 | }] 19 | ], 20 | "env": { 21 | "test": { 22 | "plugins": [ 23 | "transform-es2015-modules-commonjs", 24 | "transform-object-rest-spread", 25 | "transform-class-properties", 26 | ["transform-react-jsx", { "pragma": "createElement" }] 27 | ] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [{package.json,*.yml}] 12 | indent_size = 2 13 | indent_style = space 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | parser: 'babel-eslint', 3 | extends: ['@magento'] 4 | }; 5 | 6 | module.exports = config; 7 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at pwa@magento.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to the peregrine project! Before you start contributing, please take a moment to read through the following guidelines: 4 | 5 | * [Code of Conduct] 6 | * [Support] 7 | 8 | To contribute to this repository, start by forking the [official repository] and following the installation instructions in the README file. 9 | 10 | ## Pull Request checklist 11 | 12 | * PR commits should contain [meaningful commit messages] 13 | * To help with reviews, your PR should only create/revise a single feature or fix a single issue. 14 | * If your PR fixes a bug, please provide a step-by-step description of how to reproduce the bug. 15 | * If your PR addresses an existing issue, please reference that issue in the title or description. 16 | 17 | ## Contribution process 18 | 19 | Magento maintains a public roadmap for this and other [Magento Research] repositories in each project's issue board. 20 | 21 | Any and all community participation in this backlog is encouraged and appreciated. 22 | Even foundational infrastructure stories are available for a generous developer to take on. 23 | 24 | To get started, look for issues tagged with the **[help wanted]** labels. 25 | These issues are ready for community ownership. 26 | 27 | **Note:** 28 | *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.* 29 | 30 | ### Claiming an issue on the roadmap 31 | 32 | If you are interested in taking ownership of a roadmap feature or issue, we ask that you go through the following process. 33 | This helps us organize and forecast the progress of our projects. 34 | 35 | #### Step 1: Add an issue comment 36 | 37 | Add a comment on an issue expressing your interest in taking ownership of it. 38 | Make sure your GitHub profile includes an email address so we can contact you privately. 39 | 40 | #### Step 2: Meet with a maintainer 41 | 42 | A maintainer will contact you and ask to set up a real-time meeting to discuss the issue you are interested in owning. 43 | This meeting can be in person, video chat, audio chat, or text chat. 44 | 45 | In general this meeting is brief but can vary with the complexity of an issue. 46 | For larger issues, we may schedule follow-up meetings. 47 | 48 | During this meeting, we provide you with any additional materials or resources you need to work on the issue. 49 | 50 | #### Step 3: Provide an estimate 51 | 52 | We ask that you provide us an estimate of how long it will take you to complete the issue. 53 | 54 | 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. 55 | 56 | If you can't get back to us by that time, we understand! 57 | As a community developer, you are helping us out in addition to your regular job. 58 | We will un-assign you from this issue, but please feel free to contribute to another issue. 59 | 60 | #### Step 4: Work on the issue 61 | 62 | After you provide an estimate, the issue is now "in progress", and 63 | you officially become a member of the [Magento Research] organization. 64 | 65 | If you need more time to work on the issue, please contact us as soon as possible. 66 | We may request an update on your progress, but we are willing to accomodate. 67 | 68 | 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. 69 | 70 | #### Step 5: Create a pull request 71 | 72 | When you finish working on an issue, create a pull request with the issue number included in the title or body. 73 | This starts the (brief) code review process. 74 | 75 | After we accept and merge your contribution, you become an official contributor! 76 | Official contributors are invited to our backlog grooming sessions and have direct influence over the product roadmap. 77 | 78 | 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! 79 | 80 | ## Report an issue 81 | 82 | Create a [GitHub issue] and put an **X** in the appropriate box to report an issue with the project. 83 | Provide as much detail as you can in each section to help us triage and process the issue. 84 | 85 | ### Issue types 86 | 87 | * Bug - An error, flaw, or failure in the code 88 | * Feature suggestion - A missing feature you would like to see implemented in the project 89 | * Other - Any other type of task related to the project 90 | 91 | **Note:** 92 | *Please avoid creating GitHub issues asking for help on bugs in your project that are outside the scope of this project.* 93 | 94 | [Code of Conduct]: CODE_OF_CONDUCT.md 95 | [Support]: SUPPORT.md 96 | [official repository]: https://github.com/magento-research/peregrine 97 | [meaningful commit messages]: https://chris.beams.io/posts/git-commit/ 98 | [GitHub issue]: https://github.com/magento-research/peregrine/issues/new 99 | [Magento Research]: https://github.com/magento-research 100 | [help wanted]: https://github.com/magento-research/peregrine/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | ## This issue is a: 3 | [ ] Bug 4 | [ ] Feature suggestion 5 | [ ] Other 6 | 7 | 8 | ## Description: 9 | 10 | 11 | ### Environment and steps to reproduce 12 | 13 | OS: 14 | 15 | Magento 2 version: 16 | 17 | Other environment information: 18 | 19 | Steps to reproduce: 20 | 21 | 1. First Step 22 | 2. Second Step 23 | 3. Etc. 24 | 25 | 26 | ## Expected result: 27 | 28 | 29 | ## Possible solutions: 30 | 31 | 32 | ## Additional information: 33 | 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | ## This PR is a: 3 | [ ] New feature 4 | [ ] Enhancement/Optimization 5 | [ ] Refactor 6 | [ ] Bugfix 7 | [ ] Test for existing code 8 | [ ] Documentation 9 | 10 | 11 | ## Summary 12 | 13 | When this pull request is merged, it will... 14 | 15 | 16 | ## Additional information 17 | 18 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | Need help with something? Please use the following resources to get the help you need: 4 | 5 | * Documentation website - [PWA DevDocs] 6 | * Chat with us on **Slack** - [#pwa channel] 7 | * Send us an Email: pwa@magento.com 8 | 9 | [PWA DevDocs]: https://magento-research.github.io/pwa-devdocs/ 10 | [#pwa channel]: https://magentocommeng.slack.com/messages/C71HNKYS2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .DS_Store 4 | dist 5 | coverage 6 | storybook-dist 7 | 8 | .vscode 9 | .idea 10 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import 'storybook-readme/register'; 2 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react'; 2 | 3 | function loadStories() { 4 | const context = require.context('../src', true, /__stories__\/.+\.js$/); 5 | context.keys().forEach(context); 6 | } 7 | 8 | configure(loadStories, module); 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Open Software License ("OSL") v. 3.0 3 | 4 | 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: 5 | 6 | Licensed under the Open Software License version 3.0 7 | 8 | 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: 9 | 10 | 1. to reproduce the Original Work in copies, either alone or as part of a collective work; 11 | 12 | 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; 13 | 14 | 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; 15 | 16 | 4. to perform the Original Work publicly; and 17 | 18 | 5. to display the Original Work publicly. 19 | 20 | 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. 21 | 22 | 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. 23 | 24 | 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. 25 | 26 | 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). 27 | 28 | 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. 29 | 30 | 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. 31 | 32 | 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. 33 | 34 | 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). 35 | 36 | 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. 37 | 38 | 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. 39 | 40 | 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. 41 | 42 | 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. 43 | 44 | 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. 45 | 46 | 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. 47 | 48 | 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 " 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This repository is no longer maintained. Please visit the [PWA Studio](https://github.com/magento-research/pwa-studio) repository which now contains all PWA Studio packages in one place. 2 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 8 4 | 5 | test: 6 | override: 7 | - npm run danger 8 | 9 | deployment: 10 | prs: 11 | branch: /\b(?!master)\b\S+/ 12 | commands: 13 | - npm run storybook:build 14 | 15 | general: 16 | artifacts: 17 | - "coverage/lcov-report" 18 | - "storybook-dist" 19 | -------------------------------------------------------------------------------- /dangerfile.js: -------------------------------------------------------------------------------- 1 | const execa = require('execa'); 2 | const { fail, markdown } = require('danger'); 3 | 4 | const fromRoot = path => path.replace(`${process.cwd()}/`, ''); 5 | const fence = '```'; 6 | const codeFence = str => `${fence}\n${str.trim()}\n${fence}`; 7 | 8 | const tasks = [ 9 | function prettierCheck() { 10 | try { 11 | execa.sync('npm', ['run', '--silent', 'prettier:check']); 12 | } catch (err) { 13 | const { stdout } = err; 14 | fail( 15 | 'The following file(s) were not ' + 16 | 'formatted with **prettier**. Make sure to execute `npm run prettier` ' + 17 | `locally prior to committing.\n${codeFence(stdout)}` 18 | ); 19 | } 20 | }, 21 | 22 | function eslintCheck() { 23 | try { 24 | execa.sync('npm', ['run', '--silent', 'lint', '--', '-f', 'json']); 25 | } catch (err) { 26 | const { stdout } = err; 27 | const results = JSON.parse(stdout); 28 | const errFiles = results 29 | .filter(r => r.errorCount) 30 | .map(r => fromRoot(r.filePath)); 31 | fail( 32 | 'The following file(s) did not pass **ESLint**. Execute ' + 33 | '`npm run lint` locally for more details\n' + 34 | codeFence(errFiles.join('\n')) 35 | ); 36 | } 37 | }, 38 | 39 | function unitTests() { 40 | try { 41 | execa.sync('jest', ['--json', '--coverage']); 42 | const coverageLink = linkToCircleAsset( 43 | 'coverage/lcov-report/index.html' 44 | ); 45 | markdown( 46 | `All tests passed! [View Coverage Report](${coverageLink})` 47 | ); 48 | } catch (err) { 49 | const summary = JSON.parse(err.stdout); 50 | const failedTests = summary.testResults.filter( 51 | t => t.status !== 'passed' 52 | ); 53 | // prettier-ignore 54 | const failSummary = failedTests.map(t => 55 | `
56 | ${fromRoot(t.name)} 57 |
${t.message}
58 |
` 59 | ).join('\n'); 60 | fail( 61 | 'The following unit tests did _not_ pass 😔. ' + 62 | 'All tests must pass before this PR can be merged\n\n\n' + 63 | failSummary 64 | ); 65 | } 66 | }, 67 | 68 | function storybook() { 69 | const storybookURI = linkToCircleAsset('storybook-dist/index.html'); 70 | markdown( 71 | `[A Storybook for this PR has been deployed!](${storybookURI}). ` + 72 | 'It will be accessible as soon as the current build completes.' 73 | ); 74 | } 75 | ]; 76 | 77 | for (const task of tasks) task(); 78 | 79 | function linkToCircleAsset(pathFromProjectRoot) { 80 | const org = process.env.CIRCLE_PROJECT_USERNAME; 81 | const repo = process.env.CIRCLE_PROJECT_REPONAME; 82 | const buildNum = process.env.CIRCLE_BUILD_NUM; 83 | const idx = process.env.CIRCLE_NODE_INDEX; 84 | 85 | return [ 86 | 'https://circleci.com/api/v1/project', 87 | `/${org}/${repo}/${buildNum}`, 88 | `/artifacts/${idx}/home/ubuntu`, 89 | `/${repo}/${pathFromProjectRoot}` 90 | ].join(''); 91 | } 92 | -------------------------------------------------------------------------------- /docs/Router.md: -------------------------------------------------------------------------------- 1 | # Peregrine Router 2 | 3 | The Peregrine Router is a client-side router that is designed to understand the 4 | different storefront routes within Magento 2. If using Peregrine to bootstrap 5 | your PWA, it is configured automatically. If not, the Router can be manually 6 | consumed. 7 | 8 | ## Manual Usage 9 | 10 | ```jsx 11 | import ReactDOM from 'react-dom'; 12 | import { Router } from '@magento/peregrine'; 13 | 14 | ReactDOM.render( 15 | , 16 | document.querySelector('main') 17 | ); 18 | ``` 19 | 20 | ## Props 21 | 22 | | Prop Name | Required? | Description | 23 | | ------------- | :-------: | -----------------------------------------------------------------------------------------------------: | 24 | | `apiBase` | ✅ | Root URL of the Magento store, including protocol and hostname | 25 | | `using` | | The Router implementation to use from React-Router. Can be `BrowserRouter`/`HashRouter`/`MemoryRouter` | 26 | | `routerProps` | | Any additional props to be passed to React-Router | 27 | -------------------------------------------------------------------------------- /docs/Simulators.md: -------------------------------------------------------------------------------- 1 | Simulators are testing and development components that simulate various device 2 | conditions and network conditions that a PWA should tolerate. Use these 3 | components only temporarily during development, as a substitute for manually 4 | instrumenting your components with more complex lifecycle debugging. 5 | 6 | ### Contents 7 | - [**Progressive Load Simulators**](#progressive_load_simulators) 8 | - [``](#delayed_value) 9 | - [``](#multiple_timed_renders) 10 | 11 | 12 | ## Progressive Load Simulators 13 | 14 | Data flows into React components as props, and PWAs load data progressively and 15 | asynchronously. Therefore, PWA-ready React components should render some UI when 16 | they have partial or empty data sets. Use these components to test how React 17 | components display during data load, by timing re-renders. 18 | 19 | 20 | ### `` Component 21 | 22 | This component passes in an `initial` data value to a component on its first 23 | render. After a set `delay`, it will render a second time and pass an `updated` 24 | data value. Use this behavior to simulate a progressive loading scenario on a 25 | UI, where you would use `value` as one of the component's props or children. 26 | 27 | #### Usage 28 | 29 | In the following code sample, a developer has wrapped a span tag in a 30 | `DelayedValue` to simulate progressive data load. It will initially render a 31 | `` element with the text value "Loading State...". After 1500ms, the inner 32 | function will run again, and the component will re-render with the text changed 33 | to "Done!" 34 | 35 | ```jsx 36 | import { Simulators } from '@magento/peregrine'; 37 | const { DelayedValue } = Simulators; 38 | 39 | export default class extends React.Component { 40 | render() { 41 | return ( 42 |
43 | 48 | {status => {status}} 49 | 50 |
51 | ); 52 | } 53 | } 54 | 55 | ``` 56 | 57 | | Prop Name | Required? | Description | 58 | | --------- | :-------: | ------------------------------------------------------------------ | 59 | | `delay` | ✅ | A `Number` representing the number of milliseconds to delay before the second render. | 60 | | `updated` | ✅ | The value to pass on the delayed render. | 61 | | `initial` | | If this prop is present, the render function will invoke synchronously (as it would without the DelayedValue wrapper) one time when the component mounts. That initial render function call will receive the value of `initial` as its argument. If this prop is absent, there will be no initial synchronous render, only the delayed render. | 62 | | `onError` | | A function that will receive any errors thrown by the render. Omit to allow errors to throw uncaught. | 63 | 64 | #### Children 65 | `DelayedValue` does not take literal JSX nodes as children. It uses a variant of 66 | React's [render props][1] pattern called [function-as-child][2] to render its 67 | children asynchronously and pass them values. 68 | 69 | #### Example 70 | A `Page` renders an `ExampleGallery` that will initially show only placeholders. 71 | Its `images` prop will be an empty array. 72 | ```jsx 73 | class Page extends React.Component { 74 | render() { 75 | return ( 76 |
77 | 78 |
79 | ); 80 | } 81 | } 82 | ``` 83 | When the images arrive from the data layer, the `Page` may set its own state. 84 | But this is difficult to quickly test, especially if you want to observe a nice loading animation you're working on. 85 | 86 | Use `DelayedValue` to test that out: 87 | ```jsx 88 | import { Simulators } from '@magento/peregrine'; 89 | const { DelayedValue } = Simulators; 90 | 91 | class Page extends React.Component { 92 | render() { 93 | return ( 94 |
95 | 100 | {images => } 101 | 102 |
103 | ); 104 | } 105 | } 106 | ``` 107 | 108 | 109 | ### `` Component 110 | 111 | Renders its children an arbitrary number of times, based on the length of the 112 | array passed to the `schedule` prop. Useful for more complex load scenarios. 113 | 114 | #### Usage 115 | In the following code sample, a developer has wrapped a button tag in a 116 | `MultipleTimedRenders` to simulate several state changes that will occur in 117 | production during data load. 118 | 119 | ```jsx 120 | 121 | import { Simulators } from '@magento/peregrine/Simulators'; 122 | const { MultipleTimedRenders } = Simulators; 123 | 124 |
125 | 142 | {(className, label) => } 143 | 144 |
; 145 | 146 | ``` 147 | The presence of the `initialArgs` prop will cause a synchronous first render, 148 | with the `initialArgs` array as arguments. The `schedule` prop will cause three 149 | subsequent renders: one immediate, one in two seconds, and one in five seconds. 150 | 151 | **First (sync) render** 152 | ```html 153 | 154 | ``` 155 | 156 | **Second render (async, 0 seconds)** 157 | ```html 158 | 159 | ``` 160 | 161 | **Third render (async, 2.0 seconds)** 162 | ```html 163 | 164 | ``` 165 | 166 | **Fourth render (async, 5.0 seconds)** 167 | ```html 168 | 169 | ``` 170 | 171 | #### Props 172 | | Prop Name | Required? | Description | 173 | | ------------- | :-------: | :------------------------------------------------------------------------------------------------------ | 174 | | `schedule` | ✅ | An array of `{ elapsed: number, args: mixed[] }` objects saying when and with what arguments to invoke the render function passed as a child. | 175 | | `initialArgs` | | If this prop is present and is an array, then the render function will invoke synchronously one time, before the scheduled async updates begin. That initial sync render will receive the `initialArgs` array as arguments. If this prop is absent, there will be no initial synchronous render. | 176 | | `onError` | | A function that will receive any errors thrown by the render. Omit to allow errors to throw uncaught. | 177 | 178 | #### Children 179 | Like `DelayedValue`, the `MultipleTimedRenders` uses the [function-as-child][2] pattern. 180 | 181 | [1]: https://reactjs.org/docs/render-props.html "Render Props" 182 | [2]: https://reactjs.org/docs/render-props.html#using-props-other-than-render 183 | "Function As Child" 184 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFiles: [ 3 | '/scripts/shim.js', 4 | '/scripts/fetch-mock.js' 5 | ], 6 | verbose: true, 7 | collectCoverageFrom: [ 8 | 'src/**/*.js', 9 | '!src/**/index.js', 10 | '!src/**/__stories__/**' 11 | ] 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@magento/peregrine", 3 | "version": "0.4.0", 4 | "description": "The core runtime of Magento PWA", 5 | "license": "(OSL-3.0 OR AFL-3.0)", 6 | "author": "Magento Commerce", 7 | "main": "dist/index.js", 8 | "files": [ 9 | "dist" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/magento-research/peregrine" 14 | }, 15 | "scripts": { 16 | "build": "babel src -d dist --ignore test.js", 17 | "danger": "danger-ci", 18 | "clean": "rimraf dist", 19 | "lint": "eslint 'src/**/*.js'", 20 | "prepare": "npm-merge-driver install", 21 | "prettier": "prettier --write '{src/**/,scripts/**/,}*.js'", 22 | "prettier:check": "prettier --list-different '{src/**/,scripts/**/,}*.js'", 23 | "test": "jest --no-cache --coverage", 24 | "test:debug": "node --inspect-brk node_modules/.bin/jest -i", 25 | "test:dev": "jest --watch", 26 | "storybook": "start-storybook -p 9001 -c .storybook", 27 | "storybook:build": "build-storybook -c .storybook -o storybook-dist" 28 | }, 29 | "dependencies": {}, 30 | "peerDependencies": { 31 | "babel-runtime": "^6.0.0", 32 | "react": "^16.2.0", 33 | "react-dom": "^16.2.0", 34 | "react-redux": "^5.0.6", 35 | "react-router-dom": "^4.2.2", 36 | "redux": "^3.7.2" 37 | }, 38 | "devDependencies": { 39 | "@magento/eslint-config": "^1.0.0", 40 | "@storybook/addon-actions": "^3.4.2", 41 | "@storybook/addons": "^3.4.6", 42 | "@storybook/react": "^3.4.2", 43 | "babel-cli": "^6.26.0", 44 | "babel-core": "^6.26.0", 45 | "babel-eslint": "^8.0.3", 46 | "babel-plugin-transform-class-properties": "^6.24.1", 47 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", 48 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 49 | "babel-plugin-transform-react-jsx": "^6.24.1", 50 | "babel-plugin-transform-runtime": "^6.23.0", 51 | "babel-preset-env": "^1.6.1", 52 | "danger": "^3.4.5", 53 | "enzyme": "^3.3.0", 54 | "enzyme-adapter-react-16": "^1.1.1", 55 | "eslint": "^4.12.1", 56 | "eslint-plugin-jsx-a11y": "^6.0.3", 57 | "eslint-plugin-react": "^7.5.1", 58 | "execa": "^0.10.0", 59 | "intl": "^1.2.5", 60 | "jest": "^22.4.3", 61 | "jest-fetch-mock": "^1.4.1", 62 | "npm-merge-driver": "^2.3.5", 63 | "prettier": "^1.8.2", 64 | "prop-types": "^15.6.0", 65 | "react": "^16.2.0", 66 | "react-dom": "^16.2.0", 67 | "react-redux": "^5.0.6", 68 | "react-router-dom": "^4.2.2", 69 | "redux": "^3.7.2", 70 | "rimraf": "^2.6.2", 71 | "storybook-readme": "^3.3.0" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | singleQuote: true, 3 | tabWidth: 4, 4 | trailingComma: 'none' 5 | }; 6 | 7 | module.exports = config; 8 | -------------------------------------------------------------------------------- /scripts/fetch-mock.js: -------------------------------------------------------------------------------- 1 | global.fetch = require('jest-fetch-mock'); 2 | -------------------------------------------------------------------------------- /scripts/shim.js: -------------------------------------------------------------------------------- 1 | global.requestAnimationFrame = callback => setTimeout(callback, 0); 2 | -------------------------------------------------------------------------------- /src/ContainerChild/ContainerChild.js: -------------------------------------------------------------------------------- 1 | import { createElement, Component } from 'react'; 2 | import { func, string } from 'prop-types'; 3 | 4 | export default class ContainerChild extends Component { 5 | static propTypes = { 6 | id: string.isRequired, 7 | render: func.isRequired 8 | }; 9 | 10 | render() { 11 | return this.props.render(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/ContainerChild/__docs__/ContainerChild.md: -------------------------------------------------------------------------------- 1 | # ContainerChild 2 | 3 | The `ContainerChild` component is the only allowed child within a `Container` in 4 | PWA Studio. 5 | 6 | ## Usage 7 | 8 | ```jsx 9 | import { ContainerChild } from '@magento/peregrine'; 10 | 11 |
12 |
Used just like a normal render() method
} 15 | /> 16 | ( 19 |
Can render anything a normal component can render
20 | )} 21 | /> 22 |
; 23 | ``` 24 | 25 | ## Props 26 | 27 | | Prop Name | Required? | Description | 28 | | --------- | :-------: | ------------------------------------------------------------------------------------------------------------------: | 29 | | `id` | ✅ | A string identifier that modules/extensions can use to inject content relative to this component within a Container | 30 | | `render` | ✅ | A [render prop](https://reactjs.org/docs/render-props.html) that should return the children to render | 31 | -------------------------------------------------------------------------------- /src/ContainerChild/__stories__/ContainerChild.js: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import ContainerChild from '..'; 4 | import docs from '../__docs__/ContainerChild.md'; 5 | import { withReadme } from 'storybook-readme'; 6 | 7 | const stories = storiesOf('ContainerChild', module); 8 | 9 | stories.add( 10 | 'default', 11 | withReadme(docs, () => ( 12 | ( 15 |
16 | An example ContainerChild component, rendering its children 17 | from the "render" prop 18 |
19 | )} 20 | /> 21 | )) 22 | ); 23 | -------------------------------------------------------------------------------- /src/ContainerChild/__tests__/ContainerChild.test.js: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react'; 2 | import ContainerChild from '..'; 3 | import { configure, shallow } from 'enzyme'; 4 | import Adapter from 'enzyme-adapter-react-16'; 5 | 6 | configure({ adapter: new Adapter() }); 7 | 8 | test('Renders content from render prop', () => { 9 | const wrapper = shallow( 10 |
Hello World
} 13 | processed={true} 14 | /> 15 | ); 16 | expect(wrapper.equals(
Hello World
)).toBe(true); 17 | }); 18 | -------------------------------------------------------------------------------- /src/ContainerChild/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ContainerChild'; 2 | -------------------------------------------------------------------------------- /src/List/__docs__/item.md: -------------------------------------------------------------------------------- 1 | # Item 2 | 3 | The `Item` component is a direct child of the `Items` fragment. 4 | 5 | ## Usage 6 | 7 | See `List`. 8 | 9 | ## Props 10 | 11 | Prop Name | Required? | Description 12 | --------- | :-------: | :---------- 13 | `classes` | ❌ | A classname object. 14 | `hasFocus` | ❌ | Whether the element currently has browser focus 15 | `isSelected` | ❌ | Whether the item is currently selected 16 | `item` | ✅ | A data object. If `item` is a string, it will be rendered as a child 17 | `render` | ✅ | A [render prop](https://reactjs.org/docs/render-props.html). Also accepts a tagname (e.g., `"div"`) 18 | -------------------------------------------------------------------------------- /src/List/__docs__/items.md: -------------------------------------------------------------------------------- 1 | # Items 2 | 3 | The `Items` component is a direct child of the `List` component. As a fragment, it returns its children directly, with no wrapping element. 4 | 5 | ## Usage 6 | 7 | See `List`. 8 | 9 | ## Props 10 | 11 | Prop Name | Required? | Description 12 | --------- | :-------: | :---------- 13 | `items` | ✅ | An iterable that yields `[key, item]` pairs, such as an ES2015 `Map` 14 | `renderItem` | ❌ | A [render prop](https://reactjs.org/docs/render-props.html). Also accepts a tagname (e.g., `"div"`) 15 | `selectionModel` | ❌ | A string specifying whether to use a `radio` or `checkbox` selection model 16 | -------------------------------------------------------------------------------- /src/List/__docs__/list.md: -------------------------------------------------------------------------------- 1 | # List 2 | 3 | The `List` component maps a collection of data objects into an array of elements. It also manages the selection and focus of those elements. 4 | 5 | ## Usage 6 | 7 | ```jsx 8 | import { List } from '@magento/peregrine'; 9 | 10 | const simpleData = new Map() 11 | .set('s', 'Small') 12 | .set('m', 'Medium') 13 | .set('l', 'Large') 14 | 15 | 21 | 22 | const complexData = new Map() 23 | .set('s', { id: 's', value: 'Small' }) 24 | .set('m', { id: 'm', value: 'Medium' }) 25 | .set('l', { id: 'l', value: 'Large' }) 26 | 27 | (
    {props.children}
)} 31 | renderItem={props => (
  • {props.item.value}
  • )} 32 | /> 33 | ``` 34 | 35 | ## Props 36 | 37 | Prop Name | Required? | Description 38 | --------- | :-------: | :---------- 39 | `classes` | ❌ | A classname hash 40 | `items` | ✅ | An iterable that yields `[key, item]` pairs, such as an ES2015 `Map` 41 | `render` | ✅ | A [render prop](https://reactjs.org/docs/render-props.html) for the list element. Also accepts a tagname (e.g., `"div"`) 42 | `renderItem` | ❌ | A [render prop](https://reactjs.org/docs/render-props.html) for the list item elements. Also accepts a tagname (e.g., `"div"`) 43 | `onSelectionChange` | ❌ | A callback fired when the selection state changes 44 | `selectionModel` | ❌ | A string specifying whether to use a `radio` or `checkbox` selection model 45 | -------------------------------------------------------------------------------- /src/List/__stories__/item.js: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import { withReadme } from 'storybook-readme'; 4 | 5 | import Item from '..'; 6 | import docs from '../__docs__/item.md'; 7 | 8 | const stories = storiesOf('Item', module); 9 | 10 | stories.add( 11 | 'default', 12 | withReadme(docs, () => ( 13 | 14 | )) 15 | ); 16 | -------------------------------------------------------------------------------- /src/List/__stories__/items.js: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import { withReadme } from 'storybook-readme'; 4 | 5 | import Items from '..'; 6 | import docs from '../__docs__/items.md'; 7 | 8 | const data = { 9 | s: { id: 's', value: 'Small' }, 10 | m: { id: 'm', value: 'Medium' }, 11 | l: { id: 'l', value: 'Large' } 12 | }; 13 | 14 | const stories = storiesOf('Items', module); 15 | 16 | stories.add( 17 | 'default', 18 | withReadme(docs, () => ) 19 | ); 20 | -------------------------------------------------------------------------------- /src/List/__stories__/list.js: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import { withReadme } from 'storybook-readme'; 4 | 5 | import List from '..'; 6 | import docs from '../__docs__/list.md'; 7 | 8 | const stories = storiesOf('List', module); 9 | 10 | // simple example with string values 11 | const simpleData = new Map() 12 | .set('s', 'Small') 13 | .set('m', 'Medium') 14 | .set('l', 'Large'); 15 | 16 | stories.add( 17 | 'simple', 18 | withReadme(docs, () => ( 19 | 25 | )) 26 | ); 27 | 28 | // complex example with object values 29 | const complexData = new Map() 30 | .set('s', { id: 's', value: 'Small' }) 31 | .set('m', { id: 'm', value: 'Medium' }) 32 | .set('l', { id: 'l', value: 'Large' }); 33 | 34 | stories.add( 35 | 'complex', 36 | withReadme(docs, () => ( 37 |
      {props.children}
    } 41 | renderItem={props =>
  • {props.item.value}
  • } 42 | /> 43 | )) 44 | ); 45 | -------------------------------------------------------------------------------- /src/List/__tests__/item.spec.js: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react'; 2 | import { configure, shallow } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | 5 | import { Item } from '../index.js'; 6 | 7 | configure({ adapter: new Adapter() }); 8 | 9 | const classes = { 10 | root: 'abc' 11 | }; 12 | 13 | test('renders a div by default', () => { 14 | const props = { item: 'a' }; 15 | const wrapper = shallow().dive(); 16 | 17 | expect(wrapper.type()).toEqual('div'); 18 | }); 19 | 20 | test('renders a provided tagname', () => { 21 | const props = { item: 'a', render: 'p' }; 22 | const wrapper = shallow().dive(); 23 | 24 | expect(wrapper.type()).toEqual('p'); 25 | }); 26 | 27 | test('renders a provided component', () => { 28 | const Span = () => ; 29 | const props = { item: 'a', render: Span }; 30 | const wrapper = shallow(); 31 | 32 | expect(wrapper.type()).toEqual(Span); 33 | expect(wrapper.dive().type()).toEqual('span'); 34 | }); 35 | 36 | test('passes only rest props to basic `render`', () => { 37 | const props = { classes, item: 'a', render: 'p' }; 38 | const wrapper = shallow().dive(); 39 | 40 | expect(wrapper.props()).toHaveProperty('data-id'); 41 | expect(wrapper.props()).not.toHaveProperty('classes'); 42 | expect(wrapper.props()).not.toHaveProperty('hasFocus'); 43 | expect(wrapper.props()).not.toHaveProperty('isSelected'); 44 | expect(wrapper.props()).not.toHaveProperty('item'); 45 | expect(wrapper.props()).not.toHaveProperty('render'); 46 | }); 47 | 48 | test('passes custom and rest props to composite `render`', () => { 49 | const Span = () => ; 50 | const props = { classes, item: 'a', render: Span }; 51 | const wrapper = shallow(); 52 | 53 | expect(wrapper.props()).toHaveProperty('data-id'); 54 | expect(wrapper.props()).toHaveProperty('classes'); 55 | expect(wrapper.props()).toHaveProperty('hasFocus'); 56 | expect(wrapper.props()).toHaveProperty('isSelected'); 57 | expect(wrapper.props()).toHaveProperty('item'); 58 | expect(wrapper.props()).not.toHaveProperty('render'); 59 | }); 60 | 61 | test('passes `item` as `children` if `item` is a string', () => { 62 | const props = { item: 'a', render: 'p' }; 63 | const wrapper = shallow().dive(); 64 | 65 | expect(wrapper.text()).toEqual('a'); 66 | }); 67 | 68 | test('does not pass `children` if `item` is not a string', () => { 69 | const props = { item: { id: 1 }, render: 'p' }; 70 | const wrapper = shallow().dive(); 71 | 72 | expect(wrapper.text()).toBe(''); 73 | }); 74 | -------------------------------------------------------------------------------- /src/List/__tests__/items.spec.js: -------------------------------------------------------------------------------- 1 | import { Fragment, createElement } from 'react'; 2 | import { configure, shallow } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | 5 | import { Items } from '..'; 6 | 7 | configure({ adapter: new Adapter() }); 8 | 9 | const items = Object.entries({ 10 | a: { id: 'a', val: '10' }, 11 | b: { id: 'b', val: '20' }, 12 | c: { id: 'c', val: '30' } 13 | }); 14 | 15 | test('renders a fragment', () => { 16 | const props = { items }; 17 | const wrapper = shallow(); 18 | 19 | expect(wrapper.type()).toEqual(Fragment); 20 | }); 21 | 22 | test('renders a child for each item', () => { 23 | const props = { items }; 24 | const wrapper = shallow(); 25 | 26 | expect(wrapper.children()).toHaveLength(items.length); 27 | }); 28 | 29 | test('renders basic children of type `renderItem`', () => { 30 | const elementType = 'li'; 31 | const props = { items, renderItem: elementType }; 32 | const wrapper = shallow(); 33 | 34 | expect.assertions(items.length); 35 | wrapper.children().forEach(node => { 36 | expect( 37 | node 38 | .dive() 39 | .dive() 40 | .type() 41 | ).toEqual(elementType); 42 | }); 43 | }); 44 | 45 | test('renders composite children of type `renderItem`', () => { 46 | const Span = () => ; 47 | const props = { items, renderItem: Span }; 48 | const wrapper = shallow(); 49 | 50 | expect.assertions(items.length); 51 | wrapper.children().forEach(node => { 52 | expect(node.dive().type()).toEqual(Span); 53 | }); 54 | }); 55 | 56 | test('passes correct props to each child', () => { 57 | const elementType = 'li'; 58 | const props = { items, renderItem: elementType }; 59 | const wrapper = shallow(); 60 | 61 | wrapper.children().forEach((node, i) => { 62 | const [key, item] = items[i]; 63 | 64 | expect(node.key()).toEqual(key); 65 | expect(node.props()).toMatchObject({ 66 | item, 67 | render: props.renderItem, 68 | hasFocus: false, 69 | isSelected: false, 70 | onBlur: wrapper.instance().handleBlur, 71 | onClick: expect.any(Function), 72 | onFocus: expect.any(Function) 73 | }); 74 | }); 75 | }); 76 | 77 | test('indicates the child at index `cursor` has focus', () => { 78 | const props = { items }; 79 | const wrapper = shallow(); 80 | const state = { cursor: 1, hasFocus: true }; 81 | 82 | wrapper.setState(state); 83 | 84 | wrapper.children().forEach((node, i) => { 85 | const [, item] = items[i]; 86 | 87 | expect(node.props()).toMatchObject({ 88 | item, 89 | hasFocus: i === state.cursor, 90 | isSelected: false 91 | }); 92 | }); 93 | }); 94 | 95 | test('indicates no child has focus if the list is not focused', () => { 96 | const props = { items }; 97 | const wrapper = shallow(); 98 | const state = { cursor: 1, hasFocus: false }; 99 | 100 | wrapper.setState(state); 101 | 102 | wrapper.children().forEach((node, i) => { 103 | const [, item] = items[i]; 104 | 105 | expect(node.props()).toMatchObject({ 106 | item, 107 | hasFocus: false, 108 | isSelected: false 109 | }); 110 | }); 111 | }); 112 | 113 | test('indicates whether a child is selected', () => { 114 | const props = { items }; 115 | const wrapper = shallow(); 116 | const selection = new Set().add('b').add('c'); 117 | 118 | wrapper.setState({ selection }); 119 | 120 | wrapper.children().forEach((node, i) => { 121 | const [key, item] = items[i]; 122 | 123 | expect(node.props()).toMatchObject({ 124 | item, 125 | hasFocus: false, 126 | isSelected: selection.has(key) 127 | }); 128 | }); 129 | }); 130 | 131 | test('updates `hasFocus` on child blur', () => { 132 | const props = { items }; 133 | const wrapper = shallow(); 134 | 135 | wrapper.setState({ hasFocus: true }); 136 | wrapper.childAt(0).simulate('blur'); 137 | 138 | expect(wrapper.state('hasFocus')).toBe(false); 139 | }); 140 | 141 | test('updates `cursor` and `hasFocus` on child focus', () => { 142 | const props = { items }; 143 | const wrapper = shallow(); 144 | const index = 0; 145 | 146 | wrapper.childAt(index).simulate('focus'); 147 | 148 | expect(wrapper.state()).toMatchObject({ 149 | cursor: index, 150 | hasFocus: true 151 | }); 152 | }); 153 | 154 | test('updates radio `selection` on child click', () => { 155 | const props = { items }; 156 | const wrapper = shallow(); 157 | 158 | expect(wrapper.state('selection')).toEqual(new Set()); 159 | 160 | wrapper.childAt(0).simulate('click'); 161 | expect(wrapper.state('selection')).toEqual(new Set(['a'])); 162 | 163 | wrapper.childAt(1).simulate('click'); 164 | expect(wrapper.state('selection')).toEqual(new Set(['b'])); 165 | 166 | wrapper.childAt(0).simulate('click'); 167 | expect(wrapper.state('selection')).toEqual(new Set(['a'])); 168 | }); 169 | 170 | test('updates checkbox `selection` on child click', () => { 171 | const props = { items, selectionModel: 'checkbox' }; 172 | const wrapper = shallow(); 173 | 174 | expect(wrapper.state('selection')).toEqual(new Set()); 175 | 176 | wrapper.childAt(0).simulate('click'); 177 | expect(wrapper.state('selection')).toEqual(new Set(['a'])); 178 | 179 | wrapper.childAt(1).simulate('click'); 180 | expect(wrapper.state('selection')).toEqual(new Set(['a', 'b'])); 181 | 182 | wrapper.childAt(0).simulate('click'); 183 | expect(wrapper.state('selection')).toEqual(new Set(['b'])); 184 | }); 185 | 186 | test('calls `syncSelection` after updating selection', () => { 187 | const props = { items }; 188 | const wrapper = shallow(); 189 | const spy = jest.spyOn(wrapper.instance(), 'syncSelection'); 190 | 191 | wrapper.childAt(0).simulate('click'); 192 | 193 | expect(spy).toHaveBeenCalled(); 194 | }); 195 | 196 | test('calls `onSelectionChange` after updating selection', () => { 197 | const onSelectionChange = jest.fn(); 198 | const props = { items, onSelectionChange }; 199 | const wrapper = shallow(); 200 | 201 | wrapper.childAt(0).simulate('click'); 202 | 203 | expect(onSelectionChange).toHaveBeenCalledWith(wrapper.state('selection')); 204 | }); 205 | 206 | test('memoizes child click handlers', () => { 207 | const props = { items }; 208 | const instance = shallow().instance(); 209 | 210 | expect(instance.getClickHandler(0)).not.toBe(instance.getClickHandler(1)); 211 | expect(instance.getClickHandler(0)).toBe(instance.getClickHandler(0)); 212 | }); 213 | 214 | test('memoizes child focus handlers', () => { 215 | const props = { items }; 216 | const instance = shallow().instance(); 217 | 218 | expect(instance.getFocusHandler(0)).not.toBe(instance.getFocusHandler(1)); 219 | expect(instance.getFocusHandler(0)).toBe(instance.getFocusHandler(0)); 220 | }); 221 | -------------------------------------------------------------------------------- /src/List/__tests__/list.spec.js: -------------------------------------------------------------------------------- 1 | import { Fragment, createElement } from 'react'; 2 | import { configure, shallow } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | 5 | import List from '..'; 6 | 7 | configure({ adapter: new Adapter() }); 8 | 9 | const classes = { 10 | root: 'abc' 11 | }; 12 | 13 | const items = new Map() 14 | .set('a', { id: 'a' }) 15 | .set('b', { id: 'b' }) 16 | .set('c', { id: 'c' }); 17 | 18 | test('renders a div by default', () => { 19 | const props = { classes }; 20 | const wrapper = shallow().dive(); 21 | 22 | expect(wrapper.type()).toEqual('div'); 23 | expect(wrapper.prop('className')).toEqual(classes.root); 24 | }); 25 | 26 | test('renders a provided tagname', () => { 27 | const props = { classes, render: 'ul' }; 28 | const wrapper = shallow().dive(); 29 | 30 | expect(wrapper.type()).toEqual('ul'); 31 | expect(wrapper.prop('className')).toEqual(classes.root); 32 | }); 33 | 34 | test('renders a provided component', () => { 35 | const Nav = () =>