Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refine microcosm-http. and microcosm-dom Add file-uploader example #509

Merged
merged 3 commits into from
Apr 24, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/*"
]
}
1 change: 1 addition & 0 deletions packages/examples/file-uploads/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
public/uploads/*
19 changes: 19 additions & 0 deletions packages/examples/file-uploads/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
3 changes: 3 additions & 0 deletions packages/examples/file-uploads/src/actions/files.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import http from 'microcosm-http'

export const uploadFile = http.prepare({ method: 'POST', url: '/files' })
5 changes: 5 additions & 0 deletions packages/examples/file-uploads/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React from 'react'
import DOM from 'react-dom'
import { FileUploader } from './views/file-uploader'

DOM.render(<FileUploader />, document.getElementById('app'))
46 changes: 46 additions & 0 deletions packages/examples/file-uploads/src/views/file-uploader.js
Original file line number Diff line number Diff line change
@@ -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) }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's FormData?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a part of XMLHttpRequest 2.0:

The FormData interface provides a way to easily construct a set of key/value pairs representing form fields and their values, which can then be easily sent using the XMLHttpRequest.send() method. It uses the same format a form would use if the encoding type were set to "multipart/form-data".

https://developer.mozilla.org/en-US/docs/Web/API/FormData

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, is it naive to think that this would be a reasonable default for the serializer ActionForm property?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I was just typing this up. I'd be okay with dropping form-serialize and just making this the default. I wonder what turning FormData into JSON would look like.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue filed:
#510

}

export class FileUploader extends Presenter {
state = {
status: 'inactive',
file: undefined
}

queue = new Subject()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is Subject the de facto implementation of an Observable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm using it waaay more than Observable. It's a way to get the observable API externally, like this, but inside out:

new Observable(observer => {
  observer.next(1)
  observer.next(2)
  observer.next(3)
  observer.complete()
})

Can be:

let subject = new Subject()

subject.next(1)
subject.next(2)
subject.next(3)
subject.complete()

The advantage of an observable is that the behavior inside is lazy based on if anything subscribes to it. Subjects are more active.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's awesome. Not sure if Subject is already in your crosshairs for "needs a better name", but it's quite vague at the moment.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is. It comes from Observables literature, but I agree that it's vague. I'd be happy to figure out another name.

Working more on scheduler stuff, I have some times wondered about calling this Job or Process. But I dunno.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue filed:
#511


render() {
let { status, file } = this.state

return (
<ActionForm
action={uploadFile}
serializer={asFormData}
queue={this.queue}
onSend={this.trackProgress}
>
<Progress status={status} file={file} onCancel={this.queue.clear} />

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New here: you can pass a Subject to ActionForm and ActionButton. This lets you control the underlying scheduling.

New here: Subject:clear(). This lets you cancel all subscriptions but keep the subject open. It's sort of like a reset.

<label htmlFor="files">Upload a file</label>
<input id="file" multiple name="files" type="file" />

<footer>
<button>Upload</button>
</footer>
</ActionForm>
)
}

trackProgress = action => {
action.every(iteration =>
this.setState({ status: iteration.status, file: iteration.payload })
)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New here: action.every is a subscription callback that fires on every status change.

}
}
28 changes: 28 additions & 0 deletions packages/examples/file-uploads/src/views/progress.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react'

function Loading({ file, onCancel }) {
return (
<div>
<p>Your files are uploading...</p>
<progress value={file.progress} />
<button type="button" onClick={onCancel}>
Cancel
</button>
</div>
)
}

export function Progress({ status, file, onCancel }) {
switch (status) {
case 'next':
return <Loading file={file} onCancel={onCancel} />
case 'error':
return <p className="msg error">{file.message}</p>
case 'complete':
return <p className="msg success">Files sent!</p>
case 'cancel':
return <p className="msg error">File upload cancelled</p>
default:
return null
}
}
43 changes: 43 additions & 0 deletions packages/examples/file-uploads/webpack.config.js
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
}
50 changes: 25 additions & 25 deletions packages/microcosm-dom/src/action-button.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -15,7 +15,7 @@ export function generateActionButton(createElement, Component) {
}

componentWillUnmount() {
this.queue.cancel()
this.queue.complete()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want to cancel. That would cancel any outstanding actions, which might not be what the user wants. If that is the desired behavior, you can now do that yourself.

}

render() {
Expand All @@ -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) {
Expand All @@ -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
Expand Down
34 changes: 17 additions & 17 deletions packages/microcosm-dom/src/action-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -16,7 +16,7 @@ export function generateActionForm(createElement, Component) {
}

componentWillUnmount() {
this.queue.cancel()
this.queue.complete()
}

render() {
Expand All @@ -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
Expand All @@ -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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrapping the result in Subject.hash gives me either:

  1. The same action back
  2. A Subject version of whatever send returned.

That means you can pass anything into the send prop and the action form will turn it into the same interface as an action (Subjects). You'll always get a Subject back from send now, every time.

So you don't really need a repo. Just a send method:

let send = () => Promise.resolve(true)
let callback = (subject) => {}

<ActionButton send={send} onSend={callback}/>


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 --------------------------------------------------- //
Expand All @@ -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) {
Expand All @@ -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 })
Expand Down
9 changes: 6 additions & 3 deletions packages/microcosm-dom/src/presenter.js
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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) {
Expand All @@ -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()
Expand Down Expand Up @@ -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)
}
Expand Down
Loading