diff --git a/addon/ember-concurrency.d.ts b/addon/ember-concurrency.d.ts new file mode 100644 index 0000000..0bde98f --- /dev/null +++ b/addon/ember-concurrency.d.ts @@ -0,0 +1,440 @@ +declare module 'ember-concurrency' { + import EmberObject from '@ember/object'; + import { + UnwrapComputedPropertyGetter, + UnwrapComputedPropertyGetters + } from '@ember/object/-private/types'; + + import RSVP from 'rsvp'; + + // Lifted from @types/ember__object/observable.d.ts + interface Getter { + /** + * Retrieves the value of a property from the object. + */ + get(key: K): UnwrapComputedPropertyGetter; + /** + * To get the values of multiple properties at once, call `getProperties` + * with a list of strings or an array: + */ + getProperties( + list: K[] + ): Pick, K>; + getProperties( + ...list: K[] + ): Pick, K>; + } + + export type GeneratorFn = ( + this: T, + ...args: Args + ) => IterableIterator; + + export const all: typeof Promise.all; + export const allSettled: typeof RSVP.allSettled; + export const hash: typeof RSVP.hash; + export const race: typeof Promise.race; + + export function timeout(ms: number): Promise; + + /** + * Use `waitForEvent` to pause the task until an event is fired. + * The event can either be a jQuery event or an `Ember.Evented` event, or any + * event system where the object supports `.on()`, `.one()` and `.off()`. + */ + export function waitForEvent( + object: EmberObject | EventTarget, + eventName: string + ): Promise; + + /** + * Use `waitForProperty` to pause the task until a property on an object + * changes to some expected value. + * + * This can be used for a variety of use cases, including synchronizing with + * another task by waiting for it to become idle, or change state in some + * other way. + * + * If you omit the callback, `waitForProperty` will resume execution when the + * observed property becomes truthy. If you provide a callback, it'll be + * called immediately with the observed property's current value, and multiple + * times thereafter whenever the property changes, until you return a truthy + * value from the callback, or the current task is canceled. + * + * You can also pass in a non-`function` value in place of the callback, in + * which case the task will continue executing when the property's value + * becomes the value that you passed in. + */ + export function waitForProperty( + object: T, + key: K, + callbackOrValue?: (value: T[K]) => boolean | any + ): Promise; + + /** + * Use `waitForQueue` to pause the task until a certain run loop queue is + * reached. + */ + export function waitForQueue(queueName: string): Promise; + + export function task( + taskFn: GeneratorFn + ): Task>>; + export function task(encapsulatedTask: { + perform: GeneratorFn; + }): Task>>; + + export function taskGroup(): TaskGroupProperty; + + interface CommonTaskProperty { + restartable: () => TaskProperty; + drop: () => TaskProperty; + keepLatest: () => TaskProperty; + enqueue: () => TaskProperty; + maxConcurrency: (n: number) => TaskProperty; + cancelOn: (eventName: string) => TaskProperty; + group: (groupName: string) => TaskProperty; + } + + export interface TaskProperty extends CommonTaskProperty { + evented: () => TaskProperty; + debug: () => TaskProperty; + on: (eventName: string) => TaskProperty; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-interface + export interface TaskGroupProperty extends CommonTaskProperty {} + + // Based on https://github.com/CenterForOpenScience/ember-osf-web/blob/7933316efae805e00723789809bdeb58a96a286a/types/ember-concurrency/index.d.ts + + /** + * Describes the state that the task instance is in. + * Can be used for debugging, or potentially driving some UI state. + */ + export enum TaskInstanceState { + /** + * Task instance was canceled before it started. + */ + Dropped = 'dropped', + + /** + * Task instance was canceled before it could finish. + */ + Canceled = 'canceled', + + /** + * Task instance ran to completion, even if an exception was thrown. + */ + Finished = 'finished', + + /** + * Task instance is currently running. Returns `true`, even if it is paused + * on a yielded promise. + */ + Running = 'running', + + /** + * Task instance hasn't begun running yet. Usually because the task is using + * the `.enqueue()` task modifier. + */ + Waiting = 'waiting' + } + + /** + * A `TaskInstance` represents a single execution of a `Task`. Every call to + * `Task#perform` returns a `TaskInstance`. + * + * `TaskInstance`s are cancelable, either explicitly via `TaskInstance#cancel` + * or `Task#cancelAll`, or automatically due to the host object being + * destroyed, or because concurrency policy enforced by a Task Modifier + * canceled the task instance. + */ + interface TaskInstanceBase extends PromiseLike, Getter { + /** + * Describes the state that the task instance is in. + * Can be used for debugging, or potentially driving some UI state. + */ + // readonly state: TaskInstanceState; + + /** + * `true` if the task instance has started, else `false`. + */ + readonly hasStarted: boolean; + + /** + * `true` if the task instance was canceled before it could run to + * completion. + */ + readonly isCanceled: boolean; + + /** + * `true` if the `TaskInstance` was canceled before it could ever start + * running. + * + * For example, calling `.perform()` twice on a task with the `.drop()` + * modifier applied will result in the second task instance being dropped. + */ + readonly isDropped: boolean; + + /** + * `true` if the task has run to completion. + */ + readonly isFinished: boolean; + + /** + * `true` if the task is still running. + */ + readonly isRunning: boolean; + + /** + * `true` if the task instance is fulfilled. + */ + readonly isSuccessful: boolean; + + /** + * If this `TaskInstance` runs to completion by returning a value other than + * a rejecting promise, this property will be set with that value. + */ + readonly value?: T; + + /** + * If this `TaskInstance` is canceled or throws an error (or yields a + * promise that rejects), this property will be set with that error. + * + * Otherwise, it is `null`. + */ + readonly error?: Error; + + /** + * Cancels the task instance. Has no effect if the task instance has already + * been canceled or has already finished running. + */ + cancel(): void; + + catch(): RSVP.Promise; + finally(): RSVP.Promise; + then( + onfulfilled?: + | ((value: T) => TResult1 | RSVP.Promise) + | undefined + | null, + onrejected?: + | ((reason: any) => TResult2 | PromiseLike) + | undefined + | null + ): RSVP.Promise; + } + + /** + * Task instance was canceled before it started. + */ + interface InstanceDropped { + readonly state: TaskInstanceState.Dropped; + readonly hasStarted: false; + readonly isCanceled: true; + readonly isDropped: true; + readonly isFinished: false; + readonly isRunning: false; + readonly isSuccessful: false; + readonly value: undefined; + readonly error: Error; + } + + /** + * Task instance was canceled before it could finish. + */ + interface InstanceCanceled { + readonly state: TaskInstanceState.Canceled; + readonly hasStarted: true; + readonly isCanceled: true; + readonly isDropped: false; + readonly isFinished: false; + readonly isRunning: false; + readonly isSuccessful: false; + readonly value: undefined; + readonly error: Error; + } + + /** + * Task instance is currently running, even if it is paused on a yielded + * promise. + */ + interface InstanceRunning { + readonly state: TaskInstanceState.Running; + readonly hasStarted: true; + readonly isCanceled: false; + readonly isDropped: false; + readonly isFinished: false; + readonly isRunning: true; + readonly isSuccessful: false; + readonly value: undefined; + readonly error: null; + } + + /** + * Task instance hasn't begun running yet. Usually because the task is using + * the `.enqueue()` task modifier. + */ + interface InstanceWaiting { + readonly state: TaskInstanceState.Waiting; + readonly hasStarted: false; + readonly isCanceled: false; + readonly isDropped: false; + readonly isFinished: false; + readonly isRunning: false; + readonly isSuccessful: false; + readonly value: undefined; + readonly error: null; + } + + /** + * Task instance ran to completion, but an exception was thrown. + */ + interface InstanceError { + readonly state: TaskInstanceState.Finished; + readonly hasStarted: true; + readonly isCanceled: false; + readonly isDropped: false; + readonly isFinished: true; + readonly isRunning: false; + readonly isSuccessful: false; + readonly value: undefined; + readonly error: Error; + } + + /** + * Task instance ran to completion successfully, without any exception being + * thrown. + */ + interface InstanceSuccess { + readonly state: TaskInstanceState.Finished; + readonly hasStarted: true; + readonly isCanceled: false; + readonly isDropped: false; + readonly isFinished: true; + readonly isRunning: false; + readonly isSuccessful: true; + readonly value: any; + readonly error: null; + } + + export type TaskInstance = TaskInstanceBase & + ( + | InstanceDropped + | InstanceCanceled + | InstanceRunning + | InstanceWaiting + | InstanceError + | InstanceSuccess); + + /** + * The current state of the task. + */ + export enum TaskState { + Running = 'running', + Queued = 'queued', + Idle = 'idle' + } + + /** + * The `Task` object lives on a host Ember object (e.g. a `Component`, + * `Route`, or `Controller`). + * + * You call the `.perform()` method on this object to run individual + * `TaskInstances`, and at any point, you can call the `.cancelAll()` method + * on this object to cancel all running or enqueued `TaskInstance`s. + */ + export interface Task extends Getter { + /** + * `true` if the task is not in the `running` or `queued` state. + */ + readonly isIdle: boolean; + + /** + * `true` if any future task instances are queued. + */ + readonly isQueued: boolean; + + /** + * `true` if any current task instances are running. + */ + readonly isRunning: boolean; + + /** + * The most recently started task instance. + */ + readonly last?: TaskInstance; + + /** + * The most recently canceled task instance. + */ + readonly lastCanceled?: TaskInstance; + + /** + * The most recently completed task instance. + * + * This can be a successful task instance or a task instance that threw an + * error, but was *not* canceled. + */ + readonly lastComplete?: TaskInstance; + + /** + * The most recent task instance that errored. + */ + readonly lastErrored?: TaskInstance; + + /** + * The most recent task instance that is incomplete. + * + * @TODO: difference between `last`, `lastPerformed`, `lastIncomplete`? + */ + readonly lastIncomplete?: TaskInstance; + + /** + * The most recently performed task instance. + */ + readonly lastPerformed?: TaskInstance; + + /** + * The most recent task instance that is currently running. + */ + readonly lastRunning?: TaskInstance; + + /** + * The most recent task instance that succeeded. + */ + readonly lastSuccessful?: TaskInstance; + + /** + * The number of times this task has been performed. + */ + readonly performCount: number; + + /** + * The current state of the task. + * + * @TODO: what is the state when `.enqueue()` is used and one `TaskInstance` + * is running, while the next is queued? + */ + readonly state: TaskState; + + /** + * Creates a new `TaskInstance` and attempts to run it right away. + * + * If running this task instance would increase the task's concurrency to a + * number greater than the task's `maxConcurrency`, this task instance might + * be immediately canceled (dropped), or enqueued to run at a later time, + * after the currently running task(s) have finished. + */ + perform(...args: Args): TaskInstance; + + /** + * Cancels all running or queued `TaskInstance`s for this `Task`. + * + * If you're trying to cancel a specific `TaskInstance` (rather than all of + * the instances running under this task) call `.cancel()` on the specific + * `TaskInstance`. + */ + cancelAll(): void; + } +} diff --git a/addon/index.ts b/addon/index.ts index c08cd2f..33f203f 100644 --- a/addon/index.ts +++ b/addon/index.ts @@ -8,7 +8,9 @@ import { task as createTaskProperty, taskGroup as createTaskGroupProperty, TaskProperty, - TaskGroupProperty + TaskGroupProperty, + Task, + GeneratorFn } from 'ember-concurrency'; export { default as lastValue } from './last-value'; @@ -86,7 +88,7 @@ function createTaskFromDescriptor(desc: DecoratorDescriptor) { (typeof value === 'object' && typeof value.perform === 'function') ); - return createTaskProperty(value); + return (createTaskProperty(value) as unknown) as TaskProperty; } /** @@ -110,14 +112,15 @@ function createTaskGroupFromDescriptor(_desc: DecoratorDescriptor) { */ function applyOptions( options: TaskGroupOptions, - task: TaskGroupProperty + taskProperty: TaskGroupProperty ): TaskGroupProperty & Decorator; function applyOptions( options: TaskOptions, - task: TaskProperty + taskProperty: TaskProperty ): TaskProperty & Decorator { return Object.entries(options).reduce( ( + // eslint-disable-next-line no-shadow taskProperty, [key, value]: [ keyof typeof options, @@ -135,7 +138,7 @@ function applyOptions( value ); }, - task + taskProperty // The CP decorator gets executed in `createDecorator` ) as TaskProperty & Decorator; } @@ -192,7 +195,43 @@ const createDecorator = ( * @param {object?} [options={}] * @return {TaskProperty} */ -export const task = createDecorator(createTaskFromDescriptor); +const taskDecorator = createDecorator(createTaskFromDescriptor); + +export function task( + taskFn: GeneratorFn +): Task>>; +export function task< + Args extends any[], + R, + E extends { + // @TODO: this does not work + perform: GeneratorFn; + } +>(encapsulatedTask: E): Task>>; +export function task(options: TaskOptions): PropertyDecorator; +export function task( + target: Record, + propertyKey: string | symbol +): void; +export function task( + ...args: + | [GeneratorFn] + | [{ perform: GeneratorFn }] + | [TaskOptions] + | [Record, string | symbol] +): Task>> | PropertyDecorator | void { + const [firstParam] = args; + if ( + typeof firstParam === 'function' || + (typeof firstParam === 'object' && + // @ts-ignore + typeof firstParam.perform === 'function') + ) + // @ts-ignore + return firstParam; + // @ts-ignore + return taskDecorator(...args); +} /** * Turns the decorated generator function into a task and applies the diff --git a/package.json b/package.json index f48db72..4142f1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ember-concurrency-decorators", - "version": "1.0.0-beta.4", + "version": "1.1.0-alpha.1", "description": "decorator syntax for declaring/configuring ember-concurrency tasks", "keywords": [ "ember-addon" diff --git a/tests/unit/decorators-test.ts b/tests/unit/decorators-test.ts index c5e16b3..1b90b08 100644 --- a/tests/unit/decorators-test.ts +++ b/tests/unit/decorators-test.ts @@ -15,64 +15,100 @@ import { module('Unit | decorators', function() { test('Basic decorators functionality', function(assert) { - assert.expect(5); + assert.expect(6); class TestSubject extends EmberObject { + readonly someProperty = 123; + + @task + doStuff = task(function*(this: TestSubject) { + yield; + return this.someProperty; + }); + @task - doStuff = function*() { + withParameters = task(function*(foo: string, bar: boolean) { yield; - return 123; - }; + return { foo, bar }; + }); @restartableTask - a = function*() { + a = task(function*() { yield; return 456; - }; + }); @keepLatestTask - b = function*() { + b = task(function*() { yield; return 789; - }; + }); @dropTask - c = function*() { + c = task(function*() { yield; return 12; - }; + }); @enqueueTask - d = function*() { + d = task(function*() { yield; return 34; - }; + }); } let subject!: TestSubject; run(() => { subject = TestSubject.create(); - // @ts-ignore subject.get('doStuff').perform(); - // @ts-ignore + subject.get('withParameters').perform('abc', true); subject.get('a').perform(); - // @ts-ignore subject.get('b').perform(); - // @ts-ignore subject.get('c').perform(); - // @ts-ignore subject.get('d').perform(); }); - // @ts-ignore - assert.equal(subject.get('doStuff.last.value'), 123); - // @ts-ignore - assert.equal(subject.get('a.last.value'), 456); - // @ts-ignore - assert.equal(subject.get('b.last.value'), 789); - // @ts-ignore - assert.equal(subject.get('c.last.value'), 12); - // @ts-ignore - assert.equal(subject.get('d.last.value'), 34); + assert.equal( + subject + .get('doStuff') + .get('last')! + .get('value'), + 123 + ); + assert.deepEqual( + subject + .get('withParameters') + .get('last')! + .get('value'), + { foo: 'abc', bar: true } + ); + assert.equal( + subject + .get('a') + .get('last')! + .get('value'), + 456 + ); + assert.equal( + subject + .get('b') + .get('last')! + .get('value'), + 789 + ); + assert.equal( + subject + .get('c') + .get('last')! + .get('value'), + 12 + ); + assert.equal( + subject + .get('d') + .get('last')! + .get('value'), + 34 + ); }); // This has actually never worked. @@ -81,21 +117,27 @@ module('Unit | decorators', function() { class TestSubject extends EmberObject { @task - encapsulated = { + encapsulated = task({ privateState: 56, - *perform() { + *perform(_foo: string) { yield; - return this.privateState; + // @ts-ignore + return this.privateState; // @TODO: broken } - }; + }); } let subject!: TestSubject; run(() => { subject = TestSubject.create(); - subject.get('encapsulated').perform(); + subject.get('encapsulated').perform('abc'); }); - // @ts-ignore - assert.equal(subject.get('encapsulated.last.value'), 56); + assert.equal( + subject + .get('encapsulated') + .get('last')! + .get('value'), + 56 + ); }); }); diff --git a/tests/unit/last-value-test.ts b/tests/unit/last-value-test.ts index 3ae6a9f..c39e59c 100644 --- a/tests/unit/last-value-test.ts +++ b/tests/unit/last-value-test.ts @@ -14,9 +14,9 @@ module('Unit | last-value', function(hooks) { test('without a default value', function(assert) { class ObjectWithTask extends EmberObject { @task - task = function*() { + task = task(function*() { return yield 'foo'; - }; + }); @lastValue('task') value?: 'foo'; @@ -29,7 +29,6 @@ module('Unit | last-value', function(hooks) { 'it returns nothing if the task has not been performed' ); - // @ts-ignore instance.get('task').perform(); nextLoop(); @@ -43,9 +42,9 @@ module('Unit | last-value', function(hooks) { test('with a default value', function(assert) { class ObjectWithTaskDefaultValue extends EmberObject { @task - task = function*() { + task = task(function*() { return yield 'foo'; - }; + }); @lastValue('task') value = 'default value'; @@ -59,7 +58,6 @@ module('Unit | last-value', function(hooks) { 'it returns the default value if the task has not been performed' ); - // @ts-ignore instance.get('task').perform(); nextLoop(); diff --git a/types/ember-concurrency.d.ts b/types/ember-concurrency.d.ts deleted file mode 100644 index 88dad0a..0000000 --- a/types/ember-concurrency.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -export function task(fn: () => IterableIterator): TaskProperty; - -export function taskGroup(): TaskGroupProperty; - -interface CommonTaskProperty { - restartable: () => TaskProperty; - drop: () => TaskProperty; - keepLatest: () => TaskProperty; - enqueue: () => TaskProperty; - maxConcurrency: (n: number) => TaskProperty; - cancelOn: (eventName: string) => TaskProperty; - group: (groupName: string) => TaskProperty; -} - -export interface TaskProperty extends CommonTaskProperty { - evented: () => TaskProperty; - debug: () => TaskProperty; - on: (eventName: string) => TaskProperty; -} - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface TaskGroupProperty extends CommonTaskProperty {} - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface TaskInstance {}