From 8ced2ae2a482240371d96f0e5efbcee8eb94f849 Mon Sep 17 00:00:00 2001 From: Pau Ramon Revilla Date: Mon, 13 Feb 2017 15:44:30 +0100 Subject: [PATCH] Enable creating a resource without being in a collection --- __tests__/Collection.spec.js | 14 ----- __tests__/Model.spec.js | 103 +++++++++++++++++++++++++++++++++-- package.json | 2 +- src/Collection.js | 13 ++++- src/Model.js | 77 +++++++++++++++++++++++--- src/types.js | 1 + 6 files changed, 180 insertions(+), 30 deletions(-) diff --git a/__tests__/Collection.spec.js b/__tests__/Collection.spec.js index 39ae34e..bae5458 100644 --- a/__tests__/Collection.spec.js +++ b/__tests__/Collection.spec.js @@ -212,20 +212,6 @@ describe('Collection', () => { expect(collection.at(1).get('name')).toBe('bob') }) }) - - describe('given a successful non-array response', () => { - beforeEach(() => { - resolve({ name: 'bob' })() - }) - - it('throws an error', async () => { - collection.fetch().then((result) => { - throw new Error('expected promise to fail but it succeeded') - }, (err) => { - expect(err.message).toBe('expected an array response') - }) - }) - }) }) describe('set', () => { diff --git a/__tests__/Model.spec.js b/__tests__/Model.spec.js index 614eb3e..aaab555 100644 --- a/__tests__/Model.spec.js +++ b/__tests__/Model.spec.js @@ -8,6 +8,21 @@ class MyCollection extends Collection { url () { return '/resources' } + + model () { + return MyModel + } +} + +class MyModel extends Model { + url () { + const id = this.has('id') && this.get('id') + if (id) { + return `/resources/${id}` + } else { + return '/resources' + } + } } describe('Model', () => { @@ -81,10 +96,88 @@ describe('Model', () => { }) describe('and it does not have a collection', () => { - it('throws an error', () => { - const newModel = new Model() - newModel.save(item).catch((s) => { - expect(s).toBeTruthy() + beforeEach(() => { + model.collection = null + }) + + describe('if its optimistic (default)', () => { + it('it sets model straight away', () => { + model.save({ name }) + expect(model.get('name')).toBe('dylan') + expect(model.get('album')).toBe(item.album) + expect(model.request.label).toBe('creating') + }) + + describe('when it fails', () => { + beforeEach(reject) + + it('sets the error', () => { + return model.save({ name }).catch(() => { + expect(model.error.label).toBe('creating') + expect(model.error.body).toBe(error) + }) + }) + + it('nullifies the request', () => { + return model.save({ name }).catch(() => { + expect(model.request).toBe(null) + }) + }) + }) + + describe('when it succeeds', () => { + beforeEach(() => { + resolve({ id: 1, name: 'coltrane' })() + }) + + it('updates the data from the server', () => { + return model.save({ name }).then(() => { + expect(model.get('name')).toBe('coltrane') + }) + }) + + it('nullifies the request', () => { + return model.save({ name }).then(() => { + expect(model.request).toBe(null) + }) + }) + }) + }) + + describe('if its pessimistic', () => { + describe('when it fails', () => { + beforeEach(reject) + + it('sets the error', () => { + return model.save({ name }, { optimistic: false }).catch(() => { + expect(model.error.label).toBe('creating') + expect(model.error.body).toBe(error) + }) + }) + + it('nullifies the request', () => { + return model.save({ name }).catch(() => { + expect(model.request).toBe(null) + }) + }) + }) + + describe('when it succeeds', () => { + beforeEach(() => { + resolve({ id: 2, name: 'dylan' })() + }) + + it('adds data from the server', () => { + return model.save({ name }, { optimistic: false }).then(() => { + expect(model.get('name')).toBe('dylan') + }) + }) + + it('nullifies the request', () => { + return model.save({ name }).then(() => { + expect(model.request).toBe(null) + }) + }) }) }) }) @@ -153,7 +246,7 @@ describe('Model', () => { }) }) - describe('if its pessimistic (default)', () => { + describe('if its pessimistic', () => { describe('when it fails', () => { beforeEach(reject) diff --git a/package.json b/package.json index 751384b..fb9897e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mobx-rest", - "version": "1.0.1", + "version": "1.0.2", "description": "REST conventions for mobx.", "repository": { "type": "git", diff --git a/src/Collection.js b/src/Collection.js index 68ffd8e..bfcd24b 100644 --- a/src/Collection.js +++ b/src/Collection.js @@ -1,7 +1,16 @@ // @flow import { observable, action, asReference, IObservableArray, runInAction } from 'mobx' import Model from './Model' -import { isEmpty, filter, isMatch, find, difference, debounce, last } from 'lodash' +import { + isEmpty, + filter, + isMatch, + find, + difference, + debounce, + map, + last +} from 'lodash' import apiClient from './apiClient' import type { Label, CreateOptions, ErrorType, Request, SetOptions, Id } from './types' @@ -51,7 +60,7 @@ export default class Collection { * Gets the ids of all the items in the collection */ _ids (): Array { - return this.models.map((item) => item.id) + return map(this.models, (item) => item.id) .filter(Boolean) } diff --git a/src/Model.js b/src/Model.js index 8d09027..e9adb19 100644 --- a/src/Model.js +++ b/src/Model.js @@ -1,9 +1,18 @@ // @flow import { observable, asMap, asFlat, action, asReference, ObservableMap, runInAction } from 'mobx' import Collection from './Collection' -import { uniqueId, isString } from 'lodash' +import { uniqueId, isString, debounce } from 'lodash' import apiClient from './apiClient' -import type { OptimisticId, ErrorType, Request, Id, Label, DestroyOptions, SaveOptions } from './types' +import type { + OptimisticId, + ErrorType, + Request, + Id, + Label, + DestroyOptions, + SaveOptions, + CreateOptions +} from './types' export default class Model { @observable request: ?Request = null @@ -121,26 +130,27 @@ export default class Model { * otherwise it creates the new resource. * * It supports optimistic and patch updates. + * + * TODO: Add progress */ @action async save ( attributes: {}, { optimistic = true, patch = true }: SaveOptions = {} ): Promise<*> { - const originalAttributes = this.attributes.toJS() - let newAttributes - let data - if (!this.has('id')) { this.set(Object.assign({}, attributes)) if (this.collection) { return this.collection.create(this, { optimistic }) + } else { + return this._create(attributes, { optimistic }) } - - throw new Error('This model does not have a collection defined') } + let newAttributes + let data const label: Label = 'updating' + const originalAttributes = this.attributes.toJS() if (patch) { newAttributes = Object.assign({}, originalAttributes, attributes) @@ -186,6 +196,57 @@ export default class Model { return response } + /** + * Internal method that takes care of creating a model that does + * not belong to a collection + */ + async _create ( + attributes: {}, + { optimistic = true }: CreateOptions = {} + ): Promise<*> { + const label: Label = 'creating' + + const onProgress = debounce(function onProgress (progress) { + if (optimistic && this.request) { + this.request.progress = progress + } + }, 300) + + const { abort, promise } = apiClient().post( + this.url(), + attributes, + { onProgress } + ) + + if (optimistic) { + this.request = { + label, + abort: asReference(abort), + progress: 0 + } + } + + let data: {} + + try { + data = await promise + } catch (body) { + runInAction('create-error', () => { + this.error = { label, body } + this.request = null + }) + + throw body + } + + runInAction('create-done', () => { + this.set(data) + this.request = null + }) + + return data + } + /** * Destroys the resurce on the client and * requests the backend to delete it there diff --git a/src/types.js b/src/types.js index 6a9aebf..40cec91 100644 --- a/src/types.js +++ b/src/types.js @@ -16,6 +16,7 @@ export type DestroyOptions = { export type SaveOptions = { optimistic?: boolean; patch?: boolean; + onProgress?: () => mixed; } export type ErrorType = {