From cbc8fdad94bba8462f55077b1ef945ac8a91f948 Mon Sep 17 00:00:00 2001 From: Pau Ramon Revilla Date: Mon, 6 Feb 2017 10:08:48 +0100 Subject: [PATCH] Version 1.0! :tada: --- README.md | 64 +++--- __tests__/Collection.spec.js | 291 +++++++++++++++++++++++++++ __tests__/Model.spec.js | 366 ++++++++++++++++++++++++++++++++++ __tests__/mocks/api.js | 34 ++++ api_client_examples/jquery.js | 53 ----- package.json | 34 ++-- src/Collection.js | 258 +++++++++++++++--------- src/Model.js | 286 ++++++++++++++++++++------ src/apiClient.js | 21 ++ src/index.js | 4 +- src/types.js | 26 ++- test/CollectionTest.js | 228 --------------------- test/ModelTest.js | 255 ----------------------- test/mocks/api.js | 27 --- 14 files changed, 1175 insertions(+), 772 deletions(-) create mode 100644 __tests__/Collection.spec.js create mode 100644 __tests__/Model.spec.js create mode 100644 __tests__/mocks/api.js delete mode 100644 api_client_examples/jquery.js create mode 100644 src/apiClient.js delete mode 100644 test/CollectionTest.js delete mode 100644 test/ModelTest.js delete mode 100644 test/mocks/api.js diff --git a/README.md b/README.md index 714641d..6067006 100644 --- a/README.md +++ b/README.md @@ -15,26 +15,25 @@ npm install mobx-rest --save ## What is it? MobX is great to represent RESTful resources. Each resource can be represented -with a store which will have exactly the same actions (create, fetch, save, destroy). +with a store which will the expected REST actions (`create`, `fetch`, `save`, `destroy`, ...). Instead of writing hundreds of boilerplate lines we can leverage REST conventions -to deal with backend interactions. - -You can provide your own API to talk to you backend of choice. I added a folder -`api_client_examples` with an ajax example. +to deal with your API interactions. ## Example ```js -const apiPath = 'http://localhost:8000/api' -import { Collection, Model } from 'mobx-rest' -import { Api } from './Api' -import { observer } from 'mobx-react' +const apiPath = '/api' +import jqueryAdapter from 'mobx-rest-jquery-adapter' +import { apiClient, Collection, Model } from 'mobx-rest' + +// Set the adapter +apiClient(jqueryAdapter, { apiPath }) class Task extends Model { } class Tasks extends Collection { - basePath () { - return `${apiPath}/tasks` + url () { + return `/tasks` } model () { @@ -42,7 +41,9 @@ class Tasks extends Collection { } } -const tasks = new Tasks([], Api) +const tasks = new Tasks() + +import { observer } from 'mobx-react' @observer class Companies extends React.Component { @@ -55,8 +56,8 @@ class Companies extends React.Component { } render () { - if (tasks.request) { - return Loading... + if (tasks.isRequest('fetching')) { + return Fetching tasks... } return @@ -71,26 +72,27 @@ Your tree will have the following schema: ```js models: [ - { // Information at the resource level - uuid: String, // Client side id. Used for optimistic updates - request: { // An ongoing request - label: String, // Examples: 'updating', 'creating', 'fetching', 'destroying' ... - abort: Function, // A method to abort the ongoing request + { // Information at the resource level + optimisticId: String, // Client side id. Used for optimistic updates + request: { // An ongoing request + label: String, // Examples: 'updating', 'creating', 'fetching', 'destroying' ... + abort: Function, // A method to abort the ongoing request }, - error: { // A failed request - label: String, // Examples: 'updating', 'creating', 'fetching', 'destroying' ... - body: String, // A string representing the error + error: { // A failed request + label: String, // Examples: 'updating', 'creating', 'fetching', 'destroying' ... + body: String, // A string representing the error }, - attributes: Object // The resource attributes + attributes: Object // The resource attributes } -] // Information at the collection level -request: { // An ongoing request - label: String, // Examples: 'updating', 'creating', 'fetching', 'destroying' ... - abort: Function, // A method to abort the ongoing request +] // Information at the collection level +request: { // An ongoing request + label: String, // Examples: 'updating', 'creating', 'fetching', 'destroying' ... + abort: Function, // A method to abort the ongoing request + progress: number // If uploading a file, represents the progress }, -error: { // A failed request - label: String, // Examples: 'updating', 'creating', 'fetching', 'destroying' ... - body: Object, // A string representing the error +error: { // A failed request + label: String, // Examples: 'updating', 'creating', 'fetching', 'destroying' ... + body: Object, // A string representing the error } ``` @@ -98,7 +100,7 @@ error: { // A failed request (The MIT License) -Copyright (c) 2016 Pau Ramon +Copyright (c) 2017 Pau Ramon Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/__tests__/Collection.spec.js b/__tests__/Collection.spec.js new file mode 100644 index 0000000..4e69335 --- /dev/null +++ b/__tests__/Collection.spec.js @@ -0,0 +1,291 @@ +import { Collection, apiClient } from '../src' +import MockApi from './mocks/api' + +const error = 'boom!' +apiClient(MockApi) + +class MyCollection extends Collection { + url () { + return '/resources' + } +} + +describe('Collection', () => { + let collection + let item + + function resolve (attr) { + return () => { + apiClient().resolver = (resolve) => resolve(attr) + } + } + + function reject () { + apiClient().resolver = (_resolve, reject) => reject(error) + } + + beforeEach(() => { + item = { id: 1, name: 'miles' } + collection = new MyCollection([item]) + }) + + describe('at', () => { + it('finds a model at a given position', () => { + expect(collection.at(0).get('name')).toBe(item.name) + }) + }) + + describe('get', () => { + it('finds a model with the given id', () => { + expect(collection.at(0).get('name')).toBe(item.name) + }) + }) + + describe('filter', () => { + it('filters a collection with the given conditions', () => { + expect(collection.filter({ name: 'miles' })[0].get('name')).toBe(item.name) + expect(collection.filter({ name: 'bob' }).length).toBe(0) + }) + }) + + describe('find', () => { + it('filters a collection with the given conditions', () => { + expect(collection.find({ name: 'miles' }).get('name')).toBe(item.name) + expect(collection.find({ name: 'bob' })).toBeUndefined() + }) + }) + + describe('isRequest', () => { + it('returns false if there is no request', () => { + expect(collection.isRequest('fetching')).toBe(false) + }) + + it('returns false if the label does not match', () => { + collection.request = { label: 'creating' } + expect(collection.isRequest('fetching')).toBe(false) + }) + + it('returns true otherwie', () => { + collection.request = { label: 'fetching' } + expect(collection.isRequest('fetching')).toBe(true) + }) + }) + + describe('isEmpty', () => { + it('returns false if there is an element', () => { + expect(collection.isEmpty()).toBe(false) + }) + + it('returns true otherwise', () => { + collection.remove([1]) + expect(collection.isEmpty()).toBe(true) + }) + }) + + describe('add', () => { + it('adds a collection of models', () => { + const newItem = { id: 2, name: 'bob' } + collection.add([newItem]) + + expect(collection.models.length).toBe(2) + expect(collection.get(2).get('name')).toBe(newItem.name) + }) + }) + + describe('remove', () => { + it('removes a collection of models', () => { + collection.remove([1]) + + expect(collection.models.length).toBe(0) + }) + }) + + describe('create', () => { + const newItem = { name: 'bob' } + + describe('if its optimistic (default)', () => { + it('it adds the model straight away', () => { + collection.create(newItem) + expect(collection.models.length).toBe(2) + expect(collection.at(1).get('name')).toBe('bob') + expect(collection.at(1).request.label).toBe('creating') + }) + + describe('when it fails', () => { + beforeEach(reject) + + it('sets the error', async () => { + try { + await collection.create(newItem) + } catch (_error) { + expect(collection.error.label).toBe('creating') + expect(collection.error.body).toBe(error) + } + }) + + it('removes the model', async () => { + try { + await collection.create(newItem) + } catch (_error) { + expect(collection.models.length).toBe(1) + } + }) + }) + + describe('when it succeeds', () => { + beforeEach(() => { + resolve({ id: 2, name: 'dylan' })() + }) + + it('updates the data from the server', async () => { + await collection.create(newItem) + expect(collection.models.length).toBe(2) + expect(collection.at(1).get('name')).toBe('dylan') + }) + + it('nullifies the request', async () => { + await collection.create(newItem) + expect(collection.models.length).toBe(2) + expect(collection.at(1).request).toBe(null) + }) + }) + }) + + describe('if its pessimistic', () => { + describe('when it fails', () => { + beforeEach(reject) + + it('sets the error', async () => { + try { + await collection.create(newItem, { optimistic: false }) + } catch (_error) { + expect(collection.error.label).toBe('creating') + expect(collection.error.body).toBe(error) + } + }) + }) + + describe('when it succeeds', () => { + beforeEach(() => { + resolve({ id: 2, name: 'dylan' })() + }) + + it('adds data from the server', async () => { + try { + await collection.create(newItem, { optimistic: false }) + } catch (_error) { + expect(collection.models.length).toBe(2) + expect(collection.at(1).get('name')).toBe('dylan') + } + }) + }) + }) + }) + + describe('fetch', () => { + it('sets the request', () => { + collection.fetch() + expect(collection.request.label).toBe('fetching') + }) + + describe('when it fails', () => { + beforeEach(reject) + + it('sets the error', async () => { + try { + await collection.fetch() + } catch (_error) { + expect(collection.error.label).toBe('fetching') + expect(collection.error.body).toBe(error) + } + }) + }) + + describe('when it succeeds', () => { + beforeEach(() => { + resolve([item, { id: 2, name: 'bob' }])() + }) + + it('sets the data', async () => { + await collection.fetch() + expect(collection.models.length).toBe(2) + 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', () => { + describe('by default', () => { + it('adds missing models', () => { + const newItem = { id: 2, name: 'bob' } + collection.set([item, newItem]) + + expect(collection.models.length).toBe(2) + expect(collection.get(2).get('name')).toBe(newItem.name) + }) + + it('updates existing models', () => { + const updatedItem = { id: 1, name: 'coltrane' } + const newItem = { id: 2, name: 'bob' } + collection.set([updatedItem, newItem]) + + expect(collection.models.length).toBe(2) + expect(collection.get(1).get('name')).toBe(updatedItem.name) + expect(collection.get(2).get('name')).toBe(newItem.name) + }) + + it('removes non-existing models', () => { + const newItem = { id: 2, name: 'bob' } + collection.set([newItem]) + + expect(collection.models.length).toBe(1) + expect(collection.get(2).get('name')).toBe(newItem.name) + }) + }) + + describe('if `add` setting is off', () => { + it('does not add missing models', () => { + const newItem = { id: 2, name: 'bob' } + collection.set([item, newItem], { add: false }) + + expect(collection.models.length).toBe(1) + }) + }) + + describe('if `change` setting is off', () => { + it('does not update existing models', () => { + const updatedItem = { id: 1, name: 'coltrane' } + const newItem = { id: 2, name: 'bob' } + collection.set([updatedItem, newItem], { change: false }) + + expect(collection.models.length).toBe(2) + expect(collection.get(1).get('name')).toBe(item.name) + expect(collection.get(2).get('name')).toBe(newItem.name) + }) + }) + + describe('if `remove` setting is off', () => { + it('does not remove any models', () => { + const newItem = { id: 2, name: 'bob' } + collection.set([newItem], { remove: false }) + + expect(collection.models.length).toBe(2) + expect(collection.get(2).get('name')).toBe(newItem.name) + }) + }) + }) +}) diff --git a/__tests__/Model.spec.js b/__tests__/Model.spec.js new file mode 100644 index 0000000..614eb3e --- /dev/null +++ b/__tests__/Model.spec.js @@ -0,0 +1,366 @@ +import { Collection, Model, apiClient } from '../src' +import MockApi from './mocks/api' + +const error = 'boom!' +apiClient(MockApi) + +class MyCollection extends Collection { + url () { + return '/resources' + } +} + +describe('Model', () => { + let collection + let model + let item + + function resolve (attr) { + return () => { + apiClient().resolver = (resolve) => resolve(attr) + } + } + + function reject () { + apiClient().resolver = (_resolve, reject) => reject(error) + } + + beforeEach(() => { + item = { id: 1, name: 'miles', album: 'kind of blue' } + collection = new MyCollection([item]) + model = collection.at(0) + }) + + describe('url', () => { + it('returns the collection one', () => { + expect(model.url()).toBe('/resources/1') + }) + + it('throws if the model does not have a collection', () => { + expect(() => { + const newModel = new Model({ id: 1 }) + newModel.url() + }).toThrowError() + }) + }) + + describe('get', () => { + it('returns the attribute', () => { + expect(model.get('name')).toBe(item.name) + }) + + it('throws if the attribute is not found', () => { + expect(() => { + model.get('lol') + }).toThrowError() + }) + }) + + describe('set', () => { + const name = 'dylan' + + it('changes the given key value', () => { + model.set({ name: 'dylan' }) + expect(model.get('name')).toBe(name) + expect(model.get('album')).toBe(item.album) + }) + }) + + describe('save', () => { + const name = 'dylan' + + describe('if the item is not persisted', () => { + beforeEach(() => model.attributes.delete('id')) + + describe('and it has a collection', () => { + it('it adds the model', () => { + collection.create = jest.fn() + model.save(item) + expect(collection.create).toBeCalledWith(model, { optimistic: true }) + }) + }) + + describe('and it does not have a collection', () => { + it('throws an error', () => { + const newModel = new Model() + newModel.save(item).catch((s) => { + expect(s).toBeTruthy() + }) + }) + }) + }) + + describe('if its optimistic (default)', () => { + describe('and its patching (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('updating') + }) + }) + + describe('and its not patching', () => { + it('it sets model straight away', () => { + model.save({ name }, { patch: false }) + expect(model.get('name')).toBe('dylan') + expect(model.get('album')).toBe('kind of blue') + expect(model.request.label).toBe('updating') + }) + }) + + describe('when it fails', () => { + beforeEach(reject) + + it('sets the error', () => { + return model.save({ name }).catch(() => { + expect(model.error.label).toBe('updating') + expect(model.error.body).toBe(error) + }) + }) + + it('rolls back the changes', () => { + return model.save({ name }).catch(() => { + expect(model.get('name')).toBe(item.name) + expect(model.get('album')).toBe(item.album) + expect(model.request).toBe(null) + }) + }) + + 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 (default)', () => { + describe('when it fails', () => { + beforeEach(reject) + + it('sets the error', () => { + return model.save({ name }, { optimistic: false }).catch(() => { + expect(model.error.label).toBe('updating') + 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) + }) + }) + }) + }) + }) + + describe('destroy', () => { + describe('if the item is not persisted', () => { + beforeEach(() => model.attributes.delete('id')) + + it('it removes the model', () => { + collection.remove = jest.fn() + model.destroy() + expect(collection.remove).toBeCalledWith( + [model.optimisticId], + { optimistic: true } + ) + }) + }) + + describe('if its optimistic (default)', () => { + it('it removes the model straight away', () => { + model.destroy() + expect(collection.models.length).toBe(0) + }) + + describe('when it fails', () => { + beforeEach(reject) + + it('sets the error', () => { + return model.destroy().catch(() => { + expect(model.error.label).toBe('destroying') + expect(model.error.body).toBe(error) + }) + }) + + it('rolls back the changes', () => { + return model.destroy().catch(() => { + expect(collection.models.length).toBe(1) + expect(collection.at(0).get('name')).toBe(item.name) + }) + }) + + it('nullifies the request', () => { + return model.destroy().catch(() => { + expect(model.request).toBe(null) + }) + }) + }) + + describe('when it succeeds', () => { + beforeEach(resolve()) + + it('nullifies the request', () => { + return model.destroy().then(() => { + expect(model.request).toBe(null) + }) + }) + }) + }) + + describe('if its pessimistic', () => { + describe('when it fails', () => { + beforeEach(reject) + + it('sets the error', () => { + return model.destroy({ optimistic: false }).catch(() => { + expect(model.error.label).toBe('destroying') + expect(model.error.body).toBe(error) + }) + }) + + it('rolls back the changes', () => { + return model.destroy({ optimistic: false }).catch(() => { + expect(collection.models.length).toBe(1) + }) + }) + + it('nullifies the request', () => { + return model.destroy({ optimistic: false }).catch(() => { + expect(model.request).toBe(null) + }) + }) + }) + + describe('when it succeeds', () => { + beforeEach(resolve()) + + it('applies changes', () => { + return model.destroy({ optimistic: false }).then(() => { + expect(collection.models.length).toBe(0) + }) + }) + + it('nullifies the request', () => { + return model.destroy({ optimistic: false }).then(() => { + expect(model.request).toBe(null) + }) + }) + }) + }) + }) + + describe('fetch', () => { + describe('when it fails', () => { + beforeEach(reject) + + it('sets the error', () => { + return model.fetch().catch(() => { + expect(model.error.label).toBe('fetching') + expect(model.error.body).toBe(error) + }) + }) + + it('nullifies the request', () => { + return model.fetch().catch(() => { + expect(model.request).toBe(null) + }) + }) + }) + + describe('when it succeeds', () => { + beforeEach(resolve({ name: 'bill' })) + + it('returns the response', () => { + return model.fetch().then((response) => { + expect(response.name).toBe('bill') + }) + }) + + it('sets the response as attributes', () => { + return model.fetch().then(() => { + expect(model.get('name')).toBe('bill') + }) + }) + + it('nullifies the request', () => { + return model.fetch().then(() => { + expect(model.request).toBe(null) + }) + }) + }) + }) + + describe('rpc', () => { + describe('when it fails', () => { + beforeEach(reject) + + it('sets the error', () => { + return model.rpc('approve').catch(() => { + expect(model.error.label).toBe('updating') + expect(model.error.body).toBe(error) + }) + }) + + it('nullifies the request', () => { + return model.rpc('approve').catch(() => { + expect(model.request).toBe(null) + }) + }) + }) + + describe('when it succeeds', () => { + beforeEach(resolve('foo')) + + it('returns the response', () => { + return model.rpc('approve').then((response) => { + expect(response).toBe('foo') + }) + }) + + it('nullifies the request', () => { + return model.rpc('approve').then(() => { + expect(model.request).toBe(null) + }) + }) + }) + }) +}) diff --git a/__tests__/mocks/api.js b/__tests__/mocks/api.js new file mode 100644 index 0000000..0ee3a8c --- /dev/null +++ b/__tests__/mocks/api.js @@ -0,0 +1,34 @@ +export default { + resolver: (resolve) => { resolve() }, + + _mock () { + return { + abort: () => {}, + promise: new Promise(this.resolver) + } + }, + + get () { + return this._mock() + }, + + post (_path, _attributes, options) { + const ret = this._mock() + + // HACK + ret.promise.then(() => { + options.onProgress(100) + options.onProgress.flush() + }).catch(() => {}) + + return ret + }, + + put () { + return this._mock() + }, + + del () { + return this._mock() + } +} diff --git a/api_client_examples/jquery.js b/api_client_examples/jquery.js deleted file mode 100644 index 0cfc899..0000000 --- a/api_client_examples/jquery.js +++ /dev/null @@ -1,53 +0,0 @@ -import jq from 'jquery' - -type Request = { - abort: () => void; - promise: Promise; -} - -function ajax (url: string, options: {}): Request { - const xhr = jq.ajax(url, options) - - const promise = new Promise((resolve, reject) => { - xhr - .done(resolve) - .fail((jqXHR, textStatus) => { - return reject( - JSON.parse(jqXHR.responseText).errors || {} - ) - }) - }) - - const abort = () => xhr.abort() - - return {abort, promise} -} - -class ApiClient { - basePath: string - - /** - * Constructor - */ - constructor (basePath: string) { - this.basePath = basePath - } - - fetch (path: string = ''): Request { - return ajax(`${this.basePath}${path}`) - } - - post (path: string = '', data): Request { - return ajax(`${this.basePath}${path}`, {method: 'POST', data}) - } - - put (path: string = '', data): Request { - return ajax(`${this.basePath}${path}`, {method: 'PUT', data}) - } - - del (path: string = ''): Request { - return ajax(`${this.basePath}${path}`, {method: 'DELETE'}) - } -} - -export default ApiClient diff --git a/package.json b/package.json index f801ca3..8f2ad19 100644 --- a/package.json +++ b/package.json @@ -1,46 +1,46 @@ { "name": "mobx-rest", - "version": "0.1.1", + "version": "1.0.0", "description": "REST conventions for mobx.", "repository": { "type": "git", "url": "git@github.com:masylum/mobx-rest.git" }, "license": "MIT", - "dependencies": { - "jquery": "^3.1.0", - "lodash.difference": "^4.3.0", - "lodash.last": "^3.0.0", - "lodash.without": "^4.2.0", - "node-uuid": "^1.4.7", - "mobx": "^2.3.2" + "jest": { + "testRegex": "/__tests__/.*\\.spec\\.js$" }, "standard": { - "parser": "babel-eslint" + "parser": "babel-eslint", + "globals": [ "it", "describe", "beforeEach", "expect", "Class", "jest" ] + }, + "dependencies": { + "lodash": "^4.17.4", + "mobx": "^2.7.0" }, "devDependencies": { "babel-cli": "^6.10.1", "babel-core": "^6.10.4", - "babel-eslint": "^6.1.1", + "babel-eslint": "^7.1.1", + "babel-jest": "^18.0.0", "babel-plugin-transform-decorators-legacy": "^1.3.4", "babel-plugin-transform-flow-strip-types": "^6.8.0", "babel-polyfill": "^6.9.1", "babel-preset-es2015": "^6.3.13", "babel-preset-stage-1": "^6.5.0", "babel-register": "^6.9.0", - "flow-bin": "^0.28.0", - "mocha": "^2.3.4", - "sinon": "^1.17.4", - "snazzy": "^4.0.0", - "standard": "^7.1.2" + "flow-bin": "^0.38.0", + "jest": "^18.1.0", + "snazzy": "^6.0.0", + "standard": "^8.6.0" }, "main": "lib", "scripts": { "compile": "./node_modules/.bin/babel src --out-dir lib", "prepublish": "npm run compile", - "unit": "./node_modules/mocha/bin/mocha test/* --require babel-core/register", + "jest": "BABEL_ENV=test NODE_PATH=src jest --no-cache", "lint": "standard --verbose | snazzy", "flow": "flow", - "test": "npm run flow && npm run lint && npm run unit" + "test": "npm run flow && npm run lint && npm run jest" } } diff --git a/src/Collection.js b/src/Collection.js index 9975f30..62a4b0d 100644 --- a/src/Collection.js +++ b/src/Collection.js @@ -1,92 +1,108 @@ -/* globals Class */ // @flow -import { observable, action } from 'mobx' +import { observable, action, asReference, IObservableArray, runInAction } from 'mobx' import Model from './Model' -import arrayDiff from 'lodash.difference' -import arrayLast from 'lodash.last' -import type { Label, CreateOptions, Error, Request, SetOptions, Id } from './types.js' +import { isEmpty, filter, isMatch, find, difference, debounce, last } from 'lodash' +import apiClient from './apiClient' +import type { Label, CreateOptions, ErrorType, Request, SetOptions, Id } from './types' -type ApiCall = { - abort: () => void; - promise: Promise<*>; -} - -interface ApiInterface { // eslint-disable-line - fetch(path?: string): ApiCall; - post(path?: string, data: {}): ApiCall; - put(path?: string, data: {}): ApiCall; - del(path?: string): ApiCall; -} - -class Collection { +export default class Collection { @observable request: ?Request = null - @observable error: ?Error = null - @observable models: [] = [] + @observable error: ?ErrorType = null + @observable models: IObservableArray = [] - api: ApiInterface // eslint-disable-line - - constructor (data: ?[], Api: any) { - this.api = new Api(this.url()) - - if (data) this.set(data) + constructor (data: Array<{[key: string]: any}> = []) { + this.set(data) } /** * Returns the URL where the model's resource would be located on the server. + * + * @abstract */ url (): string { - return '/' + throw new Error('You must implement this method') } /** * Specifies the model class for that collection */ - model (): Class { + model (): Class<*> { return Model } + /** + * Questions whether the request exists + * and matches a certain label + */ + isRequest (label: Label): boolean { + if (!this.request) return false + + return this.request.label === label + } + + /** + * Wether the collection is empty + */ + isEmpty (): boolean { + return isEmpty(this.models) + } + /** * Gets the ids of all the items in the collection */ - _ids (): Array { - const ids = this.models.map((item) => item.id) + _ids (): Array { + return this.models.map((item) => item.id) .filter(Boolean) - - // LOL flow: https://github.com/facebook/flow/issues/1414 - return ((ids.filter(Boolean): Array): Array) } /** * Get a resource at a given position */ - at (index: number): ?Model { + at (index: number): ?T { return this.models[index] } /** * Get a resource with the given id or uuid */ - get (id: Id): ?Model { + get (id: Id): ?T { return this.models.find((item) => item.id === id) } /** - * Adds a collection of models. - * Returns the added models. + * Get resources matching criteria */ - @action add (models: Array): Array { - const Model = this.model() + filter (query: {[key: string]: any} = {}): Array { + return filter(this.models, ({ attributes }) => { + return isMatch(attributes.toJS(), query) + }) + } - const instances = models.map((attr) => new Model(this, attr)) - this.models = this.models.concat(instances) + /** + * Finds an element with the given matcher + */ + find (query: {[key: string]: mixed}): ?T { + return find(this.models, ({ attributes }) => { + return isMatch(attributes.toJS(), query) + }) + } - return instances + /** + * Adds a collection of models. + * Returns the added models. + */ + @action + add (data: Array<{[key: string]: any}>): Array { + const models = data.map(d => this.build(d)) + this.models = this.models.concat(models) + return models } /** * Removes the model with the given ids or uuids */ - @action remove (ids: Array): void { + @action + remove (ids: Array): void { ids.forEach((id) => { const model = this.get(id) if (!model) return @@ -100,61 +116,113 @@ class Collection { * * You can disable adding, changing or removing. */ - @action set ( - models: [], - {add = true, change = true, remove = true}: SetOptions = {} + @action + set ( + models: Array<{[key: string]: any}>, + { add = true, change = true, remove = true }: SetOptions = {} ): void { if (remove) { const ids = models.map((d) => d.id) - this.remove(arrayDiff(this._ids(), ids)) + const toRemove = difference(this._ids(), ids) + if (toRemove.length) this.remove(toRemove) } models.forEach((attributes) => { - let model = this.get(attributes.id) + const model = this.get(attributes.id) if (model && change) model.set(attributes) if (!model && add) this.add([attributes]) }) } + /** + * Creates a new model instance with the given attributes + */ + build (attributes: {[key: string]: any} = {}) { + const ModelClass = this.model() + const model = new ModelClass(attributes) + model.collection = this + + return model + } + /** * Creates the model and saves it on the backend * * The default behaviour is optimistic but this * can be tuned. */ - @action create ( - attributes: Object, - {optimistic = true}: CreateOptions = {} + @action + async create ( + attributesOrModel: {[key: string]: any} | Model, + { optimistic = true }: CreateOptions = {} ): Promise<*> { - const label: Label = 'creating' - const { abort, promise } = this.api.post('', attributes) let model + let attributes = attributesOrModel instanceof Model + ? attributesOrModel.attributes.toJS() + : attributesOrModel + const label: Label = 'creating' + + const onProgress = debounce(function onProgress (progress) { + if (optimistic && model.request) { + model.request.progress = progress + } + + if (this.request) { + this.request.progress = progress + } + }, 300) + + const { abort, promise } = apiClient().post( + this.url(), + attributes, + { onProgress } + ) if (optimistic) { - model = arrayLast(this.add([attributes])) - model.request = {label, abort} + model = attributesOrModel instanceof Model + ? attributesOrModel + : last(this.add([attributesOrModel])) + model.request = { + label, + abort: asReference(abort), + progress: 0 + } } - return new Promise((resolve, reject) => { - promise - .then((data) => { - if (model) { - model.set(data) - model.request = null - } else { - this.add([data]) - } - - resolve(data) - }) - .catch((body) => { - if (model) this.remove([model.id]) - this.error = {label, body} - - reject(body) - }) + this.request = { + label, + abort: asReference(abort), + progress: 0 + } + + let data: {} + + try { + data = await promise + } catch (body) { + runInAction('create-error', () => { + if (model) { + this.remove([model.id]) + } + this.error = { label, body } + this.request = null + }) + + throw body + } + + runInAction('create-done', () => { + if (model) { + model.set(data) + model.request = null + } else { + this.add([data]) + } + this.request = null }) + + return data } /** @@ -164,28 +232,38 @@ class Collection { * use the options to disable adding, changing * or removing. */ - @action fetch (options: SetOptions = {}): Promise<*> { + @action + async fetch (options: SetOptions = {}): Promise { const label: Label = 'fetching' - const {abort, promise} = this.api.fetch() + const { abort, promise } = apiClient().get( + this.url(), + options.data + ) + + this.request = { + label, + abort: asReference(abort), + progress: 0 + } - this.request = {label, abort} + let data: Array<{[key: string]: any}> - return new Promise((resolve, reject) => { - promise - .then((data) => { - this.request = null - this.set(data, options) + try { + data = await promise + } catch (err) { + runInAction('fetch-error', () => { + this.error = { label, body: err } + this.request = null + }) - resolve(data) - }) - .catch((body) => { - this.request = null - this.error = {label, body} + throw err + } - reject(body) - }) + runInAction('fetch-done', () => { + this.set(data, options) + this.request = null }) + + return data } } - -export default Collection diff --git a/src/Model.js b/src/Model.js index 1dd70a1..8d09027 100644 --- a/src/Model.js +++ b/src/Model.js @@ -1,41 +1,143 @@ // @flow -import { observable, asMap, action, ObservableMap } from 'mobx' +import { observable, asMap, asFlat, action, asReference, ObservableMap, runInAction } from 'mobx' import Collection from './Collection' -import getUuid from 'node-uuid' -import type { Uuid, Error, Request, Id, Label, DestroyOptions, SaveOptions } from './types' +import { uniqueId, isString } from 'lodash' +import apiClient from './apiClient' +import type { OptimisticId, ErrorType, Request, Id, Label, DestroyOptions, SaveOptions } from './types' -class Model { +export default class Model { @observable request: ?Request = null - @observable error: ?Error = null + @observable error: ?ErrorType = asFlat(null) - uuid: Uuid - collection: Collection + optimisticId: OptimisticId = uniqueId('i_') + collection: ?Collection<*> = null attributes: ObservableMap - constructor (collection: Collection, attributes: {}) { - this.uuid = getUuid.v4() - this.collection = collection - this.attributes = observable(asMap(attributes)) + constructor (attributes: {[key: string]: any} = {}) { + this.attributes = asMap(attributes) } - get (attribute: string): ?any { - return this.attributes.get(attribute) + /** + * Return the url for this given REST resource + * + * @abstract + */ + url (): string { + if (this.collection) { + return `${this.collection.url()}/${this.get('id')}` + } + + throw new Error('`url` method not implemented') + } + + /** + * Get the attribute from the model. + * + * Since we want to be sure changes on + * the schema don't fail silently we + * throw an error if the field does not + * exist. + * + * If you want to deal with flexible schemas + * use `has` to check wether the field + * exists. + */ + get (attribute: string): any { + if (this.has(attribute)) { + return this.attributes.get(attribute) + } + throw new Error(`Attribute "${attribute}" not found`) } - @action set (data: {}): void { + /** + * Returns whether the given field exists + * for the model. + */ + has (attribute: string): boolean { + return this.attributes.has(attribute) + } + + /** + * Get an id from the model. It will use either + * the backend assigned one or the client. + */ + get id (): Id { + return this.has('id') + ? this.get('id') + : this.optimisticId + } + + /** + * Merge the given attributes with + * the current ones + */ + @action + set (data: {}): void { this.attributes.merge(data) } - @action save ( + /** + * Fetches the model from the backend. + */ + @action + async fetch (options: { data?: {} } = {}): Promise { + const label: Label = 'fetching' + const { abort, promise } = apiClient().get( + this.url(), + options.data + ) + + this.request = { + label, + abort: asReference(abort), + progress: 0 + } + + let data + + try { + data = await promise + } catch (body) { + runInAction('fetch-error', () => { + this.error = { label, body } + this.request = null + }) + + throw body + } + + runInAction('fetch-done', () => { + this.set(data) + this.request = null + }) + + return data + } + + /** + * Saves the resource on the backend. + * + * If the item has an `id` it updates it, + * otherwise it creates the new resource. + * + * It supports optimistic and patch updates. + */ + @action + async save ( attributes: {}, - {optimistic = true, patch = true}: SaveOptions = {} + { optimistic = true, patch = true }: SaveOptions = {} ): Promise<*> { - let originalAttributes = this.attributes.toJS() + const originalAttributes = this.attributes.toJS() let newAttributes let data - if (!this.get('id')) { - return this.collection.create(attributes, {optimistic}) + if (!this.has('id')) { + this.set(Object.assign({}, attributes)) + if (this.collection) { + return this.collection.create(this, { optimistic }) + } + + throw new Error('This model does not have a collection defined') } const label: Label = 'updating' @@ -48,70 +150,130 @@ class Model { data = Object.assign({}, originalAttributes, attributes) } - // TODO: use PATCH - const { promise, abort } = this.collection.api.put( - `/${this.id}`, - data + const { promise, abort } = apiClient().put( + this.url(), + data, + { method: patch ? 'PATCH' : 'PUT' } ) - if (optimistic) this.attributes = asMap(newAttributes) + if (optimistic) this.set(newAttributes) + + this.request = { + label, + abort: asReference(abort), + progress: 0 + } - this.request = {label, abort} + let response - return new Promise((resolve, reject) => { - promise - .then((data) => { - this.request = null - this.set(data) + try { + response = await promise + } catch (body) { + runInAction('save-fail', () => { + this.request = null + this.set(originalAttributes) + this.error = { label, body } + }) - resolve(data) - }) - .catch((body) => { - this.request = null - this.attributes = asMap(originalAttributes) - this.error = {label, body} + throw isString(body) ? new Error(body) : body + } - reject(body) - }) + runInAction('save-done', () => { + this.request = null + this.set(response) }) + + return response } - @action destroy ( - {optimistic = true}: DestroyOptions = {} + /** + * Destroys the resurce on the client and + * requests the backend to delete it there + * too + */ + @action + async destroy ( + { optimistic = true }: DestroyOptions = {} ): Promise<*> { - if (!this.get('id')) { - this.collection.remove([this.uuid], {optimistic}) + if (!this.has('id') && this.collection) { + this.collection.remove([this.optimisticId], { optimistic }) return Promise.resolve() } const label: Label = 'destroying' - const { promise, abort } = this.collection.api.del(`/${this.id}`) + const { promise, abort } = apiClient().del(this.url()) - if (optimistic) this.collection.remove([this.id]) + if (optimistic && this.collection) { + this.collection.remove([this.id]) + } - this.request = {label, abort} + this.request = { + label, + abort: asReference(abort), + progress: 0 + } - return new Promise((resolve, reject) => { - return promise - .then(() => { - if (!optimistic) this.collection.remove([this.id]) - this.request = null + try { + await promise + } catch (body) { + runInAction('destroy-fail', () => { + if (optimistic && this.collection) { + this.collection.add([this.attributes.toJS()]) + } + this.error = { label, body } + this.request = null + }) - resolve() - }) - .catch((body) => { - if (optimistic) this.collection.add([this.attributes.toJS()]) - this.error = {label, body} - this.request = null + throw body + } - reject(body) - }) + runInAction('destroy-done', () => { + if (!optimistic && this.collection) { + this.collection.remove([this.id]) + } + this.request = null }) + + return null } - get id (): Id { - return this.get('id') || this.uuid + /** + * Call an RPC action for all those + * non-REST endpoints that you may have in + * your API. + */ + @action + async rpc ( + method: string, + body?: {} + ): Promise<*> { + const label: Label = 'updating' // TODO: Maybe differentiate? + const { promise, abort } = apiClient().post( + `${this.url()}/${method}`, + body || {} + ) + + this.request = { + label, + abort: asReference(abort), + progress: 0 + } + + let response + + try { + response = await promise + } catch (body) { + runInAction('accept-fail', () => { + this.request = null + this.error = { label, body } + }) + + throw body + } + + this.request = null + + return response } } - -export default Model diff --git a/src/apiClient.js b/src/apiClient.js new file mode 100644 index 0000000..54bc143 --- /dev/null +++ b/src/apiClient.js @@ -0,0 +1,21 @@ +import type { Adapter } from './types' + +let currentAdapter + +/** + * Sets or gets the api client instance + */ +export default function apiClient ( + adapter?: Adapter, + options: {[key: string]: any} = {} +): Adapter { + if (adapter) { + currentAdapter = Object.assign({}, adapter, options) + } + + if (!currentAdapter) { + throw new Error('You must set an adapter first!') + } + + return currentAdapter +} diff --git a/src/index.js b/src/index.js index 1d94000..9947b0b 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,6 @@ +// @flow import Collection from './Collection' import Model from './Model' +import apiClient from './apiClient' -export { Collection, Model } +export { Collection, Model, apiClient } diff --git a/src/types.js b/src/types.js index d1154e1..6a9aebf 100644 --- a/src/types.js +++ b/src/types.js @@ -1,34 +1,44 @@ // @flow export type Label = 'fetching' | 'creating' | 'updating' | 'destroying' -export type Uuid = string -export type Id = number | Uuid +export type OptimisticId = string +export type Id = number | OptimisticId export type CreateOptions = { - optimistic: boolean; + optimistic?: boolean; + onProgress?: () => mixed; } export type DestroyOptions = { - optimistic: boolean; + optimistic?: boolean; } export type SaveOptions = { - optimistic: boolean; - patch: boolean; + optimistic?: boolean; + patch?: boolean; } -export type Error = { +export type ErrorType = { label: Label; - body: any; + body: {}; } export type Request = { label: Label; abort: () => void; + progress: number; } export type SetOptions = { add?: boolean; change?: boolean; remove?: boolean; + data?: {}; +} + +export type Adapter = { + get (path: string, data?: {}, options?: {}): Request; + post (path: string, data?: {}, options?: {}): Request; + put (path: string, data?: {}, options?: {}): Request; + del (path: string, options?: {}): Request; } diff --git a/test/CollectionTest.js b/test/CollectionTest.js deleted file mode 100644 index 5615e8d..0000000 --- a/test/CollectionTest.js +++ /dev/null @@ -1,228 +0,0 @@ -/*global describe, it, context, beforeEach*/ -import assert from 'assert' -import Collection from '../src/Collection' -import MockApi from './mocks/api' - -const error = 'boom!' - -describe('Collection', () => { - let collection - let item - let api - - function resolve (attr) { - return () => { - api.resolver = (resolve) => resolve(attr) - } - } - - function reject () { - api.resolver = (_resolve, reject) => reject(error) - } - - beforeEach(() => { - item = {id: 1, name: 'miles'} - collection = new Collection([item], MockApi) - api = collection.api - }) - - describe('at', () => { - it('finds a model at a given position', () => { - collection.at(0) - assert.equal(collection.at(0).get('name'), item.name) - }) - }) - - describe('get', () => { - it('finds a model with the given id', () => { - collection.get(1) - assert.equal(collection.at(0).get('name'), item.name) - }) - }) - - describe('add', () => { - it('adds a collection of models', () => { - const newItem = {id: 2, name: 'bob'} - collection.add([newItem]) - - assert.equal(collection.models.length, 2) - assert.equal(collection.get(2).get('name'), newItem.name) - }) - }) - - describe('remove', () => { - it('removes a collection of models', () => { - collection.remove([1]) - - assert.equal(collection.models.length, 0) - }) - }) - - describe('create', () => { - const newItem = {name: 'bob'} - - context('if its optimistic (default)', () => { - it('it adds the model straight away', () => { - collection.create(newItem) - assert.equal(collection.models.length, 2) - assert.equal(collection.at(1).get('name'), 'bob') - assert.equal(collection.at(1).request.label, 'creating') - }) - - context('when it fails', () => { - beforeEach(reject) - - it('sets the error', () => { - return collection.create(newItem).catch(() => { - assert.equal(collection.error.label, 'creating') - assert.equal(collection.error.body, error) - }) - }) - - it('removes the model', () => { - return collection.create(newItem).catch(() => { - assert.equal(collection.models.length, 1) - }) - }) - }) - - context('when it succeeds', () => { - beforeEach(() => { - resolve({id: 2, name: 'dylan'})() - }) - - it('updates the data from the server', () => { - return collection.create(newItem).then(() => { - assert.equal(collection.models.length, 2) - assert.equal(collection.at(1).get('name'), 'dylan') - }) - }) - - it('nullifies the request', () => { - return collection.create(newItem).then(() => { - assert.equal(collection.models.length, 2) - assert.equal(collection.at(1).request, null) - }) - }) - }) - }) - - context('if its pessimistic (default)', () => { - context('when it fails', () => { - beforeEach(reject) - - it('sets the error', () => { - return collection.create(newItem, {optimistic: false}).catch(() => { - assert.equal(collection.error.label, 'creating') - assert.equal(collection.error.body, error) - }) - }) - }) - - context('when it succeeds', () => { - beforeEach(() => { - resolve({id: 2, name: 'dylan'})() - }) - - it('adds data from the server', () => { - return collection.create(newItem, {optimistic: false}).catch(() => { - assert.equal(collection.models.length, 2) - assert.equal(collection.at(1).get('name'), 'dylan') - }) - }) - }) - }) - }) - - describe('fetch', () => { - it('sets the request', () => { - collection.fetch() - assert.equal(collection.request.label, 'fetching') - }) - - context('when it fails', () => { - beforeEach(reject) - - it('sets the error', () => { - return collection.fetch().catch(() => { - assert.equal(collection.error.label, 'fetching') - assert.equal(collection.error.body, error) - }) - }) - }) - - context('when it succeeds', () => { - beforeEach(() => { - resolve([item, {id: 2, name: 'bob'}])() - }) - - it('sets the data', () => { - return collection.fetch().then(() => { - assert.equal(collection.models.length, 2) - assert.equal(collection.at(1).get('name'), 'bob') - }) - }) - }) - }) - - describe('set', () => { - context('by default', () => { - it('adds missing models', () => { - const newItem = {id: 2, name: 'bob'} - collection.set([item, newItem]) - - assert.equal(collection.models.length, 2) - assert.equal(collection.get(2).get('name'), newItem.name) - }) - - it('updates existing models', () => { - const updatedItem = {id: 1, name: 'coltrane'} - const newItem = {id: 2, name: 'bob'} - collection.set([updatedItem, newItem]) - - assert.equal(collection.models.length, 2) - assert.equal(collection.get(1).get('name'), updatedItem.name) - assert.equal(collection.get(2).get('name'), newItem.name) - }) - - it('removes non-existing models', () => { - const newItem = {id: 2, name: 'bob'} - collection.set([newItem]) - - assert.equal(collection.models.length, 1) - assert.equal(collection.get(2).get('name'), newItem.name) - }) - }) - - context('if `add` setting is off', () => { - it('does not add missing models', () => { - const newItem = {id: 2, name: 'bob'} - collection.set([item, newItem], {add: false}) - - assert.equal(collection.models.length, 1) - }) - }) - - context('if `change` setting is off', () => { - it('does not update existing models', () => { - const updatedItem = {id: 1, name: 'coltrane'} - const newItem = {id: 2, name: 'bob'} - collection.set([updatedItem, newItem], {change: false}) - - assert.equal(collection.models.length, 2) - assert.equal(collection.get(1).get('name'), item.name) - assert.equal(collection.get(2).get('name'), newItem.name) - }) - }) - - context('if `remove` setting is off', () => { - it('does not remove any models', () => { - const newItem = {id: 2, name: 'bob'} - collection.set([newItem], {remove: false}) - - assert.equal(collection.models.length, 2) - assert.equal(collection.get(2).get('name'), newItem.name) - }) - }) - }) -}) diff --git a/test/ModelTest.js b/test/ModelTest.js deleted file mode 100644 index 72a21c4..0000000 --- a/test/ModelTest.js +++ /dev/null @@ -1,255 +0,0 @@ -/*global describe, it, context, beforeEach*/ -import assert from 'assert' -import Collection from '../src/Collection' -import MockApi from './mocks/api' -import sinon from 'sinon' - -const error = 'boom!' - -describe('Model', () => { - let collection - let model - let item - let api - - function resolve (attr) { - return () => { - api.resolver = (resolve) => resolve(attr) - } - } - - function reject () { - api.resolver = (_resolve, reject) => reject(error) - } - - beforeEach(() => { - item = {id: 1, name: 'miles', album: 'kind of blue'} - collection = new Collection([item], MockApi) - api = collection.api - model = collection.at(0) - }) - - describe('get', () => { - it('returns the attribute', () => { - assert.equal(model.get('name'), item.name) - }) - }) - - describe('set', () => { - const name = 'dylan' - - it('changes the given key value', () => { - model.set({name: 'dylan'}) - assert.equal(model.get('name'), name) - assert.equal(model.get('album'), item.album) - }) - }) - - describe('save', () => { - const name = 'dylan' - - context('if the item is not persisted', () => { - beforeEach(() => model.attributes.delete('id')) - - it('it adds the model', () => { - const create = sinon.stub(collection, 'create') - collection.create(item, {optimistic: true}) - sinon.assert.calledWith(create, item, {optimistic: true}) - }) - }) - - context('if its optimistic (default)', () => { - context('and its patching (default)', () => { - it('it sets model straight away', () => { - model.save({name}) - assert.equal(model.get('name'), 'dylan') - assert.equal(model.get('album'), item.album) - assert.equal(model.request.label, 'updating') - }) - }) - - context('and its not patching', () => { - it('it sets model straight away', () => { - model.save({name}, {patch: false}) - assert.equal(model.get('name'), 'dylan') - assert.equal(model.get('album'), null) - assert.equal(model.request.label, 'updating') - }) - }) - - context('when it fails', () => { - beforeEach(reject) - - it('sets the error', () => { - return model.save({name}).catch(() => { - assert.equal(model.error.label, 'updating') - assert.equal(model.error.body, error) - }) - }) - - it('rolls back the changes', () => { - return model.save({name}).catch(() => { - assert.equal(model.get('name'), item.name) - assert.equal(model.get('album'), item.album) - assert.equal(model.request, null) - }) - }) - - it('nullifies the request', () => { - return model.save({name}).catch(() => { - assert.equal(model.request, null) - }) - }) - }) - - context('when it succeeds', () => { - beforeEach(() => { - resolve({id: 1, name: 'coltrane'})() - }) - - it('updates the data from the server', () => { - return model.save({name}).then(() => { - assert.equal(model.get('name'), 'coltrane') - }) - }) - - it('nullifies the request', () => { - return model.save({name}).then(() => { - assert.equal(model.request, null) - }) - }) - }) - }) - - context('if its pessimistic (default)', () => { - context('when it fails', () => { - beforeEach(reject) - - it('sets the error', () => { - return model.save({name}, {optimistic: false}).catch(() => { - assert.equal(model.error.label, 'updating') - assert.equal(model.error.body, error) - }) - }) - - it('nullifies the request', () => { - return model.save({name}).catch(() => { - assert.equal(model.request, null) - }) - }) - }) - - context('when it succeeds', () => { - beforeEach(() => { - resolve({id: 2, name: 'dylan'})() - }) - - it('adds data from the server', () => { - return model.save({name}, {optimistic: false}).then(() => { - assert.equal(model.get('name'), 'dylan') - }) - }) - - it('nullifies the request', () => { - return model.save({name}).then(() => { - assert.equal(model.request, null) - }) - }) - }) - }) - }) - - describe('destroy', () => { - context('if the item is not persisted', () => { - beforeEach(() => model.attributes.delete('id')) - - it('it removes the model', () => { - const remove = sinon.stub(collection, 'remove') - model.destroy() - sinon.assert.calledWith(remove, [model.uuid], {optimistic: true}) - }) - }) - - context('if its optimistic (default)', () => { - it('it removes the model straight away', () => { - model.destroy() - assert.equal(collection.models.length, 0) - }) - - context('when it fails', () => { - beforeEach(reject) - - it('sets the error', () => { - return model.destroy().catch(() => { - assert.equal(model.error.label, 'destroying') - assert.equal(model.error.body, error) - }) - }) - - it('rolls back the changes', () => { - return model.destroy().catch(() => { - assert.equal(collection.models.length, 1) - assert.equal(collection.at(0).get('name'), item.name) - }) - }) - - it('nullifies the request', () => { - return model.destroy().catch(() => { - assert.equal(model.request, null) - }) - }) - }) - - context('when it succeeds', () => { - beforeEach(resolve()) - - it('nullifies the request', () => { - return model.destroy().then(() => { - assert.equal(model.request, null) - }) - }) - }) - }) - - context('if its pessimistic', () => { - context('when it fails', () => { - beforeEach(reject) - - it('sets the error', () => { - return model.destroy({optimistic: false}).catch(() => { - assert.equal(model.error.label, 'destroying') - assert.equal(model.error.body, error) - }) - }) - - it('rolls back the changes', () => { - return model.destroy({optimistic: false}).catch(() => { - assert.equal(collection.models.length, 1) - }) - }) - - it('nullifies the request', () => { - return model.destroy({optimistic: false}).catch(() => { - assert.equal(model.request, null) - }) - }) - }) - - context('when it succeeds', () => { - beforeEach(resolve()) - - it('applies changes', () => { - return model.destroy({optimistic: false}).then(() => { - assert.equal(collection.models.length, 0) - }) - }) - - it('nullifies the request', () => { - return model.destroy({optimistic: false}).then(() => { - assert.equal(model.request, null) - }) - }) - }) - }) - }) -}) diff --git a/test/mocks/api.js b/test/mocks/api.js deleted file mode 100644 index 082b7fb..0000000 --- a/test/mocks/api.js +++ /dev/null @@ -1,27 +0,0 @@ -class MockApi { - constructor () { - this.resolver = () => {} - } - - _mock () { - return {abort: () => {}, promise: new Promise(this.resolver)} - } - - fetch () { - return this._mock() - } - - post () { - return this._mock() - } - - put () { - return this._mock() - } - - del () { - return this._mock() - } -} - -export default MockApi