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

File uploader example #16

Merged
merged 9 commits into from
Sep 5, 2015
Merged
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions examples/file-uploader/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uploads/*
90 changes: 90 additions & 0 deletions examples/file-uploader/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# File Uploader

This example is a browser-based file-upload component, i.e. it renders a list
of chosen files, shows their upload progress, and allows users to cancel
uploads.

Similar to the [zip-code-futures][zip] example, it demonstrates a way to do
asynchronous side effects in a controlled way. The top-level update function
(in `main.js`) was copied from that example. It features a convention for making
synchronous and asynchronous updates together which is very similar to Elm's
`effects`. You can read more about this in the [Elm architecture tutorial][elm].

It also features routing of nested updates, both synchronous (which 'bubble up'
the component tree in the familiar way, based on user events) and asynchronous
(which are constructed via future chains of actions to 'dive down' the component
tree, based on server responses).

The uploader wraps XMLHttpRequest in a [ramda-fantasy][rf] Future, which
repeatedly resolves on XHR progress events.


## How to build it

Install the dependencies.

```
npm install
```

Then build the code with

```
npm run build
```

Run the tests

```
npm run test
```


## How to use it

Create an uploads folder, if it doesn't already exist:

```
mkdir -p uploads
```

Start the test server

```
node server.js 8080
```

Then open your browser to `http://localhost:8080/index.html`, and choose some
files to upload.


## Other notes

- The progress bar is rendered as SVG using @yelouafi 's [builder][svg] (not yet
part of snabbdom, but it works great!)

- The example is intended to be a step towards a realistic stand-alone
file uploader, i.e. a 'widget' or reusable component. So `main` and `app`
give a picture of how you would integrate it into a real app. Comments
welcome on the app interface to the 'widget' (the `list` and `uploader`),
I am sure it could be improved.

- 'Structural' styling is done via javacript, while app-specific styles can be
applied via CSS.

- The XHR `abort` method is not handled in a pure way. This seems impossible,
at least using the Future interface. So there is a bit of a 'wormhole'
exposing this method back to the app and into the model, where it can be
hooked up to a click handler. Other suggestions welcome.

- The tests include a simple dummy uploader for running unit tests independent
of XHR and the browser, an example of how easy it is to test using this
architecture.



[zip]: https://github.com/paldepind/functional-frontend-architecture/tree/master/examples/zip-codes-future
[elm]: https://github.com/evancz/elm-architecture-tutorial#example-5-random-gif-viewer
[rf]: https://github.com/ramda/ramda-fantasy
[svg]: https://github.com/paldepind/snabbdom/issues/4

56 changes: 56 additions & 0 deletions examples/file-uploader/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>File Uploader</title>
<script type="text/javascript" src="js/build.js"></script>
<style>

body {
box-sizing: border-box;
font-size: 12pt;
font-family: Verdana, Arial, Helvetica, sans-serif;
margin: 1%;
}

.upload {
padding: 0.5rem;
margin: 0.5rem;
border: 1px solid lightgrey;
border-radius: 3px;
}

.upload.uploaded {
background-color: aliceblue;
}

.upload.error {
color: red;
}

.upload.abort {
color: lightgrey;
}

.upload > .status {
text-transform: uppercase;
font-size: 0.7em;
transform: translateY(30%);
}

.upload > .progress rect.bar {
fill: steelblue;
}

.upload > .progress line.end {
stroke: lightgrey;
stroke-width: 1;
}

</style>
</head>
<body>
<h1>File Uploader example</h1>
<div id="container"></div>
</body>
</html>
75 changes: 75 additions & 0 deletions examples/file-uploader/js/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@

const Type = require('union-type');
const T = require('ramda/src/T')
, assoc = require('ramda/src/assoc')
, curry = require('ramda/src/curry')
, compose = require('ramda/src/compose')
, map = require('ramda/src/map')
, invoker = require('ramda/src/invoker')
;
const h = require('snabbdom/h');

const uploadList = require('./list');
const uploader = require('./uploader');


// action

const listUpdate = (listAction,model) => {
const [state, tasks] = uploadList.update(listAction, model.uploads);
return [ assoc('uploads', state, model),
tasks.map( map(Action.Route) )
];
}

const Action = Type({
Create: [T, T],
Route: [uploadList.Action]
});

const update = Action.caseOn({
Create: (up,files,model) => {
return listUpdate( uploadList.Action.Create(up,files), model );
},
Copy link
Owner

Choose a reason for hiding this comment

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

What about dropping the { return }:

Create: (up, files, model) => listUpdate(uploadList.Action.Create(up, files),


Route: listUpdate
});


// model

const init = () => { return { uploads: uploadList.init() }; }

// view

const view = curry( ({url, headers, action$}, model) => {
Copy link
Owner

Choose a reason for hiding this comment

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

I don't understand where headers is coming from?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That was intended to be in case you need to set headers on the XHR, but it doesn't need to be parameterized at the top level of the app, for same reason url can just be a constant.


const up = uploader.upload(headers, url);

const form = (
Copy link
Owner

Choose a reason for hiding this comment

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

Maybe making form a separate function would be nice. I think it would make it easier to see what values it depends on (only update as far as I can tell).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point, I was not consistent about putting functions in the highest scope they can be, that's my preference too.

h('form', {on: {submit: preventDefault} }, [
h('input',
{ props: {type: 'file', multiple: true},
on: {
change: compose(action$, Action.Create(up), getTarget('files'))
}
}
)
]
)
);

return (
h('div.uploading', {}, [
form,
uploadList.view(model.uploads)
])
);
});

const getTarget = curry( (key,e) => e.target[key] );
const preventDefault = invoker(0, 'preventDefault');


module.exports = { init, update, Action, view }

Loading