diff --git a/lib/__tests__/__snapshots__/transform.js.snap b/lib/__tests__/__snapshots__/transform.js.snap index a3d3667..215db87 100644 --- a/lib/__tests__/__snapshots__/transform.js.snap +++ b/lib/__tests__/__snapshots__/transform.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`basic 1`] = ` +exports[`classic components basic 1`] = ` "========== export default Component.extend({ @@ -23,7 +23,7 @@ foo ==========" `; -exports[`handles \`ariaRole\` correctly 1`] = ` +exports[`classic components handles \`ariaRole\` correctly 1`] = ` "========== export default Component.extend({ @@ -47,7 +47,7 @@ foo ==========" `; -exports[`handles \`attributeBindings\` correctly 1`] = ` +exports[`classic components handles \`attributeBindings\` correctly 1`] = ` "========== export default Component.extend({ @@ -71,7 +71,7 @@ foo ==========" `; -exports[`handles \`classNameBindings\` correctly 1`] = ` +exports[`classic components handles \`classNameBindings\` correctly 1`] = ` "========== export default Component.extend({ @@ -95,7 +95,7 @@ foo ==========" `; -exports[`handles \`classNames\` correctly 1`] = ` +exports[`classic components handles \`classNames\` correctly 1`] = ` "========== export default Component.extend({ @@ -119,7 +119,7 @@ foo ==========" `; -exports[`handles \`elementId\` correctly 1`] = ` +exports[`classic components handles \`elementId\` correctly 1`] = ` "========== export default Component.extend({ @@ -143,7 +143,7 @@ foo ==========" `; -exports[`handles \`hasComponentCSS\` option correctly 1`] = ` +exports[`classic components handles \`hasComponentCSS\` option correctly 1`] = ` "========== export default Component.extend({ @@ -167,7 +167,7 @@ foo ==========" `; -exports[`handles single \`classNames\` item correctly 1`] = ` +exports[`classic components handles single \`classNames\` item correctly 1`] = ` "========== export default Component.extend({ @@ -191,7 +191,7 @@ foo ==========" `; -exports[`multi-line template 1`] = ` +exports[`classic components multi-line template 1`] = ` "========== export default Component.extend({}); ~~~~~~~~~~ @@ -217,7 +217,7 @@ export default Component.extend({ ==========" `; -exports[`replaces existing \`tagName\` 1`] = ` +exports[`classic components replaces existing \`tagName\` 1`] = ` "========== export default Component.extend({ @@ -240,3 +240,363 @@ foo ==========" `; + +exports[`native components basic 1`] = ` +"========== + + export default class FooComponent extends Component { + } + +~~~~~~~~~~ +foo +~~~~~~~~~~ + => tagName: div +~~~~~~~~~~ + + import { tagName } from \\"@ember-decorators/component\\"; + @tagName(\\"\\") + export default class FooComponent extends Component { + } + +~~~~~~~~~~ +
+ foo +
+==========" +`; + +exports[`native components handles \`@attribute\` and \`@attributeBindings\` correctly 1`] = ` +"========== + + import { attribute, attributeBindings } from '@ember-decorators/component'; + + @attributeBindings('foo') + export default class FooComponent extends Component { + @attribute('baz') bar; + } + +~~~~~~~~~~ +foo +~~~~~~~~~~ + => tagName: div +~~~~~~~~~~ + + import { tagName } from '@ember-decorators/component'; + + @tagName(\\"\\") + export default class FooComponent extends Component { + bar; + } + +~~~~~~~~~~ +
+ foo +
+==========" +`; + +exports[`native components handles \`@attribute\` correctly 1`] = ` +"========== + + import { attribute } from '@ember-decorators/component'; + + export default class FooComponent extends Component { + @attribute foo; + @attribute('baz') bar; + } + +~~~~~~~~~~ +foo +~~~~~~~~~~ + => tagName: div +~~~~~~~~~~ + + import { tagName } from '@ember-decorators/component'; + + @tagName(\\"\\") + export default class FooComponent extends Component { + foo; + bar; + } + +~~~~~~~~~~ +
+ foo +
+==========" +`; + +exports[`native components handles \`@attributeBindings\` correctly 1`] = ` +"========== + + import { attributeBindings } from '@ember-decorators/component'; + + @attributeBindings('foo', 'bar:baz') + export default class FooComponent extends Component { + } + +~~~~~~~~~~ +foo +~~~~~~~~~~ + => tagName: div +~~~~~~~~~~ + + import { tagName } from '@ember-decorators/component'; + + @tagName(\\"\\") + export default class FooComponent extends Component { + } + +~~~~~~~~~~ +
+ foo +
+==========" +`; + +exports[`native components handles \`@className\` correctly 1`] = ` +"========== + + import { className } from '@ember-decorators/component'; + + export default class FooComponent extends Component { + @className('b') a; + @className('y', 'z') x; + @className(undefined, 'bar') foo; + } + +~~~~~~~~~~ +foo +~~~~~~~~~~ + => tagName: div +~~~~~~~~~~ + + import { tagName } from '@ember-decorators/component'; + + @tagName(\\"\\") + export default class FooComponent extends Component { + a; + x; + foo; + } + +~~~~~~~~~~ +
+ foo +
+==========" +`; + +exports[`native components handles \`@classNameBindings\` correctly 1`] = ` +"========== + + import { classNameBindings } from '@ember-decorators/component'; + + @classNameBindings('a:b', 'x:y:z', 'foo::bar') + export default class FooComponent extends Component { + } + +~~~~~~~~~~ +foo +~~~~~~~~~~ + => tagName: div +~~~~~~~~~~ + + import { tagName } from '@ember-decorators/component'; + + @tagName(\\"\\") + export default class FooComponent extends Component { + } + +~~~~~~~~~~ +
+ foo +
+==========" +`; + +exports[`native components handles \`@classNames\` correctly 1`] = ` +"========== + + import { classNames } from '@ember-decorators/component'; + + @classNames('foo', 'bar:baz') + export default class FooComponent extends Component { + } + +~~~~~~~~~~ +foo +~~~~~~~~~~ + => tagName: div +~~~~~~~~~~ + + import { tagName } from '@ember-decorators/component'; + + @tagName(\\"\\") + export default class FooComponent extends Component { + } + +~~~~~~~~~~ +
+ foo +
+==========" +`; + +exports[`native components handles \`ariaRole\` correctly 1`] = ` +"========== + + export default class FooComponent extends Component { + ariaRole = 'button'; + } + +~~~~~~~~~~ +foo +~~~~~~~~~~ + => tagName: div +~~~~~~~~~~ + + import { tagName } from \\"@ember-decorators/component\\"; + @tagName(\\"\\") + export default class FooComponent extends Component {} + +~~~~~~~~~~ +
+ foo +
+==========" +`; + +exports[`native components handles \`elementId\` correctly 1`] = ` +"========== + + export default class FooComponent extends Component { + elementId = 'qux'; + } + +~~~~~~~~~~ +foo +~~~~~~~~~~ + => tagName: div +~~~~~~~~~~ + + import { tagName } from \\"@ember-decorators/component\\"; + @tagName(\\"\\") + export default class FooComponent extends Component {} + +~~~~~~~~~~ +
+ foo +
+==========" +`; + +exports[`native components handles \`hasComponentCSS\` option correctly 1`] = ` +"========== + + import { classNames } from '@ember-decorators/component'; + + @classNames('foo', 'bar:baz') + export default class extends Component { + } + +~~~~~~~~~~ +foo +~~~~~~~~~~ + => tagName: div +~~~~~~~~~~ + + import { tagName } from '@ember-decorators/component'; + + @tagName(\\"\\") + export default class extends Component { + } + +~~~~~~~~~~ +
+ foo +
+==========" +`; + +exports[`native components handles single \`@classNames\` item correctly 1`] = ` +"========== + + import { classNames } from '@ember-decorators/component'; + + @classNames('foo') + export default class FooComponent extends Component { + } + +~~~~~~~~~~ +foo +~~~~~~~~~~ + => tagName: div +~~~~~~~~~~ + + import { tagName } from '@ember-decorators/component'; + + @tagName(\\"\\") + export default class FooComponent extends Component { + } + +~~~~~~~~~~ +
+ foo +
+==========" +`; + +exports[`native components multi-line template 1`] = ` +"========== +export default class extends Component {}; +~~~~~~~~~~ +{{#if this.foo}} + FOO +{{else}} + BAR +{{/if}} +~~~~~~~~~~ + => tagName: div +~~~~~~~~~~ +import { tagName } from \\"@ember-decorators/component\\"; +@tagName(\\"\\") +export default class extends Component {} +~~~~~~~~~~ +
+ {{#if this.foo}} + FOO + {{else}} + BAR + {{/if}} +
+==========" +`; + +exports[`native components replaces existing \`tagName\` 1`] = ` +"========== + + import { tagName } from '@ember-decorators/component'; + + @tagName('span') + export default class FooComponent extends Component { + } + +~~~~~~~~~~ +foo +~~~~~~~~~~ + => tagName: span +~~~~~~~~~~ + + import { tagName } from '@ember-decorators/component'; + + @tagName(\\"\\") + export default class FooComponent extends Component { + } + +~~~~~~~~~~ + + foo + +==========" +`; diff --git a/lib/__tests__/find-properties.js b/lib/__tests__/find-properties.js index 77d646a..af43339 100644 --- a/lib/__tests__/find-properties.js +++ b/lib/__tests__/find-properties.js @@ -6,7 +6,7 @@ const { findAttributeBindings, findClassNames, findClassNameBindings, -} = require('../utils'); +} = require('../utils/classic'); describe('findTagName()', () => { const TESTS = [ diff --git a/lib/__tests__/transform.js b/lib/__tests__/transform.js index f528203..ea243a3 100644 --- a/lib/__tests__/transform.js +++ b/lib/__tests__/transform.js @@ -16,139 +16,140 @@ ${result.template} ==========`; } -test('basic', () => { +test('throws if supported component type is not found', () => { let source = ` + export default class extends Dummy { + } + `; + + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Unsupported component type."` + ); +}); + +describe('classic components', function() { + test('basic', () => { + let source = ` export default Component.extend({ }); `; - let template = `foo`; + let template = `foo`; - expect(generateSnapshot(source, template)).toMatchSnapshot(); -}); + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); -test('replaces existing `tagName`', () => { - let source = ` + test('replaces existing `tagName`', () => { + let source = ` export default Component.extend({ tagName: 'span', }); `; - let template = `foo`; + let template = `foo`; - expect(generateSnapshot(source, template)).toMatchSnapshot(); -}); + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); -test('handles `elementId` correctly', () => { - let source = ` + test('handles `elementId` correctly', () => { + let source = ` export default Component.extend({ elementId: 'qux', }); `; - let template = `foo`; + let template = `foo`; - expect(generateSnapshot(source, template)).toMatchSnapshot(); -}); + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); -test('handles `attributeBindings` correctly', () => { - let source = ` + test('handles `attributeBindings` correctly', () => { + let source = ` export default Component.extend({ attributeBindings: ['foo', 'bar:baz'], }); `; - let template = `foo`; + let template = `foo`; - expect(generateSnapshot(source, template)).toMatchSnapshot(); -}); + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); -test('handles `classNames` correctly', () => { - let source = ` + test('handles `classNames` correctly', () => { + let source = ` export default Component.extend({ classNames: ['foo', 'bar:baz'], }); `; - let template = `foo`; + let template = `foo`; - expect(generateSnapshot(source, template)).toMatchSnapshot(); -}); + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); -test('handles single `classNames` item correctly', () => { - let source = ` + test('handles single `classNames` item correctly', () => { + let source = ` export default Component.extend({ classNames: ['foo'], }); `; - let template = `foo`; + let template = `foo`; - expect(generateSnapshot(source, template)).toMatchSnapshot(); -}); + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); -test('handles `classNameBindings` correctly', () => { - let source = ` + test('handles `classNameBindings` correctly', () => { + let source = ` export default Component.extend({ classNameBindings: ['a:b', 'x:y:z', 'foo::bar'], }); `; - let template = `foo`; + let template = `foo`; - expect(generateSnapshot(source, template)).toMatchSnapshot(); -}); + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); -test('handles `ariaRole` correctly', () => { - let source = ` + test('handles `ariaRole` correctly', () => { + let source = ` export default Component.extend({ ariaRole: 'button', }); `; - let template = `foo`; - - expect(generateSnapshot(source, template)).toMatchSnapshot(); -}); - -test('throws if `Component.extend({ ... })` is not found', () => { - let source = ` - export default class extends Component { - } - `; + let template = `foo`; - expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( - `"Unsupported component type. Only classic components (\`Component.extend({ ... }\`) are supported currently."` - ); -}); + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); -test('throws if `Component.extend({ ... })` argument is not found', () => { - let source = ` + test('throws if `Component.extend({ ... })` argument is not found', () => { + let source = ` export default Component.extend(); `; - expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( - `"Could not find object argument in \`export default Component.extend({ ... });\`"` - ); -}); + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Could not find object argument in \`export default Component.extend({ ... });\`"` + ); + }); -test('skips tagless components', () => { - let source = ` + test('skips tagless components', () => { + let source = ` export default Component.extend({ tagName: '', }); `; - let template = 'foo'; + let template = 'foo'; - let result = transform(source, template); - expect(result.tagName).toEqual(undefined); - expect(result.source).toEqual(source); - expect(result.template).toEqual(template); -}); + let result = transform(source, template); + expect(result.tagName).toEqual(undefined); + expect(result.source).toEqual(source); + expect(result.template).toEqual(template); + }); -test('throws if component is using `this.element`', () => { - let source = ` + test('throws if component is using `this.element`', () => { + let source = ` export default Component.extend({ didInsertElement() { console.log(this.element); @@ -156,13 +157,13 @@ test('throws if component is using `this.element`', () => { }); `; - expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( - `"Using \`this.element\` is not supported in tagless components"` - ); -}); + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Using \`this.element\` is not supported in tagless components"` + ); + }); -test('throws if component is using `this.elementId`', () => { - let source = ` + test('throws if component is using `this.elementId`', () => { + let source = ` export default Component.extend({ didInsertElement() { console.log(this.elementId); @@ -170,13 +171,13 @@ test('throws if component is using `this.elementId`', () => { }); `; - expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( - `"Using \`this.elementId\` is not supported in tagless components"` - ); -}); + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Using \`this.elementId\` is not supported in tagless components"` + ); + }); -test('throws if component is using `keyDown()`', () => { - let source = ` + test('throws if component is using `keyDown()`', () => { + let source = ` export default Component.extend({ keyDown() { console.log('Hello World!'); @@ -184,13 +185,13 @@ test('throws if component is using `keyDown()`', () => { }); `; - expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( - `"Using \`keyDown()\` is not supported in tagless components"` - ); -}); + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Using \`keyDown()\` is not supported in tagless components"` + ); + }); -test('throws if component is using `click()`', () => { - let source = ` + test('throws if component is using `click()`', () => { + let source = ` export default Component.extend({ click() { console.log('Hello World!'); @@ -198,13 +199,13 @@ test('throws if component is using `click()`', () => { }); `; - expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( - `"Using \`click()\` is not supported in tagless components"` - ); -}); + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Using \`click()\` is not supported in tagless components"` + ); + }); -test('throws if component is using a computed property for `ariaRole`', () => { - let source = ` + test('throws if component is using a computed property for `ariaRole`', () => { + let source = ` export default Component.extend({ ariaRole: computed(function() { return 'button'; @@ -212,15 +213,15 @@ test('throws if component is using a computed property for `ariaRole`', () => { }); `; - expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( - `"Codemod does not support computed properties for \`ariaRole\`."` - ); -}); + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Codemod does not support computed properties for \`ariaRole\`."` + ); + }); -test('multi-line template', () => { - let source = `export default Component.extend({});`; + test('multi-line template', () => { + let source = `export default Component.extend({});`; - let template = ` + let template = ` {{#if this.foo}} FOO {{else}} @@ -228,17 +229,343 @@ test('multi-line template', () => { {{/if}} `.trim(); - expect(generateSnapshot(source, template)).toMatchSnapshot(); -}); + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); -test('handles `hasComponentCSS` option correctly', () => { - let source = ` + test('handles `hasComponentCSS` option correctly', () => { + let source = ` export default Component.extend({ classNames: ['foo', 'bar:baz'], }); `; - let template = `foo`; + let template = `foo`; + + expect(generateSnapshot(source, template, { hasComponentCSS: true })).toMatchSnapshot(); + }); +}); + +describe('native components', function() { + test('basic', () => { + let source = ` + export default class FooComponent extends Component { + } + `; + + let template = `foo`; + + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); + + test('replaces existing `tagName`', () => { + let source = ` + import { tagName } from '@ember-decorators/component'; + + @tagName('span') + export default class FooComponent extends Component { + } + `; + + let template = `foo`; + + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); + + test('handles `elementId` correctly', () => { + let source = ` + export default class FooComponent extends Component { + elementId = 'qux'; + } + `; + + let template = `foo`; + + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); + + test('handles `@attributeBindings` correctly', () => { + let source = ` + import { attributeBindings } from '@ember-decorators/component'; + + @attributeBindings('foo', 'bar:baz') + export default class FooComponent extends Component { + } + `; + + let template = `foo`; + + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); + + test('handles `@attribute` correctly', () => { + let source = ` + import { attribute } from '@ember-decorators/component'; + + export default class FooComponent extends Component { + @attribute foo; + @attribute('baz') bar; + } + `; + + let template = `foo`; + + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); + + test('handles `@attribute` and `@attributeBindings` correctly', () => { + let source = ` + import { attribute, attributeBindings } from '@ember-decorators/component'; + + @attributeBindings('foo') + export default class FooComponent extends Component { + @attribute('baz') bar; + } + `; + + let template = `foo`; + + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); + + test('handles `@className` correctly', () => { + let source = ` + import { className } from '@ember-decorators/component'; + + export default class FooComponent extends Component { + @className('b') a; + @className('y', 'z') x; + @className(undefined, 'bar') foo; + } + `; + + let template = `foo`; + + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); + + test('handles `ariaRole` correctly', () => { + let source = ` + export default class FooComponent extends Component { + ariaRole = 'button'; + } + `; + + let template = `foo`; + + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); + + test('throws if component is a non-string value for `ariaRole`', () => { + let source = ` + export default class FooComponent extends Component { + ariaRole = true; + } + `; + + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Codemod only supports static strings for \`ariaRole\`."` + ); + }); + + test('throws if component is using a native getter for `ariaRole`', () => { + let source = ` + export default class FooComponent extends Component { + get ariaRole() { + return 'button'; + } + } + `; + + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Codemod does not support dynamic values for \`ariaRole\` (e.g. getter or computed properties)."` + ); + }); + + test('throws if component is using a computed property for `ariaRole`', () => { + let source = ` + import { computed } from '@ember/object'; + + export default class FooComponent extends Component { + @computed() + get ariaRole() { + return 'button'; + } + } + `; + + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Codemod does not support dynamic values for \`ariaRole\` (e.g. getter or computed properties)."` + ); + }); + + test('throws for non-boolean @className', () => { + let source = ` + import { className } from '@ember-decorators/component'; + + export default class FooComponent extends Component { + @className activeClass = 'active'; + } + `; + + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Unsupported non-boolean \`@className\` for property: activeClass"` + ); + }); + + test('handles `@classNames` correctly', () => { + let source = ` + import { classNames } from '@ember-decorators/component'; + + @classNames('foo', 'bar:baz') + export default class FooComponent extends Component { + } + `; + + let template = `foo`; + + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); + + test('handles single `@classNames` item correctly', () => { + let source = ` + import { classNames } from '@ember-decorators/component'; + + @classNames('foo') + export default class FooComponent extends Component { + } + `; + + let template = `foo`; + + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); + + test('handles `@classNameBindings` correctly', () => { + let source = ` + import { classNameBindings } from '@ember-decorators/component'; + + @classNameBindings('a:b', 'x:y:z', 'foo::bar') + export default class FooComponent extends Component { + } + `; + + let template = `foo`; + + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); + + test('throws for non-boolean @classNameBindings', () => { + let source = ` + import { classNameBindings } from '@ember-decorators/component'; + + @classNameBindings('a:b', 'foo') + export default class FooComponent extends Component { + } + `; + + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Unsupported non-boolean \`@classNameBindings\` value: foo"` + ); + }); + + test('skips tagless components', () => { + let source = ` + import { tagName } from '@ember-decorators/component'; + + @tagName('') + export default class FooComponent extends Component { + } + `; + + let template = 'foo'; + + let result = transform(source, template); + expect(result.tagName).toEqual(undefined); + expect(result.source).toEqual(source); + expect(result.template).toEqual(template); + }); + + test('throws if component is using `this.element`', () => { + let source = ` + export default class FooComponent extends Component { + didInsertElement() { + console.log(this.element); + } + } + `; + + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Using \`this.element\` is not supported in tagless components"` + ); + }); + + test('throws if component is using `this.elementId`', () => { + let source = ` + export default class FooComponent extends Component { + didInsertElement() { + console.log(this.elementId); + } + } + `; + + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Using \`this.elementId\` is not supported in tagless components"` + ); + }); + + test('throws if component is using `keyDown()`', () => { + let source = ` + export default class FooComponent extends Component { + keyDown() { + console.log('Hello World!'); + } + } + `; + + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Using \`keyDown()\` is not supported in tagless components"` + ); + }); + + test('throws if component is using `click()`', () => { + let source = ` + export default class FooComponent extends Component { + click() { + console.log('Hello World!'); + } + } + `; + + expect(() => transform(source, '')).toThrowErrorMatchingInlineSnapshot( + `"Using \`click()\` is not supported in tagless components"` + ); + }); + + test('multi-line template', () => { + let source = `export default class extends Component {};`; + + let template = ` +{{#if this.foo}} + FOO +{{else}} + BAR +{{/if}} + `.trim(); + + expect(generateSnapshot(source, template)).toMatchSnapshot(); + }); + + test('handles `hasComponentCSS` option correctly', () => { + let source = ` + import { classNames } from '@ember-decorators/component'; + + @classNames('foo', 'bar:baz') + export default class extends Component { + } + `; + + let template = `foo`; - expect(generateSnapshot(source, template, { hasComponentCSS: true })).toMatchSnapshot(); + expect(generateSnapshot(source, template, { hasComponentCSS: true })).toMatchSnapshot(); + }); }); diff --git a/lib/transform.js b/lib/transform.js index c4ff5a7..41458e4 100644 --- a/lib/transform.js +++ b/lib/transform.js @@ -5,6 +5,7 @@ const _debug = require('debug')('tagless-ember-components-codemod'); const SilentError = require('./silent-error'); const transformClassicComponent = require('./transform/classic'); +const transformNativeComponent = require('./transform/native'); const transformTemplate = require('./transform/template'); function transformPath(componentPath, options) { @@ -31,20 +32,35 @@ function transformPath(componentPath, options) { function checkComponentType(root) { // find `export default Component.extend({ ... });` AST node - let exportDefaultDeclarations = root.find(j.ExportDefaultDeclaration, { - declaration: { - type: 'CallExpression', - callee: { - type: 'MemberExpression', - object: { name: 'Component' }, - property: { name: 'extend' }, + if ( + root.find(j.ExportDefaultDeclaration, { + declaration: { + type: 'CallExpression', + callee: { + type: 'MemberExpression', + object: { name: 'Component' }, + property: { name: 'extend' }, + }, }, - }, - }); - - if (exportDefaultDeclarations.length === 1) { + }).length === 1 + ) { return 'classic'; } + + // find `export default class FooComponent extends Component {}` AST node + if ( + root.find(j.ExportDefaultDeclaration, { + declaration: { + type: 'ClassDeclaration', + superClass: { + type: 'Identifier', + name: 'Component', + }, + }, + }).length === 1 + ) { + return 'native'; + } } function transform(source, template, options = {}) { @@ -54,10 +70,10 @@ function transform(source, template, options = {}) { if (type === 'classic') { result = transformClassicComponent(root, options); + } else if (type === 'native') { + result = transformNativeComponent(root, options); } else { - throw new SilentError( - `Unsupported component type. Only classic components (\`Component.extend({ ... }\`) are supported currently.` - ); + throw new SilentError(`Unsupported component type.`); } if (result) { diff --git a/lib/transform/classic.js b/lib/transform/classic.js index 9285c18..aea07e0 100644 --- a/lib/transform/classic.js +++ b/lib/transform/classic.js @@ -11,7 +11,7 @@ const { findAriaRole, isMethod, isProperty, -} = require('../utils'); +} = require('../utils/classic'); const EVENT_HANDLER_METHODS = [ // Touch events diff --git a/lib/transform/native.js b/lib/transform/native.js new file mode 100644 index 0000000..8081820 --- /dev/null +++ b/lib/transform/native.js @@ -0,0 +1,169 @@ +const j = require('jscodeshift').withParser('ts'); +const _debug = require('debug')('tagless-ember-components-codemod'); + +const SilentError = require('../silent-error'); +const { + addClassDecorator, + findTagName, + findElementId, + findClassNames, + findClassNameBindings, + findAttributeBindings, + findAriaRole, + isMethod, + removeDecorator, + ensureImport, + isProperty, +} = require('../utils/native'); + +const EVENT_HANDLER_METHODS = [ + // Touch events + 'touchStart', + 'touchMove', + 'touchEnd', + 'touchCancel', + + // Keyboard events + 'keyDown', + 'keyUp', + 'keyPress', + + // Mouse events + 'mouseDown', + 'mouseUp', + 'contextMenu', + 'click', + 'doubleClick', + 'focusIn', + 'focusOut', + + // Form events + 'submit', + 'change', + 'focusIn', + 'focusOut', + 'input', + + // Drag and drop events + 'dragStart', + 'drag', + 'dragEnter', + 'dragLeave', + 'dragOver', + 'dragEnd', + 'drop', +]; + +module.exports = function transformNativeComponent(root, options) { + let debug = options.debug || _debug; + + let exportDefaultDeclarations = root.find(j.ExportDefaultDeclaration); + + if (exportDefaultDeclarations.length !== 1) { + throw new SilentError( + `Could not find \`export default class SomeComponent extends Component { ... }\`` + ); + } + + let exportDefaultDeclaration = exportDefaultDeclarations.get(); + let classDeclaration = exportDefaultDeclaration.get('declaration'); + // find class body + let classBody = classDeclaration.get('body', 'body'); + + // find `tagName` property if it exists + let tagName = findTagName(classDeclaration); + + // skip tagless components (silent) + if (tagName === '') { + debug('tagName: %o -> skip', tagName); + return; + } + + debug('tagName: %o', tagName); + + // skip components that use `this.element` + let thisElementPaths = j(classBody).find(j.MemberExpression, { + object: { type: 'ThisExpression' }, + property: { name: 'element' }, + }); + if (thisElementPaths.length !== 0) { + throw new SilentError(`Using \`this.element\` is not supported in tagless components`); + } + + // skip components that use `this.elementId` + let thisElementIdPaths = j(classBody).find(j.MemberExpression, { + object: { type: 'ThisExpression' }, + property: { name: 'elementId' }, + }); + if (thisElementIdPaths.length !== 0) { + throw new SilentError(`Using \`this.elementId\` is not supported in tagless components`); + } + + // skip components that use `click()` etc. + for (let methodName of EVENT_HANDLER_METHODS) { + let handlerMethod = classBody.filter(path => isMethod(path, methodName))[0]; + if (handlerMethod) { + throw new SilentError(`Using \`${methodName}()\` is not supported in tagless components`); + } + } + + // analyze `elementId`, `attributeBindings`, `classNames` and `classNameBindings` + let elementId = findElementId(classBody); + debug('elementId: %o', elementId); + + let attributeBindings = findAttributeBindings(classDeclaration); + debug('attributeBindings: %o', attributeBindings); + + let classNames = findClassNames(classDeclaration); + debug('classNames: %o', classNames); + + let classNameBindings = findClassNameBindings(classDeclaration); + debug('classNameBindings: %o', classNameBindings); + + let ariaRole; + try { + ariaRole = findAriaRole(classBody); + } catch (error) { + throw new SilentError('Codemod only supports static strings for `ariaRole`.'); + } + debug('ariaRole: %o', ariaRole); + + if (!ariaRole && classBody.filter(path => isMethod(path, 'ariaRole'))[0]) { + throw new SilentError( + 'Codemod does not support dynamic values for `ariaRole` (e.g. getter or computed properties).' + ); + } + + // set `@tagName('')` + addClassDecorator(exportDefaultDeclaration, 'tagName', [j.stringLiteral('')]); + ensureImport(root, 'tagName', '@ember-decorators/component'); + + // remove `elementId`, `attributeBindings`, `classNames` and `classNameBindings` + j(classBody) + .find(j.ClassProperty) + // .filter(path => path.parentPath === properties) + .filter(path => isProperty(path, 'elementId') || isProperty(path, 'ariaRole')) + .remove(); + + removeDecorator(root, classDeclaration, 'attributeBindings', '@ember-decorators/component'); + removeDecorator(root, classDeclaration, 'classNames', '@ember-decorators/component'); + removeDecorator(root, classDeclaration, 'classNameBindings', '@ember-decorators/component'); + j(classBody) + .find(j.ClassProperty) + .forEach(path => removeDecorator(root, path, 'attribute', '@ember-decorators/component')); + j(classBody) + .find(j.ClassProperty) + .forEach(path => removeDecorator(root, path, 'className', '@ember-decorators/component')); + + let newSource = root.toSource(); + + return { + newSource, + tagName, + elementId, + classNames, + classNameBindings, + attributeBindings, + ariaRole, + }; +}; diff --git a/lib/transform/template.js b/lib/transform/template.js index 1eee2ce..3f17794 100644 --- a/lib/transform/template.js +++ b/lib/transform/template.js @@ -1,4 +1,4 @@ -const { indentLines } = require('../utils'); +const { indentLines } = require('../utils/template'); const templateRecast = require('ember-template-recast'); diff --git a/lib/utils.js b/lib/utils/classic.js similarity index 94% rename from lib/utils.js rename to lib/utils/classic.js index 7462017..dd55803 100644 --- a/lib/utils.js +++ b/lib/utils/classic.js @@ -1,6 +1,6 @@ const j = require('jscodeshift').withParser('ts'); -const SilentError = require('./silent-error'); +const SilentError = require('../silent-error'); function isProperty(path, name) { let node = path.value; @@ -91,13 +91,6 @@ function findClassNameBindings(properties) { return classNameBindings; } -function indentLines(content) { - return content - .split('\n') - .map(it => ` ${it}`) - .join('\n'); -} - module.exports = { isProperty, isMethod, @@ -109,5 +102,4 @@ module.exports = { findClassNameBindings, findElementId, findTagName, - indentLines, }; diff --git a/lib/utils/native.js b/lib/utils/native.js new file mode 100644 index 0000000..9d23278 --- /dev/null +++ b/lib/utils/native.js @@ -0,0 +1,278 @@ +const j = require('jscodeshift').withParser('ts'); + +const SilentError = require('../silent-error'); + +function addClassDecorator(classDeclaration, name, args) { + let existing = findDecorator(classDeclaration, name); + + if (existing) { + existing.value.expression.arguments = args; + } else { + if (classDeclaration.value.decorators === undefined) { + classDeclaration.value.decorators = []; + } + classDeclaration.value.decorators.unshift( + j.decorator(j.callExpression(j.identifier(name), args)) + ); + } +} + +function isDecorator(path, name, withArgs) { + let node = path.value; + let isCall = + node.expression.type === 'CallExpression' && node.expression.callee.type === 'Identifier'; + + return ( + node.type === 'Decorator' && + ((isCall && node.expression.callee.name === name) || + (!isCall && node.expression.name === name)) && + (withArgs === undefined || (withArgs === true && isCall) || (withArgs === false && !isCall)) + ); +} + +function isProperty(path, name) { + let node = path.value; + return node.type === 'ClassProperty' && node.key.type === 'Identifier' && node.key.name === name; +} + +function isMethod(path, name) { + let node = path.value; + return node.type === 'ClassMethod' && node.key.type === 'Identifier' && node.key.name === name; +} + +function findStringProperty(properties, name, defaultValue = null) { + let propertyPath = properties.filter(path => isProperty(path, name))[0]; + if (!propertyPath) { + return defaultValue; + } + + let valuePath = propertyPath.get('value'); + if (valuePath.value.type !== 'StringLiteral') { + throw new SilentError(`Unexpected \`${name}\` value: ${j(valuePath).toSource()}`); + } + + return valuePath.value.value; +} + +function findDecorator(path, name, withArgs) { + let decorators = path.get('decorators'); + if (decorators.value === undefined) { + return; + } + + let existing = decorators.filter(path => isDecorator(path, name, withArgs)); + + if (existing.length > 0) { + return existing[0]; + } +} + +function findStringDecorator(path, name) { + let decorator = findDecorator(path, name, true); + + if (!decorator) { + return; + } + + return decorator.value.expression.arguments[0].value; +} + +function findTagName(path) { + let value = findStringDecorator(path, 'tagName'); + return value !== undefined ? value : 'div'; +} + +function findElementId(path) { + return findStringProperty(path, 'elementId'); +} + +function findAriaRole(path) { + return findStringProperty(path, 'ariaRole'); +} + +function findStringArrayDecorator(path, name) { + let decorator = findDecorator(path, name, true); + if (!decorator) { + return []; + } + + let args = decorator.get('expression').value.arguments; + + return args.map(element => { + if (element.type !== 'StringLiteral') { + throw new SilentError(`Unexpected \`${name}\` value: ${j(args).toSource()}`); + } + + return element.value; + }); +} + +function findAttributeBindings(classDeclaration) { + let attrBindings = new Map(); + for (let binding of findStringArrayDecorator(classDeclaration, 'attributeBindings')) { + let [value, attr] = binding.split(':'); + attrBindings.set(attr || value, value); + } + + j(classDeclaration) + .find(j.ClassProperty) + .forEach(path => { + let value = path.node.key.name; + let key = findStringDecorator(path, 'attribute'); + if (key === undefined) { + let decorator = findDecorator(path, 'attribute', false); + if (decorator) { + key = value; + } + } + if (key !== undefined) { + attrBindings.set(key, value); + } + }); + + return attrBindings; +} + +function findClassNames(classDeclaration) { + return findStringArrayDecorator(classDeclaration, 'classNames'); +} + +function findClassNameBindings(classDeclaration) { + let classNameBindings = new Map(); + for (let binding of findStringArrayDecorator(classDeclaration, 'classNameBindings')) { + let parts = binding.split(':'); + + if (parts.length === 1) { + throw new SilentError(`Unsupported non-boolean \`@classNameBindings\` value: ${binding}`); + } else if (parts.length === 2) { + classNameBindings.set(parts[0], [parts[1], null]); + } else if (parts.length === 3) { + classNameBindings.set(parts[0], [parts[1] || null, parts[2]]); + } else { + throw new SilentError(`Unexpected \`@classNameBindings\` value: ${binding}`); + } + } + + j(classDeclaration) + .find(j.ClassProperty) + .forEach(path => { + let key = path.node.key.name; + + if (findDecorator(path, 'className', false)) { + throw new SilentError(`Unsupported non-boolean \`@className\` for property: ${key}`); + } + let decorator = findDecorator(path, 'className', true); + if (!decorator) { + return; + } + + let args = decorator.get('expression').value.arguments; + let [truthy, falsy] = args.map(element => element.value); + classNameBindings.set(key, [truthy || null, falsy || null]); + }); + + return classNameBindings; +} + +function removeDecorator(root, classDeclaration, name, source) { + let decorator = findDecorator(classDeclaration, name); + if (decorator) { + j(decorator).remove(); + removeImport(root, name, source); + } +} + +function ensureImport(root, name, source) { + let body = root.get().value.program.body; + + let declaration = root.find(j.ImportDeclaration, { + source: { value: source }, + }); + + if (declaration.length) { + if ( + declaration.find(j.ImportSpecifier, { + imported: { + name, + }, + }).length === 0 + ) { + declaration.get('specifiers').push(j.importSpecifier(j.identifier(name))); + } + } else { + let importStatement = createImportStatement(source, null, [name]); + body.unshift(importStatement); + body[0].comments = body[1].comments; + delete body[1].comments; + } +} + +function removeImport(root, name, source) { + let declaration = root.find(j.ImportDeclaration, { + source: { value: source }, + }); + + if (declaration.length) { + declaration + .find(j.ImportSpecifier, { + imported: { + name, + }, + }) + .remove(); + + if (declaration.get().value.specifiers.length === 0) { + declaration.remove(); + } + } +} + +// shamelessly taken from https://github.com/ember-codemods/ember-modules-codemod/blob/3afaab6b77fcb494873e2667f6e1bb14362f3845/transform.js#L607 +function createImportStatement(source, imported, local) { + let declaration, variable, idIdentifier, nameIdentifier; + + // if no variable name, return `import 'jquery'` + if (!local) { + declaration = j.importDeclaration([], j.literal(source)); + return declaration; + } + + // multiple variable names indicates a destructured import + if (Array.isArray(local)) { + let variableIds = local.map(function(v) { + return j.importSpecifier(j.identifier(v), j.identifier(v)); + }); + + declaration = j.importDeclaration(variableIds, j.literal(source)); + } else { + // else returns `import $ from 'jquery'` + nameIdentifier = j.identifier(local); //import var name + variable = j.importDefaultSpecifier(nameIdentifier); + + // if propName, use destructuring `import {pluck} from 'underscore'` + if (imported && imported !== 'default') { + idIdentifier = j.identifier(imported); + variable = j.importSpecifier(idIdentifier, nameIdentifier); // if both are same, one is dropped... + } + + declaration = j.importDeclaration([variable], j.literal(source)); + } + + return declaration; +} + +module.exports = { + addClassDecorator, + isProperty, + isMethod, + findAriaRole, + findAttributeBindings, + findClassNames, + findClassNameBindings, + findElementId, + findTagName, + findDecorator, + removeDecorator, + ensureImport, + removeImport, +}; diff --git a/lib/utils/template.js b/lib/utils/template.js new file mode 100644 index 0000000..3db79ef --- /dev/null +++ b/lib/utils/template.js @@ -0,0 +1,10 @@ +function indentLines(content) { + return content + .split('\n') + .map(it => ` ${it}`) + .join('\n'); +} + +module.exports = { + indentLines, +};