Skip to content
This repository has been archived by the owner on Sep 9, 2022. It is now read-only.

WIP: TypeScript #5

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const name: string;

export default class Modifier {
element: HTMLElement | SVGElement;
didInsertElement(positionalArgs?: Array<any>, namedArgs?: { [key: string]: any; }): void;
didRecieveArguments(positionalArgs?: Array<any>, namedArgs?: { [key: string]: any; }): void;
didUpdateArguments(positionalArgs?: Array<any>, namedArgs?: { [key: string]: any; }): void;
willDestroyElement(positionalArgs?: Array<any>, namedArgs?: { [key: string]: any; }): void;

static modifier(klass: typeof Modifier): typeof Modifier;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, there have been some changes on master. The typing is now:

export interface Args {
  positional: any[];
  named: { [key: string]: any };
}

export default class Modifier {
  args: Args;
  element: Element | null;
  isDestroying: boolean;
  isDestroyed: boolean;
  constructor(owner: Owner, args: Args);
  didRecieveArguments(): void;
  didUpdateArguments(): void;
  didInstall(): void;
  willRemove(): void;
  willDestroy(): void;
}

There is also the classic API too, but I have no idea what's the best practice for typing things that inherit from Ember.Object. Maybe @chriskrycho will know.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’ll take a look and make a recommendation that way tomorrow!

Copy link

@chriskrycho chriskrycho Sep 20, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The types should be a single index.d.ts file with the following definitions:

interface ModifierArgs {
  positional: unknown[];
  named: { [key: string]: unknown };
}

interface IModifier<Args extends ModifierArgs = ModifierArgs> {
  args: Args;
  element: Element | null;
  isDestroying: boolean;
  isDestroyed: boolean;
  didReceiveArguments(): void;
  didUpdateArguments(): void;
  didInstall(): void;
  willRemove(): void;
  willDestroy(): void;
}

type Owner = unknown;

declare module "ember-class-based-modifier" {
  export default class Modifier<Args extends ModifierArgs = ModifierArgs>
    implements IModifier<Args> {
    args: Args;
    element: Element | null;
    isDestroying: boolean;
    isDestroyed: boolean;
    constructor(owner: Owner, args: Args);
    didReceiveArguments(): void;
    didUpdateArguments(): void;
    didInstall(): void;
    willRemove(): void;
    willDestroy(): void;
  }
}

declare module "ember-class-based-modifier/classic" {
  import EmberObject from "@ember/object";

  export default class Modifier extends EmberObject implements IModifier {
    args: ModifierArgs;
    element: Element | null;
    isDestroying: boolean;
    isDestroyed: boolean;
    didReceiveArguments(): void;
    didUpdateArguments(): void;
    didInstall(): void;
    willRemove(): void;
    willDestroy(): void;
  }
}

There are a number of important things to note here.

First, I've replaced any with unknown. This means that TS consumers will need to do a runtime check to narrow the types from "anything" to "the actual type". (There are some features landing in TS 3.7 which will make Ember's debug-only assert work perfectly for this, so keep your eyes open for that!) This is roughly what you have to do in a well-behaved modifier anyway, so it's not an extra burden, just helpful guidance from the type system.

Second, this sets up the Args definition with a generic. This is so that the user can extend it with their own args in a way analogous to how the types for @glimmer/component work. Users of the modern, class-based API can write something like this, and it'll just work (with the caveat that they'll have to do exactly those runtime checks required by using unknown above):

import Modifier from 'ember-class-based-modifier';

export default class MyModifier extends Modifier {
  // ...
}

However, they'll also be able to do this, supplying a narrower type:

import Modifier from 'ember-class-based-modifier';

interface MyArgs {
  positional: [number, boolean],
  named: {
    first: string;
    second: object;
  }
}

export default class MyModifier extends Modifier<MyArgs> {
  // ...
}

