├── src ├── Price │ ├── index.js │ ├── Price.js │ ├── __stories__ │ │ └── Price.js │ ├── __tests__ │ │ └── Price.spec.js │ └── __docs__ │ │ └── Price.md ├── Router │ ├── index.js │ ├── webpackInterop.js │ ├── fetchRootComponent.js │ ├── __tests__ │ │ ├── resolveUnknownRoute.test.js │ │ ├── fetchRootComponent.test.js │ │ ├── MagentoRouteHandler.test.js │ │ └── Router.test.js │ ├── Router.js │ ├── MagentoRouteHandler.js │ └── resolveUnknownRoute.js ├── Peregrine │ ├── index.js │ ├── Peregrine.js │ └── __tests__ │ │ └── Peregrine.test.js ├── store │ ├── middleware │ │ ├── index.js │ │ └── log.js │ ├── enhancers │ │ ├── index.js │ │ └── exposeSlices.js │ └── index.js ├── ContainerChild │ ├── index.js │ ├── ContainerChild.js │ ├── __tests__ │ │ └── ContainerChild.test.js │ ├── __stories__ │ │ └── ContainerChild.js │ └── __docs__ │ │ └── ContainerChild.md ├── List │ ├── index.js │ ├── __stories__ │ │ ├── item.js │ │ ├── items.js │ │ └── list.js │ ├── __docs__ │ │ ├── item.md │ │ ├── items.md │ │ └── list.md │ ├── item.js │ ├── list.js │ ├── __tests__ │ │ ├── item.spec.js │ │ ├── list.spec.js │ │ └── items.spec.js │ └── items.js ├── __tests__ │ └── index.test.js ├── util │ ├── unaryMemoize.js │ ├── __tests__ │ │ ├── unaryMemoize.spec.js │ │ └── fromRenderProp.spec.js │ └── fromRenderProp.js ├── Simulators │ ├── index.js │ ├── SimulatorErrorBoundary.js │ ├── DelayedValue.js │ ├── MultipleTimedRenders.js │ ├── __tests__ │ │ ├── DelayedValue.spec.js │ │ ├── MultipleTimedRenders.spec.js │ │ └── schedule-callback-args.spec.js │ └── schedule-callback-args.js └── index.js ├── .storybook ├── addons.js └── config.js ├── scripts ├── fetch-mock.js └── shim.js ├── .eslintrc.js ├── .gitignore ├── prettier.config.js ├── README.md ├── .editorconfig ├── jest.config.js ├── circle.yml ├── .github ├── SUPPORT.md ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE.md ├── CODE_OF_CONDUCT.md └── CONTRIBUTING.md ├── .babelrc ├── docs ├── Router.md └── Simulators.md ├── package.json ├── dangerfile.js └── LICENSE /src/Price/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Price'; 2 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import 'storybook-readme/register'; 2 | -------------------------------------------------------------------------------- /src/Router/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Router'; 2 | -------------------------------------------------------------------------------- /scripts/fetch-mock.js: -------------------------------------------------------------------------------- 1 | global.fetch = require('jest-fetch-mock'); 2 | -------------------------------------------------------------------------------- /src/Peregrine/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Peregrine'; 2 | -------------------------------------------------------------------------------- /src/store/middleware/index.js: -------------------------------------------------------------------------------- 1 | export { default as log } from './log'; 2 | -------------------------------------------------------------------------------- /src/ContainerChild/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ContainerChild'; 2 | -------------------------------------------------------------------------------- /scripts/shim.js: -------------------------------------------------------------------------------- 1 | global.requestAnimationFrame = callback => setTimeout(callback, 0); 2 | -------------------------------------------------------------------------------- /src/store/enhancers/index.js: -------------------------------------------------------------------------------- 1 | export { default as exposeSlices } from './exposeSlices'; 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | parser: 'babel-eslint', 3 | extends: ['@magento'] 4 | }; 5 | 6 | module.exports = config; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .DS_Store 4 | dist 5 | coverage 6 | storybook-dist 7 | 8 | .vscode 9 | .idea 10 | -------------------------------------------------------------------------------- /src/List/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './list'; 2 | export { default as Items } from './items'; 3 | export { default as Item } from './item'; 4 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | singleQuote: true, 3 | tabWidth: 4, 4 | trailingComma: 'none' 5 | }; 6 | 7 | module.exports = config; 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | import Peregrine from '..'; 2 | import Peregrine2 from '../Peregrine'; 3 | 4 | test('Re-exports Peregrine', () => { 5 | expect(Peregrine).toBe(Peregrine2); 6 | }); 7 | -------------------------------------------------------------------------------- /src/util/unaryMemoize.js: -------------------------------------------------------------------------------- 1 | const memoize = fn => { 2 | const cache = new Map(); 3 | 4 | return x => (cache.has(x) ? cache.get(x) : cache.set(x, fn(x)).get(x)); 5 | }; 6 | 7 | export default memoize; 8 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/Simulators/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Simulator utilities for PWA React components. 3 | * @namespace PeregrineSimulators 4 | */ 5 | export { default as DelayedValue } from './DelayedValue'; 6 | export { default as MultipleTimedRenders } from './MultipleTimedRenders'; 7 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Peregrine'; 2 | export { default as ContainerChild } from './ContainerChild'; 3 | export { default as List, Items, Item } from './List'; 4 | export { default as Router } from './Router'; 5 | export { default as Simulators } from './Simulators'; 6 | export { default as Price } from './Price'; 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /src/Router/webpackInterop.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // https://webpack.js.org/api/module-variables/#__webpack_chunk_load__-webpack-specific- 3 | loadChunk: 4 | process.env.NODE_ENV === 'test' ? () => {} : __webpack_chunk_load__, 5 | // https://webpack.js.org/api/module-variables/#__webpack_require__-webpack-specific- 6 | require: process.env.NODE_ENV === 'test' ? () => {} : __webpack_require__ 7 | }; 8 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, compose, createStore } from 'redux'; 2 | import { exposeSlices } from './enhancers'; 3 | import { log } from './middleware'; 4 | 5 | const reducer = (state = {}) => state; 6 | 7 | const initStore = () => 8 | createStore( 9 | reducer, 10 | compose( 11 | applyMiddleware(log), 12 | exposeSlices 13 | ) 14 | ); 15 | 16 | export default initStore; 17 | -------------------------------------------------------------------------------- /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/util/__tests__/unaryMemoize.spec.js: -------------------------------------------------------------------------------- 1 | import memoize from '../unaryMemoize'; 2 | 3 | test('caches results', () => { 4 | const fn = memoize(i => ({ i })); 5 | const a = fn(0); 6 | const b = fn(1); 7 | const c = fn(0); 8 | 9 | expect(a).not.toBe(b); 10 | expect(a).toBe(c); 11 | }); 12 | 13 | test('calls the function only on cache miss', () => { 14 | const noop = jest.fn(); 15 | const fn = memoize(i => noop(i)); 16 | 17 | fn(0); 18 | fn(0); 19 | fn(1); 20 | 21 | expect(noop).toHaveBeenCalledTimes(2); 22 | }); 23 | -------------------------------------------------------------------------------- /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/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/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/store/middleware/log.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Log actions and state to the browser console. 3 | * This function adheres to Redux's middleware pattern. 4 | * 5 | * @param {Store} store The store to augment. 6 | * @returns {Function} 7 | */ 8 | const log = store => next => action => { 9 | const result = next(action); 10 | 11 | console.groupCollapsed(action.type); 12 | console.group('payload'); 13 | console.log(action.payload); 14 | console.groupEnd(); 15 | console.group('next state'); 16 | console.log(store.getState()); 17 | console.groupEnd(); 18 | console.groupEnd(); 19 | 20 | return result; 21 | }; 22 | 23 | export default log; 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/store/enhancers/exposeSlices.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | /** 4 | * Add slice methods to a store. 5 | * This function adheres to Redux's store enhancer pattern. 6 | * 7 | * @param {Function} createStore The store creator to enhance. 8 | * @returns {Function} 9 | */ 10 | const exposeSlices = createStore => (...args) => { 11 | const store = createStore(...args); 12 | const slices = {}; 13 | 14 | /** 15 | * Add a slice to the root. 16 | * The store replaces the root with one containing the new slice. 17 | */ 18 | const addReducer = (key, reducer) => { 19 | slices[key] = reducer; 20 | 21 | store.replaceReducer(combineReducers(slices)); 22 | }; 23 | 24 | return { 25 | ...store, 26 | addReducer 27 | }; 28 | }; 29 | 30 | export default exposeSlices; 31 | -------------------------------------------------------------------------------- /src/Simulators/SimulatorErrorBoundary.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import { func, string } from 'prop-types'; 3 | 4 | class SimulatorErrorBoundary extends Component { 5 | static propTypes = { 6 | what: string.isRequired, 7 | when: string.isRequired, 8 | handler: func 9 | }; 10 | 11 | componentDidCatch(e, info) { 12 | const { what, when, handler } = this.props; 13 | const renderError = new Error( 14 | `${what} subtree threw an error ${when}: ${e.message}\n${info && 15 | info.componentStack}` 16 | ); 17 | Object.assign(renderError, this.state, info); 18 | 19 | if (handler) { 20 | handler(renderError, info); 21 | } else { 22 | throw renderError; 23 | } 24 | } 25 | 26 | render() { 27 | return this.props.children; 28 | } 29 | } 30 | 31 | export default SimulatorErrorBoundary; 32 | -------------------------------------------------------------------------------- /src/Router/fetchRootComponent.js: -------------------------------------------------------------------------------- 1 | import webpackInterop from './webpackInterop'; 2 | 3 | /** 4 | * @description Uses the webpack runtime to async load a chunk, and then returns 5 | * the default export for the module specified with moduleID 6 | * @param {number} chunkID 7 | * @param {number} moduleID 8 | */ 9 | export default function fetchRootComponent(chunkID, moduleID) { 10 | return webpackInterop.loadChunk(chunkID).then(() => { 11 | const modNamespace = webpackInterop.require(moduleID); 12 | if (!modNamespace) { 13 | throw new Error( 14 | `Expected chunkID ${chunkID} to have module ${moduleID}. Cannot render this route without a matching RootComponent` 15 | ); 16 | } 17 | 18 | if (typeof modNamespace.default !== 'function') { 19 | throw new Error( 20 | `moduleID ${moduleID} in chunk ${chunkID} was missing a default export for a RootComponent` 21 | ); 22 | } 23 | 24 | return modNamespace.default; 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/Router/__tests__/resolveUnknownRoute.test.js: -------------------------------------------------------------------------------- 1 | import resolveUnknownRoute from '../resolveUnknownRoute'; 2 | 3 | const urlResolverRes = type => 4 | JSON.stringify({ 5 | data: { 6 | urlResolver: { type } 7 | } 8 | }); 9 | 10 | test('Happy path: resolves w/ rootChunkID and rootModuleID for first matching component found', async () => { 11 | fetch.mockResponseOnce(urlResolverRes('PRODUCT')); 12 | fetch.mockResponseOnce( 13 | JSON.stringify({ 14 | Category: { 15 | rootChunkID: 2, 16 | rootModuleID: 100, 17 | pageTypes: ['CATEGORY'] 18 | }, 19 | Product: { 20 | rootChunkID: 1, 21 | rootModuleID: 99, 22 | pageTypes: ['PRODUCT'] // match 23 | } 24 | }) 25 | ); 26 | 27 | const res = await resolveUnknownRoute({ 28 | route: 'foo-bar.html', 29 | apiBase: 'https://store.com', 30 | __tmp_webpack_public_path__: 'https://dev-server.com/pub' 31 | }); 32 | 33 | expect(res.rootChunkID).toBe(1); 34 | expect(res.rootModuleID).toBe(99); 35 | fetch.resetMocks(); 36 | }); 37 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/Price/Price.js: -------------------------------------------------------------------------------- 1 | import { createElement, PureComponent, Fragment } from 'react'; 2 | import { number, string, shape } from 'prop-types'; 3 | 4 | export default class Price extends PureComponent { 5 | static propTypes = { 6 | value: number.isRequired, 7 | currencyCode: string.isRequired, 8 | classes: shape({ 9 | currency: string, 10 | integer: string, 11 | decimal: string, 12 | fraction: string 13 | }) 14 | }; 15 | 16 | static defaultProps = { 17 | classes: {} 18 | }; 19 | 20 | render() { 21 | const { value, currencyCode, classes } = this.props; 22 | const parts = Intl.NumberFormat(undefined, { 23 | style: 'currency', 24 | currency: currencyCode 25 | }).formatToParts(value); 26 | 27 | return ( 28 | 29 | {parts.map((part, i) => { 30 | const partClass = classes[part.type]; 31 | const key = `${i}-${part.value}`; 32 | return ( 33 | 34 | {part.value} 35 | 36 | ); 37 | })} 38 | 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Price/__stories__/Price.js: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import { withReadme } from 'storybook-readme'; 4 | import Price from '../Price'; 5 | import docs from '../__docs__/Price.md'; 6 | 7 | const stories = storiesOf('Price', module); 8 | 9 | stories.add( 10 | 'USD', 11 | withReadme(docs, () => ) 12 | ); 13 | 14 | stories.add( 15 | 'EUR', 16 | withReadme(docs, () => ) 17 | ); 18 | 19 | stories.add( 20 | 'JPY', 21 | withReadme(docs, () => ) 22 | ); 23 | 24 | stories.add( 25 | 'Custom Styles', 26 | withReadme(docs, () => { 27 | const classes = { 28 | currency: 'curr', 29 | integer: 'int', 30 | decimal: 'dec', 31 | fraction: 'fract' 32 | }; 33 | return ( 34 |
    35 | 41 | 42 |
    43 | ); 44 | }) 45 | ); 46 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/Router/Router.js: -------------------------------------------------------------------------------- 1 | import { createElement, Component } from 'react'; 2 | import { BrowserRouter, Route } from 'react-router-dom'; 3 | import { string, func, object } from 'prop-types'; 4 | import MagentoRouteHandler from './MagentoRouteHandler'; 5 | 6 | export default class MagentoRouter extends Component { 7 | static propTypes = { 8 | /* Can be BrowserRouter, MemoryRouter, HashRouter, etc */ 9 | using: func, 10 | routerProps: object, 11 | apiBase: string.isRequired, 12 | __tmp_webpack_public_path__: string.isRequired 13 | }; 14 | 15 | static defaultProps = { 16 | using: BrowserRouter, 17 | routerProps: {} 18 | }; 19 | 20 | render() { 21 | const { 22 | using: Router, 23 | routerProps, 24 | apiBase, 25 | __tmp_webpack_public_path__ 26 | } = this.props; 27 | 28 | return ( 29 | 30 | ( 32 | 39 | )} 40 | /> 41 | 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/util/fromRenderProp.js: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react'; 2 | 3 | // memoization cache 4 | const cache = new Map(); 5 | 6 | export const filterProps = (props = {}, blacklist = []) => 7 | Object.entries(props).reduce((r, [k, v]) => { 8 | if (!blacklist.includes(k)) { 9 | r[k] = v; 10 | } 11 | return r; 12 | }, {}); 13 | 14 | const fromRenderProp = (elementType, customProps = []) => { 15 | const isComposite = typeof elementType === 'function'; 16 | 17 | // if `elementType` is a function, it's already a component 18 | if (isComposite) { 19 | return elementType; 20 | } 21 | 22 | // sort and de-dupe `customProps` 23 | const uniqueCustomProps = Array.from(new Set([...customProps].sort())); 24 | 25 | // hash arguments for memoization 26 | const key = `${elementType}//${uniqueCustomProps.join(',')}`; 27 | 28 | // only create a new component if not cached 29 | // otherwise React will unmount on every render 30 | if (!cache.has(key)) { 31 | // create an SFC that renders a node of type `elementType` 32 | // and filter any props that shouldn't be written to the DOM 33 | const Component = props => 34 | createElement(elementType, filterProps(props, uniqueCustomProps)); 35 | 36 | Component.displayName = `fromRenderProp(${elementType})`; 37 | cache.set(key, Component); 38 | } 39 | 40 | return cache.get(key); 41 | }; 42 | 43 | export default fromRenderProp; 44 | -------------------------------------------------------------------------------- /src/Simulators/DelayedValue.js: -------------------------------------------------------------------------------- 1 | import { createElement, Component } from 'react'; 2 | import { func, number, any } from 'prop-types'; 3 | import MultipleTimedRenders from './MultipleTimedRenders'; 4 | import SimulatorErrorBoundary from './SimulatorErrorBoundary'; 5 | 6 | class DelayedValue extends Component { 7 | static propTypes = { 8 | initial: any, 9 | delay: number.isRequired, 10 | updated: any.isRequired, 11 | onError: func, 12 | /* (prop: any) => React.Element */ 13 | children: func.isRequired 14 | }; 15 | 16 | render() { 17 | const { delay, updated, initial, onError, children } = this.props; 18 | const initialArgs = initial && [initial]; 19 | 20 | return ( 21 | 26 | 36 | {children} 37 | 38 | 39 | ); 40 | } 41 | } 42 | 43 | export default DelayedValue; 44 | -------------------------------------------------------------------------------- /src/List/item.js: -------------------------------------------------------------------------------- 1 | import { Component, createElement } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import fromRenderProp from '../util/fromRenderProp'; 5 | 6 | class Item extends Component { 7 | static propTypes = { 8 | classes: PropTypes.shape({ 9 | root: PropTypes.string 10 | }), 11 | hasFocus: PropTypes.bool, 12 | isSelected: PropTypes.bool, 13 | item: PropTypes.any.isRequired, 14 | render: PropTypes.oneOfType([PropTypes.func, PropTypes.string]) 15 | .isRequired 16 | }; 17 | 18 | static defaultProps = { 19 | classes: {}, 20 | hasFocus: false, 21 | isSelected: false, 22 | render: 'div' 23 | }; 24 | 25 | get children() { 26 | const { item } = this.props; 27 | const isString = typeof item === 'string'; 28 | 29 | return isString ? item : null; 30 | } 31 | 32 | render() { 33 | const { 34 | classes, 35 | hasFocus, 36 | isSelected, 37 | item, 38 | render, 39 | ...restProps 40 | } = this.props; 41 | const customProps = { classes, hasFocus, isSelected, item }; 42 | const Root = fromRenderProp(render, Object.keys(customProps)); 43 | 44 | return ( 45 | 46 | {this.children} 47 | 48 | ); 49 | } 50 | } 51 | 52 | export default Item; 53 | -------------------------------------------------------------------------------- /src/Router/__tests__/fetchRootComponent.test.js: -------------------------------------------------------------------------------- 1 | import fetchRootComponent from '../fetchRootComponent'; 2 | import webpackInterop from '../webpackInterop'; 3 | 4 | jest.mock('../webpackInterop', () => ({ 5 | loadChunk: jest.fn(), 6 | require: jest.fn() 7 | })); 8 | 9 | test('Resolves to default export of requested module', async () => { 10 | webpackInterop.loadChunk.mockReturnValueOnce(Promise.resolve()); 11 | const stubModuleNamespaceObject = { 12 | default: () => {} 13 | }; 14 | webpackInterop.require.mockReturnValueOnce(stubModuleNamespaceObject); 15 | 16 | expect(await fetchRootComponent(0, 0)).toBe( 17 | stubModuleNamespaceObject.default 18 | ); 19 | }); 20 | 21 | test('Should reject with a meaningful error when module is not in chunk', async () => { 22 | expect.hasAssertions(); 23 | webpackInterop.loadChunk.mockReturnValueOnce(Promise.resolve()); 24 | 25 | try { 26 | await fetchRootComponent(0, 0); 27 | } catch (err) { 28 | expect(err.message).toMatch(/without a matching RootComponent/); 29 | } 30 | }); 31 | 32 | test('Should reject with a meaningful error when default export is not a function', async () => { 33 | expect.hasAssertions(); 34 | webpackInterop.loadChunk.mockReturnValueOnce(Promise.resolve()); 35 | webpackInterop.require.mockReturnValueOnce({}); 36 | 37 | try { 38 | await fetchRootComponent(0, 0); 39 | } catch (err) { 40 | expect(err.message).toMatch(/missing a default export/); 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /src/Peregrine/Peregrine.js: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react'; 2 | import { Provider as ReduxProvider } from 'react-redux'; 3 | import createStore from '../store'; 4 | import MagentoRouter from '../Router'; 5 | 6 | /** 7 | * 8 | * @param {string} apiBase Absolute URL pointing to the GraphQL endpoint 9 | * @param {string} __tmp_webpack_public_path__ Temporary hack. Expects the `__webpack_public_path__` value 10 | * @returns {{ store: Store, Provider: () => JSX.Element }} 11 | */ 12 | export default function bootstrap({ apiBase, __tmp_webpack_public_path__ }) { 13 | // Remove deprecation warning after 2 version bumps 14 | if (process.env.NODE_ENV !== 'production' && this instanceof bootstrap) { 15 | throw new Error( 16 | 'The API for Peregrine has changed. ' + 17 | 'Please see the Release Notes on Github ' + 18 | 'for instructions to update your application' 19 | ); 20 | } 21 | 22 | const store = createStore(); 23 | const routerProps = { 24 | apiBase, 25 | __tmp_webpack_public_path__: ensureDirURI(__tmp_webpack_public_path__) 26 | }; 27 | const Provider = () => ( 28 | 29 | 30 | 31 | ); 32 | 33 | return { store, Provider }; 34 | } 35 | 36 | /** 37 | * Given a URI, will always return the same URI with a trailing slash 38 | * @param {string} uri 39 | */ 40 | function ensureDirURI(uri) { 41 | return uri.endsWith('/') ? uri : uri + '/'; 42 | } 43 | -------------------------------------------------------------------------------- /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/Peregrine/__tests__/Peregrine.test.js: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react'; 2 | import { configure, shallow } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | import { Provider as ReduxProvider } from 'react-redux'; 5 | import MagentoRouter from '../../Router'; 6 | import bootstrap from '..'; 7 | 8 | configure({ adapter: new Adapter() }); 9 | 10 | beforeAll(() => { 11 | for (const [methodName, method] of Object.entries(console)) { 12 | if (typeof method === 'function') { 13 | jest.spyOn(console, methodName).mockImplementation(() => {}); 14 | } 15 | } 16 | }); 17 | 18 | afterAll(() => { 19 | for (const method of Object.values(console)) { 20 | if (typeof method === 'function') { 21 | method.mockRestore(); 22 | } 23 | } 24 | }); 25 | 26 | test('Throws descriptive error when using former API', () => { 27 | const fn = () => new bootstrap({}); 28 | expect(fn).toThrowError(/The API for Peregrine has changed/); 29 | }); 30 | 31 | test('Exposes the Redux store', () => { 32 | const { store } = bootstrap({ 33 | apiBase: '/graphql', 34 | __tmp_webpack_public_path__: 'foobar' 35 | }); 36 | expect(store).toMatchObject({ 37 | dispatch: expect.any(Function), 38 | getState: expect.any(Function), 39 | addReducer: expect.any(Function) 40 | }); 41 | }); 42 | 43 | test('Provider includes Redux + Router', () => { 44 | const { Provider } = bootstrap({ 45 | apiBase: '/graphql', 46 | __tmp_webpack_public_path__: 'foobar' 47 | }); 48 | const wrapper = shallow(); 49 | expect(wrapper.find(ReduxProvider).length).toBe(1); 50 | expect(wrapper.find(MagentoRouter).length).toBe(1); 51 | }); 52 | -------------------------------------------------------------------------------- /src/util/__tests__/fromRenderProp.spec.js: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react'; 2 | import { configure, shallow } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | 5 | import fromRenderProp, { filterProps } from '../fromRenderProp'; 6 | 7 | configure({ adapter: new Adapter() }); 8 | 9 | test('returns a component', () => { 10 | const Div = fromRenderProp('div'); 11 | 12 | expect(Div).toBeInstanceOf(Function); 13 | }); 14 | 15 | test('returns a basic component that renders', () => { 16 | const Div = fromRenderProp('div'); 17 | const wrapper = shallow(
    foo
    ); 18 | 19 | expect(wrapper.prop('children')).toBe('foo'); 20 | }); 21 | 22 | test('returns a composite component that renders', () => { 23 | const Foo = props =>
    ; 24 | const WrappedFoo = fromRenderProp(Foo); 25 | const wrapper = shallow(foo); 26 | 27 | expect(wrapper.prop('children')).toBe('foo'); 28 | }); 29 | 30 | test('excludes custom props for a basic component', () => { 31 | const Div = fromRenderProp('div', ['foo']); 32 | const wrapper = shallow(
    ); 33 | 34 | expect(wrapper.prop('foo')).toBeUndefined(); 35 | }); 36 | 37 | test('includes custom props for a composite component', () => { 38 | const Foo = props =>
    ; 39 | const WrappedFoo = fromRenderProp(Foo, ['foo']); 40 | const wrapper = shallow(); 41 | 42 | expect(wrapper.prop('foo')).toBe('bar'); 43 | }); 44 | 45 | test('`filterProps` returns an object', () => { 46 | expect(filterProps()).toEqual({}); 47 | }); 48 | 49 | test('`filterProps` filters properties from an object', () => { 50 | const input = { a: 0, b: 1 }; 51 | const output = { b: 1 }; 52 | const excludedProps = ['a']; 53 | 54 | expect(filterProps(input, excludedProps)).toEqual(output); 55 | }); 56 | -------------------------------------------------------------------------------- /src/Price/__tests__/Price.spec.js: -------------------------------------------------------------------------------- 1 | import { createElement, Fragment } from 'react'; 2 | import { configure, shallow } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | import Price from '../Price'; 5 | import IntlPolyfill from 'intl'; 6 | 7 | configure({ adapter: new Adapter() }); 8 | 9 | if (!global.Intl.NumberFormat.prototype.formatToParts) { 10 | global.Intl = IntlPolyfill; 11 | } 12 | 13 | test('Renders a USD price', () => { 14 | const wrapper = shallow(); 15 | expect( 16 | wrapper.equals( 17 | 18 | $ 19 | 100 20 | . 21 | 99 22 | 23 | ) 24 | ).toBe(true); 25 | }); 26 | 27 | test('Renders a EUR price', () => { 28 | const wrapper = shallow(); 29 | expect( 30 | wrapper.equals( 31 | 32 | 33 | 100 34 | . 35 | 99 36 | 37 | ) 38 | ).toBe(true); 39 | }); 40 | 41 | test('Allows custom classnames for each part', () => { 42 | const classes = { 43 | currency: 'curr', 44 | integer: 'int', 45 | decimal: 'dec', 46 | fraction: 'fract' 47 | }; 48 | const wrapper = shallow( 49 | 50 | ); 51 | expect( 52 | wrapper.equals( 53 | 54 | $ 55 | 88 56 | . 57 | 81 58 | 59 | ) 60 | ).toBe(true); 61 | }); 62 | -------------------------------------------------------------------------------- /src/Price/__docs__/Price.md: -------------------------------------------------------------------------------- 1 | # Price 2 | 3 | The `Price` component is used anywhere a price is rendered in PWA Studio. 4 | 5 | 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). 6 | 7 | ## Usage 8 | 9 | ```jsx 10 | import Price from '@peregrine/Price'; 11 | import cssModule from './my-pricing-styles'; 12 | 13 | ; 14 | /* 15 | $ 16 | 88 17 | . 18 | 81 19 | */ 20 | ``` 21 | 22 | ## Props 23 | 24 | | Prop Name | Required? | Description | 25 | | -------------- | :-------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 26 | | `classes` | ❌ | A classname object. | 27 | | `value` | ✅ | Numeric price | 28 | | `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) | 29 | -------------------------------------------------------------------------------- /src/Simulators/MultipleTimedRenders.js: -------------------------------------------------------------------------------- 1 | import { createElement, Component } from 'react'; 2 | import { arrayOf, oneOfType, shape, func, number, any } from 'prop-types'; 3 | import SimulatorErrorBoundary from './SimulatorErrorBoundary'; 4 | import scheduleCallbackArgs from './schedule-callback-args'; 5 | 6 | export default class MultipleTimedRenders extends Component { 7 | static propTypes = { 8 | initialArgs: oneOfType([arrayOf(any), func]), 9 | scheduledArgs: arrayOf( 10 | shape({ 11 | elapsed: number.isRequired, 12 | args: oneOfType([arrayOf(any), func]).isRequired 13 | }).isRequired 14 | ).isRequired, 15 | /* (error: Error) => any */ 16 | onError: func, 17 | /* (prop: any) => React.Element */ 18 | children: func.isRequired 19 | }; 20 | 21 | static defaultProps = { 22 | onError: e => { 23 | throw e; 24 | } 25 | }; 26 | 27 | state = { 28 | args: 29 | typeof this.props.initialArgs === 'function' 30 | ? this.props.initialArgs() 31 | : this.props.initialArgs 32 | }; 33 | 34 | componentDidMount() { 35 | this._pending = scheduleCallbackArgs( 36 | this.props.scheduledArgs, 37 | (...args) => this.setState({ args }), 38 | e => 39 | this.props.onError( 40 | new Error(`Could not retrieve arguments: ${e.message}`) 41 | ) 42 | ); 43 | } 44 | 45 | componentWillUnmount() { 46 | this._pending.cancel(); 47 | } 48 | 49 | render() { 50 | return this.state.args ? ( 51 | 56 | {this.props.children(...this.state.args)} 57 | 58 | ) : null; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Router/__tests__/MagentoRouteHandler.test.js: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react'; 2 | import MagentoRouteHandler from '../MagentoRouteHandler'; 3 | import { configure, shallow } from 'enzyme'; 4 | import Adapter from 'enzyme-adapter-react-16'; 5 | import resolveUnknownRoute from '../resolveUnknownRoute'; 6 | import fetchRootComponent from '../fetchRootComponent'; 7 | 8 | configure({ adapter: new Adapter() }); 9 | 10 | jest.mock('../fetchRootComponent', () => jest.fn()); 11 | jest.mock('../resolveUnknownRoute'); 12 | 13 | const mockUnknownRouteResolverOnce = () => 14 | resolveUnknownRoute.mockReturnValueOnce( 15 | Promise.resolve({ 16 | rootChunkID: 0, 17 | rootModuleID: 1, 18 | matched: true 19 | }) 20 | ); 21 | const mockFetchRootComponentOnce = Component => 22 | fetchRootComponent.mockReturnValueOnce(Promise.resolve(Component)); 23 | 24 | test('Does not re-fetch route that has already been seen', cb => { 25 | const RouteComponent = () =>
    I'm a route
    ; 26 | const SecondRouteComponent = () =>
    Other Route
    ; 27 | mockUnknownRouteResolverOnce(); 28 | mockFetchRootComponentOnce(RouteComponent); 29 | const wrapper = shallow( 30 | 35 | ); 36 | wrapper.setState({ 37 | // Populate state with a pre-visited route 38 | '/second-path.html': SecondRouteComponent 39 | }); 40 | process.nextTick(() => { 41 | wrapper.update(); 42 | wrapper.setProps({ 43 | // Navigate to page we've already seen 44 | location: { pathname: '/second-path.html' } 45 | }); 46 | process.nextTick(() => { 47 | expect(resolveUnknownRoute).toHaveBeenCalledTimes(1); 48 | expect(wrapper.find(SecondRouteComponent).length).toBe(1); 49 | cb(); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/List/list.js: -------------------------------------------------------------------------------- 1 | import { Component, createElement } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import fromRenderProp from '../util/fromRenderProp'; 5 | import Items from './items'; 6 | 7 | class List extends Component { 8 | static propTypes = { 9 | classes: PropTypes.shape({ 10 | root: PropTypes.string 11 | }), 12 | items: PropTypes.oneOfType([ 13 | PropTypes.instanceOf(Map), 14 | PropTypes.arrayOf(PropTypes.array) 15 | ]).isRequired, 16 | render: PropTypes.oneOfType([PropTypes.func, PropTypes.string]) 17 | .isRequired, 18 | renderItem: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), 19 | onSelectionChange: PropTypes.func, 20 | selectionModel: PropTypes.oneOf(['checkbox', 'radio']) 21 | }; 22 | 23 | static defaultProps = { 24 | classes: {}, 25 | items: new Map(), 26 | render: 'div', 27 | renderItem: 'div', 28 | selectionModel: 'radio' 29 | }; 30 | 31 | render() { 32 | const { 33 | classes, 34 | items, 35 | render, 36 | renderItem, 37 | onSelectionChange, 38 | selectionModel, 39 | ...restProps 40 | } = this.props; 41 | 42 | const customProps = { 43 | classes, 44 | items, 45 | onSelectionChange, 46 | selectionModel 47 | }; 48 | 49 | const Root = fromRenderProp(render, Object.keys(customProps)); 50 | 51 | return ( 52 | 53 | 59 | 60 | ); 61 | } 62 | 63 | handleSelectionChange = selection => { 64 | const { onSelectionChange } = this.props; 65 | 66 | if (onSelectionChange) { 67 | onSelectionChange(selection); 68 | } 69 | }; 70 | } 71 | 72 | export default List; 73 | -------------------------------------------------------------------------------- /src/Router/MagentoRouteHandler.js: -------------------------------------------------------------------------------- 1 | import { createElement, Component } from 'react'; 2 | import { string, shape } from 'prop-types'; 3 | import resolveUnknownRoute from './resolveUnknownRoute'; 4 | import fetchRootComponent from './fetchRootComponent'; 5 | 6 | export default class MagentoRouteHandler extends Component { 7 | static propTypes = { 8 | apiBase: string.isRequired, 9 | __tmp_webpack_public_path__: string.isRequired, 10 | location: shape({ 11 | pathname: string.isRequired 12 | }).isRequired 13 | }; 14 | 15 | state = {}; 16 | 17 | componentDidMount() { 18 | this.getRouteComponent(this.props.location.pathname); 19 | } 20 | 21 | componentWillReceiveProps(nextProps) { 22 | const { location } = this.props; 23 | const changed = nextProps.location.pathname !== location.pathname; 24 | const seen = !!this.state[nextProps.location.pathname]; 25 | 26 | if (changed && !seen) { 27 | this.getRouteComponent(nextProps.location.pathname); 28 | } 29 | } 30 | 31 | getRouteComponent(pathname) { 32 | const { apiBase, __tmp_webpack_public_path__ } = this.props; 33 | 34 | resolveUnknownRoute({ 35 | route: pathname, 36 | apiBase, 37 | __tmp_webpack_public_path__ 38 | }) 39 | .then(({ rootChunkID, rootModuleID, matched }) => { 40 | if (!matched) { 41 | // TODO: User-defined 404 page 42 | // when the API work is done to support it 43 | throw new Error('404'); 44 | } 45 | return fetchRootComponent(rootChunkID, rootModuleID); 46 | }) 47 | .then(Component => { 48 | this.setState({ 49 | [pathname]: Component 50 | }); 51 | }) 52 | .catch(err => { 53 | console.log('Routing resolve failed\n', err); 54 | }); 55 | } 56 | 57 | render() { 58 | const { location } = this.props; 59 | const Page = this.state[location.pathname]; 60 | 61 | if (!Page) { 62 | // TODO (future iteration): User-defined loading content 63 | return
    Loading
    ; 64 | } 65 | 66 | return ; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/items.js: -------------------------------------------------------------------------------- 1 | import { Component, Fragment, createElement } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import memoize from '../util/unaryMemoize'; 5 | import ListItem from './item'; 6 | 7 | const removeFocus = () => ({ 8 | hasFocus: false 9 | }); 10 | 11 | const updateCursor = memoize(index => () => ({ 12 | cursor: index, 13 | hasFocus: true 14 | })); 15 | 16 | const updateSelection = memoize(key => (prevState, props) => { 17 | const { selectionModel } = props; 18 | let selection; 19 | 20 | if (selectionModel === 'radio') { 21 | selection = new Set().add(key); 22 | } 23 | 24 | if (selectionModel === 'checkbox') { 25 | selection = new Set(prevState.selection); 26 | 27 | if (selection.has(key)) { 28 | selection.delete(key); 29 | } else { 30 | selection.add(key); 31 | } 32 | } 33 | 34 | return { selection }; 35 | }); 36 | 37 | class Items extends Component { 38 | static propTypes = { 39 | items: PropTypes.oneOfType([ 40 | PropTypes.instanceOf(Map), 41 | PropTypes.arrayOf(PropTypes.array) 42 | ]).isRequired, 43 | renderItem: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), 44 | selectionModel: PropTypes.oneOf(['checkbox', 'radio']) 45 | }; 46 | 47 | static defaultProps = { 48 | selectionModel: 'radio' 49 | }; 50 | 51 | state = { 52 | cursor: null, 53 | hasFocus: false, 54 | selection: new Set() 55 | }; 56 | 57 | render() { 58 | const { items, renderItem } = this.props; 59 | const { cursor, hasFocus, selection } = this.state; 60 | 61 | const children = Array.from(items, ([key, item], index) => ( 62 | 72 | )); 73 | 74 | return {children}; 75 | } 76 | 77 | syncSelection() { 78 | const { selection } = this.state; 79 | const { onSelectionChange } = this.props; 80 | 81 | if (onSelectionChange) { 82 | onSelectionChange(selection); 83 | } 84 | } 85 | 86 | handleBlur = () => { 87 | this.setState(removeFocus); 88 | }; 89 | 90 | getClickHandler = memoize(key => () => { 91 | this.setState(updateSelection(key), this.syncSelection); 92 | }); 93 | 94 | getFocusHandler = memoize(index => () => { 95 | this.setState(updateCursor(index)); 96 | }); 97 | } 98 | 99 | export default Items; 100 | -------------------------------------------------------------------------------- /src/Router/resolveUnknownRoute.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Given a route string, resolves with the "standard route", along 3 | * with the assigned Root Component (and its owning chunk) from the backend 4 | * @param {{ route: string, apiBase: string, __tmp_webpack_public_path__: string}} opts 5 | * @returns {Promise<{matched: boolean, rootChunkID: number | undefined, rootModuleID: number | undefined }>} 6 | */ 7 | export default function resolveUnknownRoute(opts) { 8 | const { route, apiBase, __tmp_webpack_public_path__ } = opts; 9 | 10 | return remotelyResolveRoute({ 11 | route, 12 | apiBase 13 | }).then(res => { 14 | if (!(res && res.type)) { 15 | return { matched: false }; 16 | } 17 | return tempGetWebpackChunkData( 18 | res.type, 19 | __tmp_webpack_public_path__ 20 | ).then(({ rootChunkID, rootModuleID }) => ({ 21 | rootChunkID, 22 | rootModuleID, 23 | matched: true 24 | })); 25 | }); 26 | } 27 | 28 | /** 29 | * @description Calls the GraphQL API for results from the urlResolver query 30 | * @param {{ route: string, apiBase: string}} opts 31 | * @returns {Promise<{type: "PRODUCT" | "CATEGORY" | "CMS_PAGE"}>} 32 | */ 33 | function remotelyResolveRoute(opts) { 34 | const url = new URL('/graphql', opts.apiBase); 35 | return fetch(url, { 36 | method: 'POST', 37 | credentials: 'include', 38 | headers: new Headers({ 39 | 'Content-Type': 'application/json' 40 | }), 41 | body: JSON.stringify({ 42 | query: ` 43 | { 44 | urlResolver(url: "${opts.route}") { 45 | type 46 | } 47 | } 48 | `.trim() 49 | }) 50 | }) 51 | .then(res => res.json()) 52 | .then(res => res.data.urlResolver); 53 | } 54 | 55 | /** 56 | * @description This is temporary until we have proper support in the backend 57 | * and the GraphQL API for storing/retrieving the assigned Root Component for a route. 58 | * For now, we fetch the manifest manually, and just grab the first RootComponent 59 | * that is compatible with the current pageType 60 | * @param {"PRODUCT" | "CATEGORY" | "CMS_PAGE"} pageType 61 | * @returns {Promise<{rootChunkID: number, rootModuleID: number}>} 62 | */ 63 | function tempGetWebpackChunkData(pageType, webpackPublicPath) { 64 | return fetch(new URL('roots-manifest.json', webpackPublicPath)) 65 | .then(res => res.json()) 66 | .then(manifest => { 67 | const firstCompatibleConfig = Object.values(manifest).find(conf => { 68 | return conf.pageTypes.some(type => type === pageType); 69 | }); 70 | 71 | if (!firstCompatibleConfig) { 72 | throw new Error( 73 | `Could not find RootComponent for pageType ${pageType}` 74 | ); 75 | } 76 | 77 | return { 78 | rootChunkID: firstCompatibleConfig.rootChunkID, 79 | rootModuleID: firstCompatibleConfig.rootModuleID 80 | }; 81 | }); 82 | } 83 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /src/Router/__tests__/Router.test.js: -------------------------------------------------------------------------------- 1 | import MagentoRouter from '../Router'; 2 | import { createElement } from 'react'; 3 | import { configure, mount, shallow } from 'enzyme'; 4 | import Adapter from 'enzyme-adapter-react-16'; 5 | import { MemoryRouter } from 'react-router-dom'; 6 | import resolveUnknownRoute from '../resolveUnknownRoute'; 7 | import fetchRootComponent from '../fetchRootComponent'; 8 | 9 | configure({ adapter: new Adapter() }); 10 | 11 | jest.mock('../fetchRootComponent', () => jest.fn()); 12 | jest.mock('../resolveUnknownRoute'); 13 | 14 | const mockUnknownRouteResolverOnce = () => 15 | resolveUnknownRoute.mockReturnValueOnce( 16 | Promise.resolve({ 17 | rootChunkID: 0, 18 | rootModuleID: 1, 19 | matched: true 20 | }) 21 | ); 22 | 23 | const mockFetchRootComponentOnce = Component => 24 | fetchRootComponent.mockReturnValueOnce(Promise.resolve(Component)); 25 | 26 | test('Only rendered route is a catch-all', () => { 27 | const routesWrapper = shallow( 28 | 33 | ).find('Route'); 34 | expect(routesWrapper.length).toBe(1); 35 | expect(routesWrapper.prop('path')).toBeUndefined(); 36 | }); 37 | 38 | test('Renders component for matching route', cb => { 39 | mockUnknownRouteResolverOnce(); 40 | const RouteComponent = () =>
    Route Component
    ; 41 | mockFetchRootComponentOnce(RouteComponent); 42 | const wrapper = mount( 43 | 51 | ); 52 | 53 | process.nextTick(() => { 54 | wrapper.update(); 55 | expect(wrapper.text()).toBe('Route Component'); 56 | cb(); 57 | }); 58 | }); 59 | 60 | test('Renders loading content before first route is resolved', () => { 61 | mockUnknownRouteResolverOnce(); 62 | const RouteComponent = () =>
    Route Component
    ; 63 | mockFetchRootComponentOnce(RouteComponent); 64 | const wrapper = mount( 65 | 73 | ); 74 | expect(wrapper.text()).toBe('Loading'); 75 | }); 76 | 77 | test('On route change, fetches and renders new route', cb => { 78 | mockUnknownRouteResolverOnce(); 79 | const RouteComponent = () =>
    Route Component
    ; 80 | mockFetchRootComponentOnce(RouteComponent); 81 | const wrapper = mount( 82 | 90 | ); 91 | 92 | mockUnknownRouteResolverOnce(); 93 | const NewPage = () =>
    New Page
    ; 94 | mockFetchRootComponentOnce(NewPage); 95 | const { history } = wrapper.find('Router').props(); 96 | history.push('/another-route.html'); 97 | 98 | process.nextTick(() => { 99 | wrapper.update(); 100 | expect(wrapper.text()).toBe('New Page'); 101 | cb(); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /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 = () =>