diff --git a/package.json b/package.json index 749630d4..a74c7235 100644 --- a/package.json +++ b/package.json @@ -39,15 +39,13 @@ "semi": false, "singleQuote": true }, - "workspaces": { - "packages": [ - "packages/microcosm", - "packages/microcosm-devtools", - "packages/microcosm-dom", - "packages/microcosm-graphql", - "packages/microcosm-http", - "packages/microcosm-www", - "packages/examples/*" - ] - } + "workspaces": [ + "packages/microcosm", + "packages/microcosm-devtools", + "packages/microcosm-dom", + "packages/microcosm-graphql", + "packages/microcosm-http", + "packages/microcosm-www", + "packages/examples/*" + ] } diff --git a/packages/examples/file-uploads/.gitignore b/packages/examples/file-uploads/.gitignore new file mode 100644 index 00000000..d45e6de3 --- /dev/null +++ b/packages/examples/file-uploads/.gitignore @@ -0,0 +1 @@ +public/uploads/* diff --git a/packages/examples/file-uploads/package.json b/packages/examples/file-uploads/package.json new file mode 100644 index 00000000..6605bf52 --- /dev/null +++ b/packages/examples/file-uploads/package.json @@ -0,0 +1,19 @@ +{ + "name": "examples-file-uploads", + "version": "0.0.0", + "private": true, + "license": "MIT", + "scripts": { + "start": "webpack-dev-server --mode=development" + }, + "dependencies": { + "babel-loader": "^7.1.2", + "body-parser": "^1.18.2", + "html-webpack-plugin": "^3.0.6", + "multer": "^1.3.0", + "react": "^16.3.2", + "react-dom": "^16.3.2", + "webpack": "^4.6.0", + "webpack-dev-server": "^3.1.3" + } +} diff --git a/packages/microcosm-http/example/index.html b/packages/examples/file-uploads/public/index.html similarity index 100% rename from packages/microcosm-http/example/index.html rename to packages/examples/file-uploads/public/index.html diff --git a/packages/examples/file-uploads/src/actions/files.js b/packages/examples/file-uploads/src/actions/files.js new file mode 100644 index 00000000..87feaf3d --- /dev/null +++ b/packages/examples/file-uploads/src/actions/files.js @@ -0,0 +1,3 @@ +import http from 'microcosm-http' + +export const uploadFile = http.prepare({ method: 'POST', url: '/files' }) diff --git a/packages/examples/file-uploads/src/index.js b/packages/examples/file-uploads/src/index.js new file mode 100644 index 00000000..881f30d0 --- /dev/null +++ b/packages/examples/file-uploads/src/index.js @@ -0,0 +1,5 @@ +import React from 'react' +import DOM from 'react-dom' +import { FileUploader } from './views/file-uploader' + +DOM.render(, document.getElementById('app')) diff --git a/packages/examples/file-uploads/src/views/file-uploader.js b/packages/examples/file-uploads/src/views/file-uploader.js new file mode 100644 index 00000000..60788530 --- /dev/null +++ b/packages/examples/file-uploads/src/views/file-uploader.js @@ -0,0 +1,46 @@ +import React from 'react' +import { uploadFile } from '../actions/files' +import { Subject } from 'microcosm' +import { Presenter, ActionForm } from 'microcosm-dom' +import { Progress } from './progress' + +function asFormData(form) { + return { data: new FormData(form) } +} + +export class FileUploader extends Presenter { + state = { + status: 'inactive', + file: undefined + } + + queue = new Subject() + + render() { + let { status, file } = this.state + + return ( + + + + + + +
+ +
+
+ ) + } + + trackProgress = action => { + action.every(iteration => + this.setState({ status: iteration.status, file: iteration.payload }) + ) + } +} diff --git a/packages/examples/file-uploads/src/views/progress.js b/packages/examples/file-uploads/src/views/progress.js new file mode 100644 index 00000000..4570e842 --- /dev/null +++ b/packages/examples/file-uploads/src/views/progress.js @@ -0,0 +1,28 @@ +import React from 'react' + +function Loading({ file, onCancel }) { + return ( +
+

Your files are uploading...

+ + +
+ ) +} + +export function Progress({ status, file, onCancel }) { + switch (status) { + case 'next': + return + case 'error': + return

{file.message}

+ case 'complete': + return

Files sent!

+ case 'cancel': + return

File upload cancelled

+ default: + return null + } +} diff --git a/packages/examples/file-uploads/webpack.config.js b/packages/examples/file-uploads/webpack.config.js new file mode 100644 index 00000000..36bd2614 --- /dev/null +++ b/packages/examples/file-uploads/webpack.config.js @@ -0,0 +1,43 @@ +const HTMLWebpackPlugin = require('html-webpack-plugin') +const path = require('path') +const multer = require('multer') +const bodyParser = require('body-parser') + +module.exports = { + plugins: [ + new HTMLWebpackPlugin({ + template: 'public/index.html' + }) + ], + resolve: { + alias: { + microcosm: path.resolve(__dirname, '../../microcosm/src/'), + 'microcosm-http': path.resolve( + __dirname, + '../../microcosm-http/src/http.js' + ), + 'microcosm-dom': path.resolve(__dirname, '../../microcosm-dom/src/react') + } + }, + module: { + rules: [ + { + test: /\.js/, + loader: 'babel-loader', + exclude: /node_modules/ + } + ] + }, + devServer: { + before: app => { + const upload = multer({ dest: `${__dirname}/public/uploads` }) + + app.use(bodyParser.json()) + app.use(bodyParser.urlencoded({ extended: true })) + + app.post('/files', upload.array('files', 100), function(req, res, next) { + res.json(req.body) + }) + } + } +} diff --git a/packages/microcosm-dom/src/action-button.js b/packages/microcosm-dom/src/action-button.js index 85793b65..37d466bd 100644 --- a/packages/microcosm-dom/src/action-button.js +++ b/packages/microcosm-dom/src/action-button.js @@ -6,7 +6,7 @@ export function generateActionButton(createElement, Component) { constructor() { super(...arguments) - this.queue = new Subject('action-button') + this.queue = this.props.queue || new Subject() this._onClick = this._onClick.bind(this) } @@ -15,7 +15,7 @@ export function generateActionButton(createElement, Component) { } componentWillUnmount() { - this.queue.cancel() + this.queue.complete() } render() { @@ -24,47 +24,45 @@ export function generateActionButton(createElement, Component) { delete props.tag delete props.action delete props.value - delete props.onStart + delete props.onSend delete props.onNext delete props.onComplete + delete props.onChange delete props.onError delete props.onCancel delete props.send delete props.prepare - if (this.props.tag === 'button' && props.type == null) { - props.type = 'button' - } - return createElement(this.props.tag, props) } click() { - let { action, prepare, value } = this.props - - let params = prepare(value) - let result = this.send(action, params) + let result = this.send(this.props.action, this._parameterize()) + let action = Subject.hash(result) - if (result && 'subscribe' in result) { - this._onChange('start', result) + this.props.onSend(action) - result.subscribe({ - error: this._onChange.bind(this, 'error', result), - next: this._onChange.bind(this, 'next', result), - complete: this._onChange.bind(this, 'complete', result), - cancel: this._onChange.bind(this, 'cancel', result) - }) + let tracker = action.every(this._onChange, this) - this.queue.subscribe(result) - } + this.queue.subscribe({ + error: tracker.unsubscribe, + complete: tracker.unsubscribe, + cancel: action.cancel + }) - return result + return action } // Private --------------------------------------------------- // - _onChange(status, result) { - this.props[toCallbackName(status)](result.payload, result.meta) + _parameterize() { + let { value, prepare } = this.props + + return prepare(value) + } + + _onChange(action) { + this.props[toCallbackName(action.status)](action) } _onClick(event) { @@ -80,12 +78,14 @@ export function generateActionButton(createElement, Component) { ActionButton.defaultProps = { action: 'no-action', onClick: identity, - onStart: identity, + onSend: identity, onNext: identity, onComplete: identity, onError: identity, + onChange: identity, onCancel: identity, prepare: identity, + queue: null, send: null, tag: 'button', value: null diff --git a/packages/microcosm-dom/src/action-form.js b/packages/microcosm-dom/src/action-form.js index c3907be9..08b31a2e 100644 --- a/packages/microcosm-dom/src/action-form.js +++ b/packages/microcosm-dom/src/action-form.js @@ -7,7 +7,7 @@ export function generateActionForm(createElement, Component) { constructor() { super(...arguments) - this.queue = new Subject('action-form') + this.queue = this.props.queue || new Subject() this._onSubmit = this._onSubmit.bind(this) } @@ -16,7 +16,7 @@ export function generateActionForm(createElement, Component) { } componentWillUnmount() { - this.queue.cancel() + this.queue.complete() } render() { @@ -29,7 +29,7 @@ export function generateActionForm(createElement, Component) { delete props.action delete props.prepare delete props.serializer - delete props.onStart + delete props.onSend delete props.onNext delete props.onComplete delete props.onNext @@ -43,21 +43,19 @@ export function generateActionForm(createElement, Component) { submit(event) { let result = this.send(this.props.action, this._parameterize()) + let action = Subject.hash(result) - if (result && 'subscribe' in result) { - this._onChange('start', result) + this.props.onSend(action) - result.subscribe({ - error: this._onChange.bind(this, 'error', result), - next: this._onChange.bind(this, 'next', result), - complete: this._onChange.bind(this, 'complete', result), - cancel: this._onChange.bind(this, 'cancel', result) - }) + let tracker = action.every(this._onChange, this) - this.queue.subscribe(result) - } + this.queue.subscribe({ + error: tracker.unsubscribe, + complete: tracker.unsubscribe, + cancel: action.cancel + }) - return result + return action } // Private --------------------------------------------------- // @@ -70,8 +68,8 @@ export function generateActionForm(createElement, Component) { : prepare(serializer(this._form)) } - _onChange(status, result) { - this.props[toCallbackName(status)](result.payload, result.meta) + _onChange(action) { + this.props[toCallbackName(action.status)](action) } _onSubmit(event) { @@ -87,12 +85,14 @@ export function generateActionForm(createElement, Component) { ActionForm.defaultProps = { action: 'no-action', onSubmit: identity, - onStart: identity, + onSend: identity, onNext: identity, onComplete: identity, + onChange: identity, onError: identity, onCancel: identity, prepare: identity, + queue: null, send: null, tag: 'form', serializer: form => serialize(form, { hash: true, empty: true }) diff --git a/packages/microcosm-dom/src/presenter.js b/packages/microcosm-dom/src/presenter.js index df0d80f0..9d632d8c 100644 --- a/packages/microcosm-dom/src/presenter.js +++ b/packages/microcosm-dom/src/presenter.js @@ -1,4 +1,4 @@ -import { Microcosm, Observable, Subject } from 'microcosm' +import { Microcosm, Subject } from 'microcosm' import { advice, noop, shallowDiffers } from './utilities' import { intercept } from './intercept' @@ -82,7 +82,6 @@ export function generatePresenter(createElement, Component) { } componentWillUnmount() { - this.mediator.model.cancel() this.teardown(this.repo, this.props, this.state) if (this.didFork) { @@ -107,7 +106,7 @@ export function generatePresenter(createElement, Component) { constructor(props, context) { super(props, context) - this.model = Observable.of({}) + this.model = new Subject({}) this.presenter = props.presenter let prepo = props.repo || context.repo || new Microcosm() @@ -137,6 +136,10 @@ export function generatePresenter(createElement, Component) { this.updateModel(this.presenter.props, this.presenter.state) } + componentWillUnmount() { + this.model.cancel() + } + render() { return Object.getPrototypeOf(this.presenter).render.call(this.presenter) } diff --git a/packages/microcosm-dom/test/action-button.test.js b/packages/microcosm-dom/test/action-button.test.js index 2cd9eff5..13beb466 100644 --- a/packages/microcosm-dom/test/action-button.test.js +++ b/packages/microcosm-dom/test/action-button.test.js @@ -13,18 +13,18 @@ describe('ActionButton', function() { expect(send).toHaveBeenCalledWith('test', true) }) - it('executes onStart when that action starts', function() { + it('executes onSend when that action starts', function() { let repo = new Microcosm() let send = action => repo.push(action, true) - let onStart = jest.fn() + let onSend = jest.fn() let button = mount( - + ) button.click() - expect(onStart).toHaveBeenCalledWith(true, repo.history.head.meta) + expect(onSend).toHaveBeenCalledWith(repo.history.head) }) it('executes onComplete when that action completes', function() { @@ -36,7 +36,7 @@ describe('ActionButton', function() { ).click() - expect(onComplete).toHaveBeenCalledWith(true, repo.history.head.meta) + expect(onComplete).toHaveBeenCalledWith(repo.history.head) }) it('executes onError when that action completes', function() { @@ -48,7 +48,7 @@ describe('ActionButton', function() { button.click() - expect(onError).toHaveBeenCalledWith('bad', repo.history.head.meta) + expect(onError).toHaveBeenCalledWith(repo.history.head) }) it('executes onNext when that action sends an update', function() { @@ -62,7 +62,7 @@ describe('ActionButton', function() { action.next('loading') - expect(onNext).toHaveBeenCalledWith('loading', repo.history.head.meta) + expect(onNext).toHaveBeenCalledWith(repo.history.head) }) it('executes onCancel when that action is cancelled', function() { @@ -74,7 +74,7 @@ describe('ActionButton', function() { button.click() - expect(onCancel).toHaveBeenCalledWith(undefined, repo.history.head.meta) + expect(onCancel).toHaveBeenCalledWith(repo.history.head) }) it('passes along onClick', function() { @@ -154,18 +154,6 @@ describe('ActionButton', function() { expect(wrapper.tagName).toBe('A') }) - it('uses the button type when set as a button', function() { - let wrapper = mount() - - expect(wrapper.type).toBe('button') - }) - - it('does not pass the type attribute for non-buttons', function() { - let wrapper = mount() - - expect(wrapper.getAttribute('type')).toBe(null) - }) - it('inherits send from context', function() { let send = jest.fn() diff --git a/packages/microcosm-dom/test/action-form.test.js b/packages/microcosm-dom/test/action-form.test.js index 09499d49..05c3a90b 100644 --- a/packages/microcosm-dom/test/action-form.test.js +++ b/packages/microcosm-dom/test/action-form.test.js @@ -16,7 +16,7 @@ describe('ActionForm', function() { submit(form) - expect(onComplete).toHaveBeenCalledWith(true, repo.history.head.meta) + expect(onComplete).toHaveBeenCalledWith(repo.history.head) }) it('executes onError when that action fails', function() { @@ -27,24 +27,24 @@ describe('ActionForm', function() { submit(form) - expect(onError).toHaveBeenCalledWith('bad', repo.history.head.meta) + expect(onError).toHaveBeenCalledWith(repo.history.head) }) - it('executes onStart when that action opens', function() { + it('executes onSend when that action opens', function() { let repo = new Microcosm() let reply = () => repo.push(() => action => action.next('open')) - let onStart = jest.fn() + let onSend = jest.fn() let form = mount( - + ) submit(form) - expect(onStart).toHaveBeenCalledWith('open', repo.history.head.meta) + expect(onSend).toHaveBeenCalledWith(repo.history.head) }) - it('executes onUpdate when that action sends an update', function() { + it('executes onNext when that action sends an update', function() { let repo = new Microcosm() let action = repo.push(() => action => {}) let reply = () => action @@ -58,17 +58,17 @@ describe('ActionForm', function() { action.next('loading') - expect(onNext).toHaveBeenCalledWith('loading', repo.history.head.meta) + expect(onNext).toHaveBeenCalledWith(repo.history.head) }) - it('does not execute callbacks if not given an action', function() { + it('gracefully executes callbacks if not given an action', function() { let onComplete = jest.fn() let form = mount( null} />) submit(form) - expect(onComplete).not.toHaveBeenCalled() + expect(onComplete).toHaveBeenCalled() }) it('removes action callbacks when the component unmounts', function() { @@ -105,7 +105,7 @@ describe('ActionForm', function() { ) ) - expect(onComplete).toHaveBeenCalledWith({}, repo.history.head.meta) + expect(onComplete).toHaveBeenCalled() }) it('send as a prop overrides context', function() { @@ -121,10 +121,7 @@ describe('ActionForm', function() { ) ) - expect(onComplete).toHaveBeenCalledWith( - 'from-prop', - repo.history.head.meta - ) + expect(onComplete).toHaveBeenCalled() }) }) diff --git a/packages/microcosm-dom/test/presenter.test.js b/packages/microcosm-dom/test/presenter.test.js index 63e7f9c9..1abc6d9c 100644 --- a/packages/microcosm-dom/test/presenter.test.js +++ b/packages/microcosm-dom/test/presenter.test.js @@ -131,7 +131,7 @@ describe('::getModel', function() { expect(el.textContent).toEqual('Captain Kurtz') }) - it('does not recalculate the view model if the props are the same', function() { + it('does not recalculate when changes during teardown occur', function() { let spy = jest.fn() class Namer extends Presenter { @@ -540,7 +540,7 @@ describe('::teardown', function() { expect(renders).toBe(1) }) - it('changes during teardown do not cause a recalculation', function() { + it('changes during teardown do not cause a recalculation', async function() { let renders = 0 class Test extends Presenter { @@ -570,6 +570,8 @@ describe('::teardown', function() { unmount(mount()) + await delay() + // Once: for the initial calculation expect(renders).toBe(1) }) @@ -665,9 +667,7 @@ describe('Efficiency', function() { let el = mount() - repo.push(patch, { color: 'green' }) - - await delay() + await repo.push(patch, { color: 'green' }) expect(model).toHaveBeenCalledTimes(1) expect(el.textContent).toEqual('green') diff --git a/packages/microcosm-http/example/index.js b/packages/microcosm-http/example/index.js deleted file mode 100644 index d113a21e..00000000 --- a/packages/microcosm-http/example/index.js +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react' -import DOM from 'react-dom' -import Microcosm from 'microcosm' -import ActionForm from 'microcosm/addons/action-form' -import Presenter from 'microcosm/addons/presenter' -import http from 'microcosm-http' - -let repo = new Microcosm() - -repo.addDomain('files', { - actions: { - uploadFile: http.prepare({ method: 'post', url: '/files' }) - } -}) - -class FileUploader extends Presenter { - state = { - status: 'inactive', - payload: null - } - - onNext = ({ status, payload }) => { - this.setState({ status, payload }) - } - - serialize(form) { - return { data: new FormData(form) } - } - - render() { - const { status, payload } = this.state - - if (status === 'update') { - return ( -
-

Your files are uploading...

- -
- ) - } - - return ( - - {status === 'reject' ? ( -

{payload.message}

- ) : null} - - {status === 'resolve' ? ( -

Files sent!

- ) : null} - - - - -
- -
-
- ) - } -} - -DOM.render(, document.getElementById('app')) diff --git a/packages/microcosm-http/jest.config.js b/packages/microcosm-http/jest.config.js index 0fec06df..5b015a64 100644 --- a/packages/microcosm-http/jest.config.js +++ b/packages/microcosm-http/jest.config.js @@ -2,6 +2,5 @@ const { moduleNameMapper } = require('../../jest.config') module.exports = { modulePathIgnorePatterns: ['/build/'], - collectCoverageFrom: ['src/**/*.js'], moduleNameMapper: moduleNameMapper } diff --git a/packages/microcosm-http/package.json b/packages/microcosm-http/package.json index 5745fb57..ad03973f 100644 --- a/packages/microcosm-http/package.json +++ b/packages/microcosm-http/package.json @@ -3,20 +3,11 @@ "version": "0.0.0", "main": "src/http.js", "private": true, + "scripts": { + "test": "jest" + }, "dependencies": { "axios": "^0.18.0", "microcosm": "^13.0.0-alpha" - }, - "scripts": { - "start": "webpack-dev-server" - }, - "devDependencies": { - "body-parser": "^1.18.1", - "html-webpack-plugin": "^3.2.0", - "multer": "^1.3.0", - "react": "^16.3.2", - "react-dom": "^16.3.2", - "webpack": "^4.6.0", - "webpack-dev-server": "^3.1.3" } } diff --git a/packages/microcosm-http/src/http.js b/packages/microcosm-http/src/http.js index 19c9484e..4ba87aea 100644 --- a/packages/microcosm-http/src/http.js +++ b/packages/microcosm-http/src/http.js @@ -44,7 +44,6 @@ function formatErrors(error) { export default function http(args) { return action => { let source = CancelToken.source() - // https://github.com/mzabriskie/axios#request-config let options = merge(args, { cancelToken: source.token, @@ -54,11 +53,14 @@ export default function http(args) { axios(options) .then(response => { - action.next(response.data) - action.complete() + action.complete(response.data) }) .catch(error => { - action.error(formatErrors(error)) + if (axios.isCancel(error)) { + action.cancel() + } else { + action.error(formatErrors(error)) + } }) action.subscribe({ cancel: source.cancel }) diff --git a/packages/microcosm-http/test/test-helpers.js b/packages/microcosm-http/test/test-helpers.js index e22460c2..69cb3a29 100644 --- a/packages/microcosm-http/test/test-helpers.js +++ b/packages/microcosm-http/test/test-helpers.js @@ -1,9 +1,7 @@ import settle from 'axios/lib/core/settle' -import createError from 'axios/lib/core/createError' const defaults = { data: {}, - delay: 100, status: 200, uploads: [], downloads: [] @@ -16,16 +14,6 @@ export function testAdapter(mock) { let response = { ...options, config } return new Promise(function(resolve, reject) { - // Handle cancellation - // https://github.com/axios/axios#cancellation - if (config.cancelToken) { - config.cancelToken.promise.then(reject) - } - - if (response.status >= 400) { - reject(createError('Mock request failure', config, 'MOCKFAILED', {})) - } - // Emit a progress event for each item in a given array of // downloads/uploads. This is an object matching the progress // event api: @@ -34,11 +22,13 @@ export function testAdapter(mock) { options.uploads.forEach(config.onUploadProgress) // Wait a bit to simulate asynchronous behavior. - setTimeout(() => settle(resolve, reject, response), mock.delay) + let timer = setTimeout(() => settle(resolve, reject, response)) + + // Handle cancellation + // https://github.com/axios/axios#cancellation + if (config.cancelToken) { + config.cancelToken.promise.then(() => clearTimeout(timer)) + } }) } } - -export function delay(time = 0) { - return new Promise(resolve => setTimeout(resolve, time)) -} diff --git a/packages/microcosm-http/test/unit/http.test.js b/packages/microcosm-http/test/unit/http.test.js index 6413c8d4..f005b84c 100644 --- a/packages/microcosm-http/test/unit/http.test.js +++ b/packages/microcosm-http/test/unit/http.test.js @@ -1,6 +1,6 @@ -import Microcosm from 'microcosm' +import { Microcosm } from 'microcosm' import http from 'microcosm-http' -import { delay, testAdapter } from '../test-helpers' +import { testAdapter } from '../test-helpers' it('fetches', async () => { let repo = new Microcosm() @@ -16,7 +16,7 @@ it('fetches', async () => { expect(Array.isArray(posts)).toBe(true) }) -it('cancels', async () => { +it('cancels', () => { let repo = new Microcosm() let getPosts = http.prepare({ @@ -25,25 +25,28 @@ it('cancels', async () => { let action = repo.push(getPosts) - await delay(0) - - action.cancel('nevermind') + action.cancel() expect(action).toHaveProperty('meta.status', 'cancel') - expect(action).toHaveProperty('payload', 'nevermind') }) it('errors', async () => { + expect.assertions(3) + let repo = new Microcosm() - let getPosts = () => - http({ - adapter: testAdapter({ status: 400 }) - }) + let getPosts = http.prepare({ + adapter: testAdapter({ status: 400 }) + }) let action = repo.push(getPosts) - await delay(0) + try { + await action + } catch (error) { + expect(error.status).toEqual(400) + expect(error.message).toContain('Request failed with status code 400') + } expect(action).toHaveProperty('meta.status', 'error') }) diff --git a/packages/microcosm-http/webpack.config.js b/packages/microcosm-http/webpack.config.js deleted file mode 100644 index 7da7db68..00000000 --- a/packages/microcosm-http/webpack.config.js +++ /dev/null @@ -1,50 +0,0 @@ -const path = require('path') -const HTMLWebpackPlugin = require('html-webpack-plugin') - -module.exports = { - entry: './example/index.js', - output: { - filename: 'microcosm-http.js', - path: path.resolve('example/build') - }, - plugins: [ - new HTMLWebpackPlugin({ - template: './example/index.html' - }) - ], - resolve: { - alias: { - 'microcosm-http': path.resolve(__dirname, 'src/http.js'), - microcosm: path.resolve(__dirname, '../microcosm/src/') - } - }, - module: { - loaders: [ - { - test: /\.js/, - loader: 'babel-loader', - exclude: /node_modules/ - }, - { - test: /\.json/, - loader: 'json-loader' - } - ] - }, - devServer: { - port: 3000, - contentBase: path.resolve(__dirname, 'example'), - setup: app => { - const multer = require('multer') - const bodyParser = require('body-parser') - const upload = multer({ dest: 'example/uploads' }) - - app.use(bodyParser.json()) - app.use(bodyParser.urlencoded({ extended: true })) - - app.post('/files', upload.array('files', 100), function(req, res, next) { - res.json(req.body) - }) - } - } -} diff --git a/packages/microcosm/src/history.js b/packages/microcosm/src/history.js index dfad05b5..6aedc518 100644 --- a/packages/microcosm/src/history.js +++ b/packages/microcosm/src/history.js @@ -78,14 +78,7 @@ export class History extends Subject { this.head = action - let dispatch = this.dispatch.bind(this, action) - - action.subscribe({ - next: dispatch, - error: dispatch, - complete: dispatch, - cancel: dispatch - }) + action.every(this.dispatch, this) try { coroutine(action, command, params, origin) diff --git a/packages/microcosm/src/subject.js b/packages/microcosm/src/subject.js index 0d29ef06..6322c3e3 100644 --- a/packages/microcosm/src/subject.js +++ b/packages/microcosm/src/subject.js @@ -95,7 +95,16 @@ export class Subject { } } - subscribe(next: *) { + get clear(): * { + return () => { + this.payload = null + this.meta.status = 'start' + + this._observers.forEach(observer => observer.cancel()) + } + } + + subscribe(next: *): * { let observer = new Observer(...arguments) if (this.closed) { @@ -163,7 +172,22 @@ export class Subject { }) } + every(fn: (subject: this) => void, scope: any): * { + let dispatch = fn.bind(scope, this) + + return this.subscribe({ + next: dispatch, + error: dispatch, + complete: dispatch, + cancel: dispatch + }) + } + static hash(obj: *): Subject { + if (obj instanceof Subject) { + return obj + } + let subject = new Subject() if (getObservable(obj)) { diff --git a/packages/microcosm/test/unit/subject.test.js b/packages/microcosm/test/unit/subject.test.js index f8b17fce..f14dfa04 100644 --- a/packages/microcosm/test/unit/subject.test.js +++ b/packages/microcosm/test/unit/subject.test.js @@ -168,6 +168,30 @@ describe('Subject', function() { }) }) + describe('clear', function() { + it('resets the subject', function() { + let subject = new Subject() + + subject.next(true) + subject.clear() + + expect(subject.status).toBe('start') + expect(subject.payload).toBe(null) + expect(subject.closed).toBe(false) + }) + + it('cancels observers', function() { + let subject = new Subject() + let cancel = jest.fn() + + subject.subscribe({ cancel }) + + subject.clear() + + expect(cancel).toHaveBeenCalledTimes(1) + }) + }) + describe('then', function() { it('resolves when completed', async () => { let subject = new Subject() diff --git a/yarn.lock b/yarn.lock index e0959976..9bf0358e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1403,7 +1403,7 @@ bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: version "4.11.8" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" -body-parser@1.18.2, body-parser@^1.18.0, body-parser@^1.18.1, body-parser@^1.18.2: +body-parser@1.18.2, body-parser@^1.18.0, body-parser@^1.18.2: version "1.18.2" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.2.tgz#87678a19d84b47d859b83199bd59bce222b10454" dependencies: @@ -4548,7 +4548,7 @@ html-webpack-plugin@^2.30.1: pretty-error "^2.0.2" toposort "^1.0.0" -html-webpack-plugin@^3.2.0: +html-webpack-plugin@^3.0.6, html-webpack-plugin@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-3.2.0.tgz#b01abbd723acaaa7b37b6af4492ebda03d9dd37b" dependencies: