diff --git a/README.md b/README.md index 3a53895..bf56ad3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ **Experimental Addon** -This was built as a prototype to evaluate using react inside of our Ember apps. We are not yet using it in production. PRs and constructive questions and comments via [GitHub issues](https://github.com/AltSchool/ember-cli-react/issues/new) are highly encouraged. +This was built as a prototype to evaluate using React inside of our Ember apps. +We are not yet using it in production. PRs and constructive questions and +comments via [GitHub +issues](https://github.com/AltSchool/ember-cli-react/issues/new) are highly +encouraged. # ember-cli-react @@ -26,15 +30,16 @@ yarn add --dev ember-cli-react ember generate ember-cli-react ``` -**NOTE**: -`ember-cli-react` relies on a custom resolver to discover components. If you have -installed `ember-cli-react` with the standard way then you should be fine. Otherwise, you will need to manually update the first line of `app/resolver.js` to `import Resolver from 'ember-cli-react/resolver';`. +**NOTE**: `ember-cli-react` relies on a custom resolver to discover components. +If you have installed `ember-cli-react` with the standard way then you should be +fine. Otherwise, you will need to manually update the first line of +`app/resolver.js` to `import Resolver from 'ember-cli-react/resolver';`. ## Usage Write your React component as usual: -```javascript +```jsx // app/components/say-hi.jsx import React from 'react'; @@ -49,11 +54,13 @@ Then render your component in a handlebars template: {{say-hi name="Alex"}} ``` -**NOTE**: Currently, `ember-cli-react` recognizes React components with `.jsx` extension only. +**NOTE**: Currently, `ember-cli-react` recognizes React components with `.jsx` +extension only. ## Block Form -Your React component can be used in block form to allow composition with existing Ember or React components. +Your React component can be used in block form to allow composition with +existing Ember or React components. ```handlebars {{#react-panel}} @@ -63,8 +70,9 @@ Your React component can be used in block form to allow composition with existin The children of `react-panel` will be populated to `props.children`. -Note that if the children contains mutating structure (e.g. `{{if}}`, `{{each}}`), -you need to wrap them in a stable tag to work around [this Glimmer issue](https://github.com/yapplabs/ember-wormhole/issues/66#issuecomment-263575168). +Note that if the children contains mutating structure (e.g. `{{if}}`, +`{{each}}`), you need to wrap them in a stable tag to work around [this Glimmer +issue](https://github.com/yapplabs/ember-wormhole/issues/66#issuecomment-263575168). ```handlebars {{#react-panel}} @@ -78,8 +86,80 @@ you need to wrap them in a stable tag to work around [this Glimmer issue](https: {{/react-panel}} ``` -Although this is possible, block form should be used as a tool to migrate Ember to React -without the hard requirement to start with leaf components. It is highly recommended to have clean React component tree whenever possible for best performance. +Although this is possible, block form should be used as a tool to migrate Ember +to React without the hard requirement to start with leaf components. It is +highly recommended to have clean React component tree whenever possible for best +performance. + +## Using File Name Convention for React + +React is unopinionated with file name convention. However, the majority of the +community has still developed some conventions over time. + +For React component files, the widely adopted convention is PascalCase, +including +[Airbnb](https://github.com/airbnb/javascript/tree/master/react#naming). So we +have added support for this convention. + +In short, you can name your JSX files in `PascalCase`, in addition to +`snake-case`. + +```handlebars +{{!-- Both `user-avatar.jsx` and `UserAvatar.jsx` work --}} +{{user-avatar}} +``` + +### Rendering in Template + +When using the `react-component` component, referencing your React components +with `PascalCase` is also supported. However, due to the "at least one dash" +policy, it won't work if the component name is used directly. + +```handlebars +{{!-- OK! --}} +{{react-component "user-avatar"}} + +{{!-- OK! --}} +{{react-component "UserAvatar"}} + +{{!-- OK! --}} +{{user-avatar}} + +{{!-- NOT OK! --}} +{{UserAvatar}} +``` + +### Single-worded Component + +Ember requires at least a dash for component names. So single-worded component +(e.g. `Avatar`) cannot be used directly in Handlebars. However, you can still +use single-worded component with `react-component` component. + +```handlebars +{{!-- This won't work because Ember requires a dash for component --}} +{{avatar}} + +{{!-- This works --}} +{{react-component 'Avatar'}} +``` + +### React Components are Prioritised + +Whenever there is a conflict, component files with React-style convention will +be used. + +Examples: + +- When both `SameName.jsx` and `same-name.jsx` exist, `SameName.jsx` will be + used +- When both `SameName.jsx` and `same-name.js` (Ember) exist, `SameName.jsx` will + be used + +#### Known issue + +If an Ember component and a React component has exactly the same name but different extension (`same-name.js` and +`same-name.jsx`), the file with `.js` extension will be overwritten with the +output of `same-name.jsx`. We are still looking at ways to resolve this. ## Mini Todo List Example @@ -176,6 +256,9 @@ export default class TodoItem extends React.Component { ## What's Missing -There is no React `link-to` equivalent for linking to Ember routes inside of your React code. Instead pass action handlers that call `transitionTo` from an Ember route or component. +There is no React `link-to` equivalent for linking to Ember routes inside of +your React code. Instead pass action handlers that call `transitionTo` from an +Ember route or component. -In order to create minified production builds of React you must set `NODE_ENV=production`. +In order to create minified production builds of React you must set +`NODE_ENV=production`. diff --git a/addon/resolver.js b/addon/resolver.js index d93231d..ac240b9 100644 --- a/addon/resolver.js +++ b/addon/resolver.js @@ -6,26 +6,58 @@ import ReactComponent from 'ember-cli-react/components/react-component'; const { get } = Ember; export default Resolver.extend({ + // `resolveComponent` is triggered when rendering a component in template. + // For example, having `{{foo-bar}}` in a template will trigger `resolveComponent` + // with the name full name of `component:foo-bar`. resolveComponent(parsedName) { - const result = this.resolveOther(parsedName); + // First try to resolve with React-styled file name (e.g. SayHi). + // If nothing is found, try again with original convention via `resolveOther`. + let result = + this._resolveReactStyleFile(parsedName) || this.resolveOther(parsedName); + // If there is no result found after all, return nothing if (!result) { return; } + // If there is an Ember component found, return it. + // This includes the `react-component` Ember component. if (get(result, 'isComponentFactory')) { return result; } else { + // This enables using React Components directly in template return ReactComponent.extend({ reactComponent: result, }); } }, + // This resolver method is defined when we try to lookup from `react-component`. + // We create a new namespace `react-component:the-component` for them. resolveReactComponent(parsedName) { parsedName.type = 'component'; - const result = this.resolveOther(parsedName); + const result = + this._resolveReactStyleFile(parsedName) || this.resolveOther(parsedName); parsedName.type = 'react-component'; return result; }, + + // This resolver method attempt to find a file with React-style file name. + // A React-style file name is in PascalCase. + // This is made a private method to prevent creation of "react-style-file:*" + // factory. + _resolveReactStyleFile(parsedName) { + const originalName = parsedName.fullNameWithoutType; + + // Convert the compnent name while preserving namespaces + const parts = originalName.split('/'); + parts[parts.length - 1] = Ember.String.classify(parts[parts.length - 1]); + const newName = parts.join('/'); + + const parsedNameWithPascalCase = Object.assign({}, parsedName, { + fullNameWithoutType: newName, + }); + const result = this.resolveOther(parsedNameWithPascalCase); + return result; + }, }); diff --git a/package.json b/package.json index 88843a2..8818f72 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "eslint --fix", "git add" ], - "+(*.{json,css}|.prettierrc|.watchmanconfig)": [ + "+(*.{json,css,md}|.prettierrc|.watchmanconfig)": [ "prettier --write", "git add" ] diff --git a/tests/dummy/app/components/Card.jsx b/tests/dummy/app/components/Card.jsx new file mode 100644 index 0000000..b752bc5 --- /dev/null +++ b/tests/dummy/app/components/Card.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const Card = () => { + return I am a Card component, I have no dash!; +}; + +export default Card; diff --git a/tests/dummy/app/components/ReactStyleFileName.jsx b/tests/dummy/app/components/ReactStyleFileName.jsx new file mode 100644 index 0000000..24afa8e --- /dev/null +++ b/tests/dummy/app/components/ReactStyleFileName.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const ReactStyleFileName = () => { + return My file name is ReactStyleFileName; +}; + +export default ReactStyleFileName; diff --git a/tests/dummy/app/components/SameNameDifferentCaseMixed.jsx b/tests/dummy/app/components/SameNameDifferentCaseMixed.jsx new file mode 100644 index 0000000..1917eb3 --- /dev/null +++ b/tests/dummy/app/components/SameNameDifferentCaseMixed.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const SameNameDifferentCaseMixed = () => { + return My file name is "SameNameDifferentCaseMixed.jsx"; +}; + +export default SameNameDifferentCaseMixed; diff --git a/tests/dummy/app/components/SameNameJsx.jsx b/tests/dummy/app/components/SameNameJsx.jsx new file mode 100644 index 0000000..b017324 --- /dev/null +++ b/tests/dummy/app/components/SameNameJsx.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const SameNameJsxWithPascalCase = () => { + return My file name is "SameNameJsx.jsx"; +}; + +export default SameNameJsxWithPascalCase; diff --git a/tests/dummy/app/components/namespace/InsideNamespace.jsx b/tests/dummy/app/components/namespace/InsideNamespace.jsx new file mode 100644 index 0000000..5f92cd3 --- /dev/null +++ b/tests/dummy/app/components/namespace/InsideNamespace.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const InsideNamespace = () => { + return I am inside a namespace!; +}; + +export default InsideNamespace; diff --git a/tests/dummy/app/components/same-name-different-case-mixed.js b/tests/dummy/app/components/same-name-different-case-mixed.js new file mode 100644 index 0000000..a3e1d83 --- /dev/null +++ b/tests/dummy/app/components/same-name-different-case-mixed.js @@ -0,0 +1,3 @@ +import Ember from 'ember'; + +export default Ember.Component.extend(); diff --git a/tests/dummy/app/components/same-name-jsx.jsx b/tests/dummy/app/components/same-name-jsx.jsx new file mode 100644 index 0000000..7e12e9c --- /dev/null +++ b/tests/dummy/app/components/same-name-jsx.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const SameNameJsxWithSnakeCase = () => { + return My file name is "same-name-jsx.jsx"; +}; + +export default SameNameJsxWithSnakeCase; diff --git a/tests/dummy/app/templates/components/same-name-different-case-mixed.hbs b/tests/dummy/app/templates/components/same-name-different-case-mixed.hbs new file mode 100644 index 0000000..11ab511 --- /dev/null +++ b/tests/dummy/app/templates/components/same-name-different-case-mixed.hbs @@ -0,0 +1 @@ +My file name is "same-name-different-case-mixed.js" \ No newline at end of file diff --git a/tests/integration/components/react-component-test.js b/tests/integration/components/react-component-test.js index e9c596c..2148d28 100644 --- a/tests/integration/components/react-component-test.js +++ b/tests/integration/components/react-component-test.js @@ -323,6 +323,156 @@ describeComponent( expect(this.$().text()).to.contain('Hello Morgan'); }); + + it('supports React-style component file name', function() { + this.render(hbs`{{react-component "ReactStyleFileName"}}`); + + expect( + this.$() + .text() + .trim() + ).to.equal('My file name is ReactStyleFileName'); + }); + + it('supports React-style component file name, but render with Ember style name', function() { + this.render(hbs`{{react-component "react-style-file-name"}}`); + + expect( + this.$() + .text() + .trim() + ).to.equal('My file name is ReactStyleFileName'); + }); + + it('supports React-style component file name even without dash', function() { + this.render(hbs`{{react-component "card"}}`); + + expect( + this.$() + .text() + .trim() + ).to.equal('I am a Card component, I have no dash!'); + }); + + it('supports React-style component file name when rendering directly', function() { + this.render(hbs`{{react-style-file-name}}`); + + expect( + this.$() + .text() + .trim() + ).to.equal('My file name is ReactStyleFileName'); + }); + + it('supports React-style component file name with namespace', function() { + this.render(hbs`{{react-component "namespace/InsideNamespace"}}`); + + expect( + this.$() + .text() + .trim() + ).to.equal('I am inside a namespace!'); + }); + + it('supports React-style component file name when rendering directly with namespace', function() { + this.render(hbs`{{namespace/inside-namespace}}`); + + expect( + this.$() + .text() + .trim() + ).to.equal('I am inside a namespace!'); + }); + + describe('when both `SameNameJsx.jsx` and `same-name-jsx.jsx` exist', function() { + it('prioritises React-style file name (SameNameJsx.jsx)', function() { + this.render(hbs`{{react-component "SameNameJsx"}}`); + + expect( + this.$() + .text() + .trim() + ).to.equal('My file name is "SameNameJsx.jsx"'); + }); + + it('prioritises React-style file name (SameNameJsx.jsx) when render with Ember-style name', function() { + this.render(hbs`{{react-component "same-name-jsx"}}`); + + expect( + this.$() + .text() + .trim() + ).to.equal('My file name is "SameNameJsx.jsx"'); + }); + + it('prioritises React-style file name (SameNameJsx.jsx) when rendering directly', function() { + this.render(hbs`{{same-name-jsx}}`); + + expect( + this.$() + .text() + .trim() + ).to.equal('My file name is "SameNameJsx.jsx"'); + }); + }); + + describe('when both `SameNameDifferentCaseMixed.jsx` and `same-name-different-case-mixed.js` (Ember) exist', function() { + it('prioritises the React component (SameNameDifferentCaseMixed.jsx)', function() { + this.render(hbs`{{react-component "SameNameDifferentCaseMixed"}}`); + + expect( + this.$() + .text() + .trim() + ).to.equal('My file name is "SameNameDifferentCaseMixed.jsx"'); + }); + + it('prioritises the React component (SameNameDifferentCaseMixed.jsx) when render with Ember-style name', function() { + this.render( + hbs`{{react-component "same-name-different-case-mixed"}}` + ); + + expect( + this.$() + .text() + .trim() + ).to.equal('My file name is "SameNameDifferentCaseMixed.jsx"'); + }); + + it('prioritises the React component (SameNameDifferentCaseMixed.jsx) when rendering directly', function() { + this.render(hbs`{{same-name-different-case-mixed}}`); + + expect( + this.$() + .text() + .trim() + ).to.equal('My file name is "SameNameDifferentCaseMixed.jsx"'); + }); + }); + + // The React file will overwrite Ember file as that's how Broccoli-React works. + // Skipping this to keep this in mind. + describe.skip('when both `same-name-same-case-mixed.jsx` and `same-name-same-case-mixed.js` (Ember) exist', function() { + it('prioritises the React component (same-name-same-case-mixed.js)', function() { + this.render(hbs`{{react-component "same-name-same-case-mixed"}}`); + + expect( + this.$() + .text() + .trim() + ).to.equal('My file name is "same-name-same-case-mixed.jsx"'); + }); + + it('prioritises the React component (same-name-same-case-mixed.js) when rendering directly', function() { + this.render(hbs`{{same-name-ember}}`); + + expect( + this.$() + .text() + .trim() + ).to.equal('My file name is "same-name-same-case-mixed.jsx"'); + }); + }); }); } );