Importantly, that will not type-check if the interface passed into the generic is not compatible with the required ModifierArgs type. (Note here that TS users will likely want to use debug assert on those arg types in the constructor until we have a solution in place for type-checking template invocation, which is unlikely to happen this calendar year.)

Third, that second point is only applicable to native classes. We don't have a way to make TypeScript's type system support that kind of use of generics with classic classes in a straightforward way, and we're not going to invest the time to try to work around it since the community is actively moving toward native classes—and classic classes have basically been non-starters for TS consumers for a long time anyway, for precisely this reason. Having the API in place as we do here is good, and this level of support matches the second-class

Fourth, and this is the most important, we don't have a way to test this at present. As such, please request a review from me or one of the other Typed Ember contributors on changes to this file. I'll be putting together guidance over the next few months about how to use dtslint or tsd (or the two in combination) to provide tests for the exported types, which should help with this!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One addendum: that type Owner = unknown can be replaced with an import if we have a public API import for Owner; I just don't know where that exists if so.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @chriskrycho

I'll update this branch with your recommendations.

I will also add your examples to the README.

16 changes: 14 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,25 @@
"start": "ember serve",
"test": "ember test",
"test:all": "ember try:each",
"release": "release-it"
"release": "release-it",
"prepublishOnly": "ember ts:precompile",
"postpublish": "ember ts:clean"
},
"dependencies": {
"@ember-decorators/babel-transforms": "^3.1.5",
"ember-cli-babel": "^7.1.2",
"ember-cli-typescript": "^2.0.0",
"ember-modifier-manager-polyfill": "^1.0.3"
},
"devDependencies": {
"@ember/optional-features": "^0.6.3",
"@types/ember": "^3.1.0",
"@types/ember-qunit": "^3.4.6",
"@types/ember-test-helpers": "^1.0.5",
"@types/ember-testing-helpers": "^0.0.3",
"@types/ember__test-helpers": "^0.7.8",
"@types/qunit": "^2.5.4",
"@types/rsvp": "^4.0.2",
"babel-eslint": "^8.2.6",
"broccoli-asset-rev": "^2.7.0",
"decorators": "0.0.1",
Expand All @@ -46,6 +56,7 @@
"ember-cli-inject-live-reload": "^1.8.2",
"ember-cli-sri": "^2.1.1",
"ember-cli-template-lint": "^1.0.0-beta.1",
"ember-cli-typescript-blueprints": "^2.0.0",
"ember-cli-uglify": "^2.1.0",
"ember-decorators": "^5.1.3",
"ember-disable-prototype-extensions": "^1.1.3",
Expand All @@ -61,7 +72,8 @@
"eslint-plugin-node": "^7.0.1",
"loader.js": "^4.7.0",
"qunit-dom": "^0.8.0",
"release-it": "^10.2.0"
"release-it": "^10.2.0",
"typescript": "^3.4.1"
},
"engines": {
"node": "6.* || 8.* || >= 10.*"
Expand Down
16 changes: 16 additions & 0 deletions tests/dummy/app/config/environment.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export default config;

/**
* Type declarations for
* import config from './config/environment'
*
* For now these need to be managed by the developer
* since different ember addons can materialize new entries.
*/
declare const config: {
environment: any;
modulePrefix: string;
podModulePrefix: string;
locationType: string;
rootURL: string;
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { inject as service } from '@ember-decorators/service';
import hbs from 'htmlbars-inline-precompile';
import Modifier from 'ember-oo-modifiers';

module('Integration | Modifier Manager | oo modifier (native)', function(hooks) {
module('Integration | Modifier Manager | oo modifier (native) (JS)', function(hooks) {
setupRenderingTest(hooks);

hooks.beforeEach(function() {
Expand Down
246 changes: 246 additions & 0 deletions tests/integration/modifier-managers/oo-modifiers-native-ts-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, settled } from '@ember/test-helpers';
import Service from '@ember/service';
import { inject as service } from '@ember-decorators/service';
import hbs from 'htmlbars-inline-precompile';
import Modifier from 'ember-oo-modifiers';

module('Integration | Modifier Manager | oo modifier (native) (TS)', function(hooks) {
setupRenderingTest(hooks);

hooks.beforeEach(function() {
this.registerModifier = (name, modifier) => {
this.owner.register(`modifier:${name}`, modifier);
};
this.registerModifierClass = (name: string, ModifierClass: typeof Modifier) => {
this.registerModifier(name, Modifier.modifier(ModifierClass));
};
});

module('didInsertElement', function() {
test('it has DOM element on this.element', async function(assert) {
this.registerModifierClass(
'songbird',
class SongbirdModifier extends Modifier {
didInsertElement() { assert.equal(this.element.tagName, 'H1'); }
}
);
await render(hbs`<h1 {{songbird}}>Hello</h1>`);
});

test('positional arguments are passed', async function(assert) {
this.registerModifierClass(
'songbird',
class SongbirdModifier extends Modifier {
didInsertElement([a, b]: [string, string]) {
assert.equal(a, '1');
assert.equal(b, '2');
}
}
);
await render(hbs`<h1 {{songbird "1" "2"}}>Hey</h1>`);
});

test('named arguments are passed', async function(assert) {
this.registerModifierClass(
'songbird',
class SongbirdModifier extends Modifier {
didInsertElement(_: any, { a, b }: { [key: string]: string }) {
assert.equal(a, '1');
assert.equal(b, '2');
}
}
);
await render(hbs`<h1 {{songbird a="1" b="2"}}>Hey</h1>`);
});
});

module('didRecieveArguments', function() {
test('it has DOM element on this.element', async function(assert) {
this.registerModifierClass(
'songbird',
class SongbirdModifier extends Modifier {
didReceiveArguments() { assert.equal(this.element.tagName, 'H1'); }
}
);
await render(hbs`<h1 {{songbird}}>Hello</h1>`);
});

test('positional arguments are passed', async function(assert) {
this.registerModifierClass(
'songbird',
class SongbirdModifier extends Modifier {
didReceiveArguments([a, b]: [string, string]) {
assert.equal(a, '1');
assert.equal(b, '2');
}
}
);
await render(hbs`<h1 {{songbird "1" "2"}}>Hey</h1>`);
});

test('named arguments are passed', async function(assert) {
this.registerModifierClass(
'songbird',
class SongbirdModifier extends Modifier {
didReceiveArguments(_: any, { a, b }: { [key: string]: string }) {
assert.equal(a, '1');
assert.equal(b, '2');
}
}
);
await render(hbs`<h1 {{songbird a="1" b="2"}}>Hey</h1>`);
});
});

module('didUpdateArguments', function() {
test('it has DOM element on this.element', async function(assert) {
this.value = 0;
this.registerModifierClass(
'songbird',
class SongbirdModifier extends Modifier {
didUpdateArguments() { assert.equal(this.element.tagName, 'H1'); }
}
);
await render(hbs`<h1 {{songbird this.value}}>Hello</h1>`);
this.set('value', 1);
});

test('positional arguments are passed', async function(assert) {
this.value = 0;
this.registerModifierClass(
'songbird',
class SongbirdModifier extends Modifier {
didUpdateArguments([, a, b]: [any, string, string]) {
assert.equal(a, '1');
assert.equal(b, '2');
}
}
);
await render(hbs`<h1 {{songbird this.value "1" "2"}}>Hey</h1>`);
this.set('value', 1);
});

test('named arguments are passed', async function(assert) {
this.value = 0;
this.registerModifierClass(
'songbird',
class SongbirdModifier extends Modifier {
didUpdateArguments(_: any, { a, b }: { [key: string]: string }) {
assert.equal(a, '1');
assert.equal(b, '2');
}
}
);
await render(hbs`<h1 {{songbird this.value a="1" b="2"}}>Hey</h1>`);
this.set('value', 1);
});
});

module('willDestroyElement', function() {
test('it has DOM element on this.element', async function(assert) {
this.shouldRender = true;
this.registerModifierClass(
'songbird',
class SongbirdModifier extends Modifier {
willDestroyElement() { assert.equal(this.element.tagName, 'H1'); }
}
);
await render(hbs`
{{#if this.shouldRender}}
<h1 {{songbird}}>Hello</h1>
{{/if}}
`);
this.set('shouldRender', false);
});

test('positional arguments are passed', async function(assert) {
this.shouldRender = true;
this.registerModifierClass(
'songbird',
class SongbirdModifier extends Modifier {
willDestroyElement([a, b]: [string, string]) {
assert.equal(a, '1');
assert.equal(b, '2');
}
}
);
await render(hbs`
{{#if this.shouldRender}}
<h1 {{songbird "1" "2"}}>Hey</h1>
{{/if}}
`);
this.set('shouldRender', false);
});

test('named arguments are passed', async function(assert) {
this.shouldRender = true;
this.registerModifierClass(
'songbird',
class SongbirdModifier extends Modifier {
willDestroyElement(_: any, { a, b }: { [key: string]: string }) {
assert.equal(a, '1');
assert.equal(b, '2');
}
}
);
await render(hbs`
{{#if this.shouldRender}}
<h1 {{songbird a="1" b="2"}}>Hey</h1>
{{/if}}
`);
this.set('shouldRender', false);
});
});

test('has correct lifecycle hooks ordering', async function(assert) {
let callstack = [];
this.value = 0;
this.shouldRender = true;
this.registerModifierClass(
'songbird',
class SongbirdModifier extends Modifier {
didInsertElement() { callstack.push('didInsertElement'); }
didReceiveArguments() { callstack.push('didReceiveArguments'); }
didUpdateArguments() { callstack.push('didUpdateArguments'); }
willDestroyElement() { callstack.push('willDestroyElement'); }
}
);
await render(hbs`
{{#if this.shouldRender}}
<h1 {{songbird this.value}}>Hey</h1>
{{/if}}
`);
this.set('value', 1);
await settled();
this.set('shouldRender', false);
await settled();
assert.deepEqual(callstack, [
'didInsertElement',
'didReceiveArguments',
'didReceiveArguments',
'didUpdateArguments',
'willDestroyElement'
]);
});

test('can participate in ember dependency injection', async function(assert) {
this.owner.register(
'service:test-service',
class TestService extends Service {
value = 'test-service-value'
}
);
this.registerModifierClass(
'songbird',
class SongbirdModifier extends Modifier {
@service testService
didInsertElement() {
assert.equal(this.testService.value, 'test-service-value');
}
}
);
await render(hbs`<h1 {{songbird}}>Hello</h1>`);
});
});
55 changes: 55 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"compilerOptions": {
"target": "es2017",
"allowJs": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"noImplicitAny": true,
"noImplicitThis": true,
"alwaysStrict": true,
"strictNullChecks": true,
"strictPropertyInitialization": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noEmitOnError": false,
"noEmit": true,
"inlineSourceMap": true,
"inlineSources": true,
"baseUrl": ".",
"module": "es6",
"paths": {
"dummy/tests/*": [
"tests/*"
],
"dummy/*": [
"tests/dummy/app/*",
"app/*"
],
"ember-oo-modifiers": [
"addon"
],
"ember-oo-modifiers/*": [
"addon/*"
],
"ember-oo-modifiers/test-support": [
"addon-test-support"
],
"ember-oo-modifiers/test-support/*": [
"addon-test-support/*"
],
"*": [
"types/*"
]
}
},
"include": [
"app/**/*",
"addon/**/*",
"tests/**/*",
"types/**/*",
"test-support/**/*",
"addon-test-support/**/*"
]
}
1 change: 1 addition & 0 deletions types/dummy/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Loading