From f050436905b55e16496e3acf6d571a41836dd1c9 Mon Sep 17 00:00:00 2001 From: Alex LaFroscia Date: Sun, 5 Apr 2020 13:12:30 -0400 Subject: [PATCH 1/4] chore: enable decorators in `tsconfig` --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index d2fc436a..3853cfc1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "allowJs": true, "moduleResolution": "node", "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, "noEmitOnError": false, "noImplicitAny": true, "noImplicitThis": true, From a1f5cb1c07e6b37701bf21662cef71ccbf20ddb0 Mon Sep 17 00:00:00 2001 From: Alex LaFroscia Date: Sun, 5 Apr 2020 13:12:45 -0400 Subject: [PATCH 2/4] feat: add decorator for `memberAction` --- addon/decorators.ts | 10 ++++ .../utils/decorators/member-action-test.ts | 53 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 addon/decorators.ts create mode 100644 tests/unit/utils/decorators/member-action-test.ts diff --git a/addon/decorators.ts b/addon/decorators.ts new file mode 100644 index 00000000..124b732a --- /dev/null +++ b/addon/decorators.ts @@ -0,0 +1,10 @@ +import instanceOp, { InstanceOperationOptions } from './utils/member-action'; + +export function memberAction(options: InstanceOperationOptions) { + return function createMemberActionDescriptor(target: any, propertyName: string): any { + return { + value: instanceOp(options) + }; + } +} + diff --git a/tests/unit/utils/decorators/member-action-test.ts b/tests/unit/utils/decorators/member-action-test.ts new file mode 100644 index 00000000..8d288061 --- /dev/null +++ b/tests/unit/utils/decorators/member-action-test.ts @@ -0,0 +1,53 @@ +import { memberAction } from 'ember-api-actions/decorators'; +import DS from 'ember-data'; +import { setupTest } from 'ember-qunit'; +import { TestContext } from 'ember-test-helpers'; +import { module, test } from 'qunit'; +import setupPretender from '../../../helpers/setup-pretender'; + +const { Model, attr } = DS; + +class Fruit extends Model { + @attr('string') + public name?: string; + + @memberAction({ path: 'doRipen' }) + public ripen: any; +} + +module('Unit | Utility | decorators/member-action', (hooks) => { + setupTest(hooks); + setupPretender(hooks); + + let fruit: Fruit; + + hooks.beforeEach(function(this: TestContext) { + this.owner.unregister('model:fruit'); + this.owner.register('model:fruit', Fruit); + + this.store = this.owner.lookup('service:store'); + + fruit = this.store.createRecord('fruit', { + id: 1, + name: 'apple' + }); + }); + + test('it adds a method through a decorator', async function(assert) { + assert.expect(4); + + this.server.put('/fruits/:id/doRipen', (request) => { + const data = JSON.parse(request.requestBody); + assert.deepEqual(data, { id: '1', name: 'apple' }, 'member action - request payload is correct'); + assert.equal(request.params.id, '1', 'request was made to the right ID'); + return [200, {}, '{"status": "ok"}']; + }); + + assert.equal(typeof fruit.ripen, 'function', 'Assigns a method on the type'); + + const { id, name } = fruit; + const result = await fruit.ripen({ id, name }); + + assert.deepEqual(result, { status: 'ok' }, 'Passes through the API response'); + }); +}); From a31816d07ca7cb5bd8babd89ddcdd13041f8deae Mon Sep 17 00:00:00 2001 From: Alex LaFroscia Date: Sun, 5 Apr 2020 14:20:38 -0400 Subject: [PATCH 3/4] feat: add decorator for `collectionAction` --- addon/decorators.ts | 9 ++++ .../decorators/collection-action-test.ts | 51 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 tests/unit/utils/decorators/collection-action-test.ts diff --git a/addon/decorators.ts b/addon/decorators.ts index 124b732a..eea5a601 100644 --- a/addon/decorators.ts +++ b/addon/decorators.ts @@ -1,5 +1,14 @@ +import collectionOp, { CollectionOperationOptions } from './utils/collection-action'; import instanceOp, { InstanceOperationOptions } from './utils/member-action'; +export function collectionAction(options: CollectionOperationOptions) { + return function createCollectionActionDescriptor(target: any, propertyName: string): any { + return { + value: collectionOp(options) + }; + } +} + export function memberAction(options: InstanceOperationOptions) { return function createMemberActionDescriptor(target: any, propertyName: string): any { return { diff --git a/tests/unit/utils/decorators/collection-action-test.ts b/tests/unit/utils/decorators/collection-action-test.ts new file mode 100644 index 00000000..8c07f486 --- /dev/null +++ b/tests/unit/utils/decorators/collection-action-test.ts @@ -0,0 +1,51 @@ +import { collectionAction } from 'ember-api-actions/decorators'; +import DS from 'ember-data'; +import { setupTest } from 'ember-qunit'; +import { TestContext } from 'ember-test-helpers'; +import { module, test } from 'qunit'; +import setupPretender from '../../../helpers/setup-pretender'; + +const { Model, attr } = DS; + +class Fruit extends Model { + @attr('string') + public name?: string; + + @collectionAction({ path: 'ripenEverything' }) + public ripenAll: any; +} + +module('Unit | Utility | decorators/collection-action', (hooks) => { + setupTest(hooks); + setupPretender(hooks); + + let fruit: Fruit; + + hooks.beforeEach(function(this: TestContext) { + this.owner.unregister('model:fruit'); + this.owner.register('model:fruit', Fruit); + + this.store = this.owner.lookup('service:store'); + + fruit = this.store.createRecord('fruit', { + id: 1, + name: 'apple' + }); + }); + + test('it adds a method through a decorator', async function(assert) { + assert.expect(3); + + this.server.put('/fruits/ripenEverything', (request) => { + const data = JSON.parse(request.requestBody); + assert.deepEqual(data, { test: 'ok' }, 'collection action - request payload is correct'); + return [200, {}, '{"status": "ok"}']; + }); + + assert.equal(typeof fruit.ripenAll, 'function', 'Assigns a method on the type'); + + const result = await fruit.ripenAll({ test: 'ok' }); + + assert.deepEqual(result, { status: 'ok' }, 'Passes through the API response'); + }); +}); From 05a8740d73c660de4b1d13bc622708d4549c3d1e Mon Sep 17 00:00:00 2001 From: Alex LaFroscia Date: Tue, 7 Apr 2020 11:21:42 -0400 Subject: [PATCH 4/4] docs: add decorator example to README --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 83834233..7aea28f1 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,27 @@ export default DS.Model.extend({ *Warning* this implemention only works for JSON API, but it should be easy to write your own `after` hook to handle your use case. Have a look at the [implementation of `serializeAndPush`](https://github.com/mike-north/ember-api-actions/blob/master/addon/utils/serialize-and-push.ts) for an example. +### ES Class Support + +Decorators are also provided for `memberAction` and `collectionAction` for usage with ES classes rather than the classic Ember classes. The same configuration options are supported: + +```javascript +import Model, { attr } from '@ember-data/model'; +import { memberAction, collectionAction } from 'ember-api-actions/decorators'; + +export default class FruidModel extends Model { + @attr('string') name; + // /fruits/123/ripen + @memberAction({ path: 'ripen' }) ripen; + // /fruits/citrus + @collectionAction({ + path: 'citrus', + type: 'post', // HTTP POST request + urlType: 'findRecord' // Base of the URL that's generated for the action + }) getAllCitrus; +} +``` + ## Customization ember-api-actions generates URLs and ajax configuration via ember-data adapters. It will identify the appropriate adapter, and call the `buildURL` and `ajaxOptions` methods to send a JSON request similar to way conventional ember-data usage works.