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,
+};