├── .tool-versions
├── .gitignore
├── example
├── index.html
├── server.ts
└── client.ts
├── .markdown-doctest-setup.js
├── package.json
├── test
└── test.ts
├── src
└── index.ts
├── tsconfig.json
└── README.md
/.tool-versions:
--------------------------------------------------------------------------------
1 | nodejs 9.2.0
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | node_modules
3 | lib/
4 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Github Search
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/.markdown-doctest-setup.js:
--------------------------------------------------------------------------------
1 | const cycleDOM = {
2 | makeDOMDriver: () => () => {}
3 | }
4 |
5 | const cycleTime = {
6 | timeDriver: () => {}
7 | }
8 |
9 | const cycleRun = {
10 | run: () => {}
11 | }
12 |
13 | module.exports = {
14 | require: {
15 | 'cycle-remote-data': require('.'),
16 | '@cycle/dom': cycleDOM,
17 | '@cycle/time': cycleTime,
18 | '@cycle/run': cycleRun,
19 | 'xstream': require('xstream')
20 | },
21 |
22 | globals: {
23 | ...cycleDOM,
24 |
25 | document: {body: {}}
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cycle-remote-data",
3 | "version": "0.1.0",
4 | "description": "USAGE: common-readme [-r|--repo REPO-NAME] [-l|--license LICENSE]",
5 | "main": "lib/src/index.js",
6 | "typings": "lib/src/index.d.ts",
7 | "scripts": {
8 | "prepare": "tsc",
9 | "test": "mocha --compilers ts:ts-node/register && markdown-doctest"
10 | },
11 | "files": [
12 | "lib"
13 | ],
14 | "keywords": [],
15 | "author": "",
16 | "license": "MIT",
17 | "devDependencies": {
18 | "@cycle/dom": "^20.1.0",
19 | "@cycle/time": "^0.10.1",
20 | "@types/browserify": "^12.0.33",
21 | "@types/mocha": "^2.2.44",
22 | "@types/node": "^8.0.49",
23 | "browserify": "^14.5.0",
24 | "mocha": "^4.0.1",
25 | "markdown-doctest": "^0.9.1",
26 | "superagent": "^3.6.0",
27 | "ts-node": "^3.3.0",
28 | "tsify": "^3.0.3",
29 | "typescript": "^2.5.3",
30 | "xstream": "^11.0.0",
31 | "@types/superagent": "^3.5.6"
32 | },
33 | "dependencies": {
34 | "@cycle/http": "^14.8.0"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/example/server.ts:
--------------------------------------------------------------------------------
1 | import * as http from 'http';
2 | import * as fs from 'fs';
3 | import * as browserify from 'browserify';
4 |
5 | const results = [
6 | {
7 | name: 'test',
8 | value: 'foo1'
9 | },
10 | {
11 | name: 'zest',
12 | value: 'foo2'
13 | },
14 | {
15 | name: 'best',
16 | value: 'foo3'
17 | },
18 | {
19 | name: 'mest',
20 | value: 'foo4'
21 | },
22 | ]
23 |
24 | const server = http.createServer((req, res) => {
25 | console.log(req.url);
26 |
27 | if (req.url === '/' || req.url === '/index.html') {
28 | res.writeHead(200, { 'Context-Type': 'text/html' });
29 | res.end(fs.readFileSync(__dirname + '/index.html'));
30 | return;
31 | }
32 |
33 | if (req.url === '/bundle.js') {
34 | const b = browserify('./example/client.ts', {plugin: 'tsify'});
35 | res.writeHead(200, { 'Context-Type': 'application/javascript' });
36 | b.bundle().pipe(res);
37 | return;
38 | }
39 |
40 | const q = (req as any).url.slice(2);
41 |
42 | setTimeout(() => {
43 | if (Math.random() > 0.80) {
44 | res.writeHead(401, { 'Content-Type': 'application/json' });
45 | res.end(JSON.stringify([]));
46 | return;
47 | }
48 |
49 | res.writeHead(200, { 'Content-Type': 'application/json' });
50 | res.end(JSON.stringify(results.filter(r => r.name.indexOf(q) !== -1)));
51 | }, Math.random() * 3000);
52 | });
53 |
54 | server.listen(8080);
55 |
--------------------------------------------------------------------------------
/test/test.ts:
--------------------------------------------------------------------------------
1 | import * as assert from 'assert';
2 | import * as superagent from 'superagent';
3 | import * as http from 'http';
4 | import { makeRemoteDataDriver, RemoteResponse } from '../src/';
5 |
6 |
7 | const server = http.createServer(function (req, res) {
8 | if (req.url === '/err') {
9 | res.writeHead(401, { 'Content-Type': 'application/json' });
10 | res.end(JSON.stringify({}));
11 |
12 | }
13 |
14 | res.writeHead(200, { 'Content-Type': 'application/json' });
15 | res.end(JSON.stringify({hello: 'world'}));
16 | });
17 |
18 | describe('remoteDataDriver', () => {
19 | before(() => server.listen(8090));
20 | after(() => server.close());
21 |
22 | it('allows fetching remote data', (done) => {
23 | const driver = makeRemoteDataDriver()();
24 |
25 | function f (remoteData: RemoteResponse) {
26 | return remoteData.when({
27 | Ok (response) { return {status: 'success ' + response.body} },
28 | Loading () { return {status: 'loading'} },
29 | Error () { return {status: 'error'} },
30 | NotAsked () { return {status: 'not asked'} }
31 | });
32 | }
33 |
34 | const states = [
35 | {status: 'loading'},
36 | {status: 'success hello world'}
37 | ]
38 |
39 | driver.request({method: 'GET', url: 'localhost:8090'}).map(f).take(states.length).addListener({
40 | next (actual) {
41 | const expected = states.shift();
42 |
43 | assert.deepEqual(actual.status, (expected as any).status);
44 | },
45 | error: done,
46 | complete: done
47 | });
48 | });
49 |
50 | it('handles errors', (done) => {
51 | const driver = makeRemoteDataDriver()();
52 |
53 | function f (remoteData: RemoteResponse) {
54 | return remoteData.when({
55 | Ok (response) { return {status: 'success ' + response.body} },
56 | Loading () { return {status: 'loading'} },
57 | Error (error) { return {status: 'error', error} },
58 | NotAsked () { return {status: 'not asked'} }
59 | });
60 | }
61 |
62 | const states = [
63 | {status: 'loading'},
64 | {status: 'error'}
65 | ]
66 |
67 | driver.request({method: 'GET', url: 'localhost:8090/err'}).map(f).take(states.length).addListener({
68 | next (actual) {
69 | const expected = states.shift();
70 |
71 | assert.deepEqual(actual.status, (expected as any).status);
72 |
73 | if (actual.status === 'error') {
74 | assert.equal((actual as any).error.response.request.url, 'localhost:8090/err');
75 | }
76 | },
77 | error: done,
78 | complete: done
79 | });
80 | });
81 | });
82 |
83 |
--------------------------------------------------------------------------------
/example/client.ts:
--------------------------------------------------------------------------------
1 | import {
2 | makeRemoteDataDriver,
3 | rmap,
4 | RemoteDataSource,
5 | RemoteResponse,
6 | NotAsked,
7 | NotAsked$,
8 | RemoteData
9 | } from '../src';
10 | import { makeDOMDriver, DOMSource, div, input, button, pre } from '@cycle/dom';
11 | import { timeDriver, TimeSource } from '@cycle/time';
12 | import { run } from '@cycle/run';
13 | import xs from 'xstream';
14 |
15 | interface Sources {
16 | DOM: DOMSource;
17 | Time: TimeSource;
18 | RemoteData: RemoteDataSource;
19 | }
20 |
21 | interface Result {
22 | name: string;
23 | value: string;
24 | }
25 |
26 | function GithubSearch(sources: Sources) {
27 | const query$ = sources.DOM
28 | .select('.search-query')
29 | .events('input')
30 | .map(ev => (ev.target as HTMLInputElement).value)
31 | .remember();
32 |
33 | const finishedTyping$ = query$.compose(sources.Time.debounce(250));
34 |
35 | const searchClick$ = sources.DOM.select('.search').events('click');
36 |
37 | const reload$ = sources.DOM.select('.reload').events('click');
38 |
39 | const search$ = xs.merge(finishedTyping$, reload$, searchClick$);
40 |
41 | const data$ = search$.map(() => query$.take(1)).flatten();
42 |
43 | const loadingProgress$ = sources.Time.periodic(300).map(i => i % 3 + 1);
44 |
45 | const remoteData$ = data$
46 | .map(query =>
47 | query === ''
48 | ? NotAsked$
49 | : sources.RemoteData.request({ url: `/?${query}`, method: 'GET' })
50 | )
51 | .flatten();
52 |
53 | const post$ = remoteData$
54 | .map(rmap(res => res.body as Result[]))
55 | .startWith(NotAsked);
56 |
57 | return {
58 | DOM: xs.combine(post$, loadingProgress$).map(view)
59 | };
60 | }
61 |
62 | function view([remotePost, loadingProgress]: [RemoteData, number]) {
63 | return div([
64 | div('Search:'),
65 | input('.search-query'),
66 | button('.search', 'Search'),
67 |
68 | remotePost.when({
69 | Loading: progress => loadingView(loadingProgress),
70 | Error: errorView,
71 | Ok: resultsView,
72 | NotAsked: notAskedView
73 | })
74 | ]);
75 | }
76 |
77 | function errorView() {
78 | return div(['Error loading content', button('.reload', 'Reload')]);
79 | }
80 |
81 | function loadingView(progress: number) {
82 | return div([
83 | 'Loading' +
84 | Array(progress)
85 | .fill('.')
86 | .join('')
87 | ]);
88 | }
89 |
90 | function notAskedView() {
91 | return div(['Search for something!']);
92 | }
93 |
94 | function resultsView(results: Result[]) {
95 | if (results.length === 0) {
96 | return div('No results found');
97 | }
98 |
99 | return div(results.map(post => div('.post', post.name + ' - ' + post.value)));
100 | }
101 |
102 | const drivers = {
103 | DOM: makeDOMDriver(document.body),
104 | RemoteData: makeRemoteDataDriver(),
105 | Time: timeDriver
106 | };
107 |
108 | run(GithubSearch, drivers);
109 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { Response, RequestOptions } from '@cycle/http/lib/cjs/interfaces';
2 | import { optionsToSuperagent } from '@cycle/http/lib/cjs/http-driver';
3 | import * as superagent from 'superagent';
4 | import xs, { Listener, MemoryStream } from 'xstream';
5 |
6 | export interface RemoteDataSource {
7 | request(options: RequestOptions): MemoryStream;
8 | }
9 |
10 | export type RemoteResponse = RemoteData;
11 |
12 | export interface RemoteData {
13 | when(cases: Cases): U;
14 | rmap(f: (t: T) => V): RemoteData;
15 | }
16 |
17 | export interface Cases {
18 | NotAsked: () => U;
19 | Loading: (progress: number) => U;
20 | Error: (err: ResponseError) => U;
21 | Ok: (value: T) => U;
22 | }
23 |
24 | export interface ResponseError extends Error {
25 | response: Response;
26 | }
27 |
28 | export function rmap(f: (t: T) => U): (r: RemoteData) => RemoteData {
29 | return r => r.rmap(f);
30 | }
31 |
32 | export const NotAsked = {
33 | when(cases: Cases): U {
34 | return cases.NotAsked();
35 | },
36 |
37 | rmap() {
38 | return NotAsked;
39 | }
40 | };
41 |
42 | export const NotAsked$ = xs.of(NotAsked).remember();
43 |
44 | function Loading(progress: number): RemoteData {
45 | const loading = {
46 | when(cases: Cases): U {
47 | return cases.Loading(progress);
48 | },
49 |
50 | rmap() {
51 | return loading;
52 | }
53 | };
54 |
55 | return loading;
56 | }
57 |
58 | function ErrorResponse(err: ResponseError): RemoteData {
59 | const error = {
60 | when(cases: Cases): U {
61 | return cases.Error(err);
62 | },
63 |
64 | rmap() {
65 | return error;
66 | }
67 | };
68 |
69 | return error;
70 | }
71 |
72 | function Ok(value: T): RemoteData {
73 | return {
74 | when(cases: Cases): U {
75 | return cases.Ok(value);
76 | },
77 |
78 | rmap(f: (t: T) => V): RemoteData {
79 | return Ok(f(value));
80 | }
81 | };
82 | }
83 |
84 | function requestToResponse(
85 | requestOptions: RequestOptions
86 | ): MemoryStream {
87 | let request: superagent.Request;
88 |
89 | return xs.createWithMemory({
90 | start(listener) {
91 | request = optionsToSuperagent(requestOptions);
92 |
93 | listener.next(Loading(0));
94 |
95 | if (requestOptions.progress) {
96 | request = request.on('progress', ev =>
97 | listener.next(Loading(ev.percent || 0))
98 | );
99 | }
100 |
101 | request.end(processResponse(requestOptions, listener));
102 | },
103 |
104 | stop() {
105 | request.abort();
106 | }
107 | });
108 | }
109 |
110 | function processResponse(request: RequestOptions, listener: Listener) {
111 | return (error: ResponseError | null, response: Response) => {
112 | response.request = request;
113 |
114 | if (error) {
115 | error.response = response;
116 |
117 | listener.next(ErrorResponse(error));
118 | } else {
119 | listener.next(Ok(response));
120 | }
121 |
122 | listener.complete();
123 | }
124 | }
125 |
126 | export function makeRemoteDataDriver() {
127 | return function remoteDataDriver(): RemoteDataSource {
128 | return { request: requestToResponse };
129 | };
130 | }
131 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Basic Options */
4 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */
5 | "module": "commonjs", /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
6 | "lib": ["ES6", "DOM", "ES5"], /* Specify library files to be included in the compilation: */
7 | // "allowJs": true, /* Allow javascript files to be compiled. */
8 | // "checkJs": true, /* Report errors in .js files. */
9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
10 | "declaration": true, /* Generates corresponding '.d.ts' file. */
11 | // "sourceMap": true, /* Generates corresponding '.map' file. */
12 | // "outFile": "./", /* Concatenate and emit output to single file. */
13 | "outDir": "./lib", /* Redirect output structure to the directory. */
14 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
15 | // "removeComments": true, /* Do not emit comments to output. */
16 | // "noEmit": true, /* Do not emit outputs. */
17 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
18 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
19 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
20 |
21 | /* Strict Type-Checking Options */
22 | "strict": true /* Enable all strict type-checking options. */
23 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
24 | // "strictNullChecks": true, /* Enable strict null checks. */
25 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
26 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
27 |
28 | /* Additional Checks */
29 | // "noUnusedLocals": true, /* Report errors on unused locals. */
30 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
31 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
32 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
33 |
34 | /* Module Resolution Options */
35 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
36 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
37 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
38 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
39 | // "typeRoots": [], /* List of folders to include type definitions from. */
40 | // "types": [], /* Type declaration files to be included in compilation. */
41 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
42 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
43 |
44 | /* Source Map Options */
45 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
46 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */
47 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
48 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
49 |
50 | /* Experimental Options */
51 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
52 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # cycle-remote-data
2 |
3 | `cycle-remote-data` is a Cycle.js driver for fetching and sending data over HTTP.
4 |
5 | `@cycle/http` is the official HTTP driver for Cycle.js. `cycle-remote-data` attempts to improve over `@cycle/http` in a number of ways. When using `@cycle/http`, it is easy to forget to handle errors, which then crash your application. Successfully handling errors is not easy, and if you are using a type checker can be extra painful.
6 |
7 | Additionally, it is often useful to be able to show feedback in the UI when loading data, or when data loading has not yet started. This is also not always straightforward with `@cycle/http`.
8 |
9 | [krisajenkins](https://github.com/krisajenkins), author of the [Elm library](http://package.elm-lang.org/packages/krisajenkins/remotedata/latest) that inspired this one, has an [excellent blog post](http://blog.jenkster.com/2016/06/how-elm-slays-a-ui-antipattern.html) describing this problem generically.
10 |
11 | With `cycle-remote-data`, you can call `sources.RemoteData.request({url: '/search?q=hi', method: 'GET'})`, which returns a `MemoryStream` of `RemoteData` objects.
12 |
13 | There are four possible states: `Ok`, `Error`, `Loading` and `NotAsked`. When working with `RemoteData` objects, we handle all of these possibilities.
14 |
15 | When using TypeScript in strict mode, the compiler will even catch any cases we fail to handle!
16 |
17 |
18 | ## Install
19 |
20 | ```bash
21 | $ npm install cycle-remote-data
22 | ```
23 |
24 | ## Usage
25 |
26 | First, we want to import `makeRemoteDataDriver` and add it to our drivers.
27 |
28 | ```js
29 | import { makeRemoteDataDriver } from 'cycle-remote-data';
30 |
31 | const drivers = {
32 | DOM: makeDOMDriver('.app'),
33 | RemoteData: makeRemoteDataDriver()
34 | }
35 | ```
36 |
37 | Then, inside of our main, we can use `sources.RemoteData.request` to make requests.
38 |
39 | ```js
40 | function main(sources) {
41 | const results$ = sources.RemoteData.request({
42 | method: 'GET',
43 | url: 'https://api.github.com/search/repositories?q=cycle'
44 | });
45 | }
46 | ```
47 |
48 | If you've used `@cycle/http`, you'll notice some differences. First of all, we can make requests with a method in sources, rather than from our sinks. Secondly, instead of a stream of streams, we're just working with a flat stream of RemoteData states.
49 |
50 | One nice similarity is that `cycle-remote-data` expects requests in the same format as `@cycle/http`, and uses the exact same code under the hood to turn that into a `superagent` request.
51 |
52 | So we have a stream of `RemoteData` states, but what is a `RemoteData`? If we print it out, we see an object with two methods, `when` and `rmap`.
53 |
54 | `RemoteData` is an interface across four possible states, named `Ok`, `Error`, `Loading` and `NotAsked`. Notice that there is no `type` or identifying information present in a `RemoteData` object.
55 |
56 | So how do we work with the data we've loaded? That's where `when` comes into play.
57 |
58 | Let's map over our `result$` and put it into a `view`.
59 |
60 |
61 | ```js
62 | function main(sources) {
63 | const results$ = sources.RemoteData.request({
64 | method: 'GET',
65 | url: 'https://api.github.com/search/repositories?q=cycle'
66 | });
67 |
68 | return {
69 | DOM: results$.map(view)
70 | }
71 | }
72 | ```
73 |
74 | So how do we define our view?
75 |
76 | ```js
77 | function view(remoteData) {
78 | return remoteData.when({
79 | Ok: response => div(JSON.stringify(response.body)),
80 | Error: () => div('An error occurred loading data...'),
81 | Loading: () => div('Loading...'),
82 | NotAsked: () => div('')
83 | })
84 | }
85 | ```
86 |
87 | We can use the `when` method to handle all of the possible cases. Our `RemoteData` will start out `Loading`, and then progress into either `Ok` or `Error`.
88 |
89 | Whenever the `RemoteData` state changes our `results$` will update, and our `when` will be called again.
90 |
91 | You may wonder where `NotAsked` comes into it. `NotAsked` is not a state that `cycle-remote-data` will ever emit, but it can be useful to import `NotAsked` and `.startWith(NotAsked)`.
92 |
93 | This is useful for example when you have a search box, and you want it to say 'Type something to search' when nothing has been searched or it has been cleared.
94 |
95 | The above example does have an ugly part, in that we are working with responses directly in our view. What we need is a way to apply functions to the `Ok` value before calling `when`.
96 |
97 | This is where `rmap` is handy. We could alter our main function to pull the body off of the response.
98 |
99 | ```js
100 | function main(sources) {
101 | const response$ = sources.RemoteData.request({
102 | method: 'GET',
103 | url: 'https://api.github.com/search/repositories?q=cycle'
104 | });
105 |
106 | const results$ = response$.map(remoteData => remoteData.rmap(response => response.body));
107 |
108 | return {
109 | DOM: results$.map(view)
110 | }
111 | }
112 | ```
113 |
114 | `remoteData.rmap` takes a function that transforms the response. This is why `RemoteData` is actually a generic interface in TypeScript.
115 |
116 | When you call `RemoteData.request`, you're actually getting a `MemoryStream>`, where `Response` comes from `@cycle/http` and has an attached `request`. Calling `.rmap` on the `RemoteData` will return a `RemoteData` with the generic type of the return value of your `rmap` function.
117 |
118 | ## Example
119 |
120 | This is a larger example that includes a `reload` button on errors, uses a `NotAsked` state at the start, and cancels old requests when new searches are made.
121 |
122 | ```js
123 | import {makeRemoteDataDriver, NotAsked, NotAsked$} from 'cycle-remote-data';
124 | import {makeDOMDriver, div, input, button} from '@cycle/dom';
125 | import {timeDriver} from '@cycle/time';
126 | import {run} from '@cycle/run';
127 | import xs from 'xstream';
128 |
129 | function GithubSearch(sources) {
130 | const query$ = sources.DOM
131 | .select('.search')
132 | .events('input')
133 | .map(ev => ev.target.value)
134 | .remember();
135 |
136 | const reload$ = sources.DOM
137 | .select('.reload')
138 | .events('click');
139 |
140 | const search$ = xs.merge(
141 | query$.compose(sources.Time.debounce(250)),
142 | reload$
143 | );
144 |
145 | const searchWithQuery$ = search$.map(() => query$.take(1)).flatten();
146 |
147 | const post$ = searchWithQuery$
148 | .map(query =>
149 | query === ''
150 | ? NotAsked$
151 | : sources.RemoteData.request({
152 | url: `https://api.github.com/search/repositories?q=${query}`,
153 | method: 'GET'
154 | })
155 | )
156 | .flatten()
157 | .map(remoteData => remoteData.rmap(response => response.body))
158 | .startWith(NotAsked);
159 |
160 | return {
161 | DOM: post$.map(view)
162 | }
163 | }
164 |
165 | function view(remotePost) {
166 | return div([
167 | 'Search github',
168 | input('.search'),
169 |
170 | remotePost.when({
171 | Loading: loadingView,
172 | Error: errorView,
173 | Ok: postsView,
174 | NotAsked: notAskedView
175 | })
176 | ]);
177 | }
178 |
179 | function errorView() {
180 | return div([
181 | 'Error loading content',
182 | button('.reload', "Reload")
183 | ])
184 | }
185 |
186 | function loadingView() {
187 | return div('Loading...')
188 | }
189 |
190 | function notAskedView() {
191 | return div([
192 | 'Search for something!'
193 | ])
194 | }
195 |
196 | function postsView(posts) {
197 | return div([
198 | JSON.stringify(posts)
199 | ])
200 | }
201 |
202 | const drivers = {
203 | DOM: makeDOMDriver(document.body),
204 | RemoteData: makeRemoteDataDriver(),
205 | Time: timeDriver
206 | }
207 |
208 | run(GithubSearch, drivers);
209 | ```
210 |
211 | ## API
212 |
213 | ### `makeRemoteDataDriver()`
214 |
215 | This is a function that returns a remote data driver. The remote data driver is also a function, that takes no arguments and returns a `RemoteDataSource`.
216 |
217 | ### `RemoteDataSource.request(requestOptions)`
218 |
219 | The `RemoteDataSource` contains a single function, `request`, which takes a single options argument. For documentation of the options you can pass, please see the [`@cycle/http` docs](https://cycle.js.org/api/http.html).
220 |
221 | `RemoteDataSource.request()` returns a `MemoryStream` of `RemoteData` states.
222 |
223 | ### `RemoteData`
224 |
225 | `RemoteData` is a generic interface with four possible constructors: Ok, Error, Loading and NotAsked. The only state directly available is `NotAsked`, so that it can be used by users.
226 |
227 | `RemoteData` is satisfied by two methods:
228 |
229 | #### `when(cases: Cases): U`
230 |
231 | Takes an object of cases. The following cases must be handled, with the provided signatures:
232 |
233 | ```ts
234 | interface Cases {
235 | Ok(t: T): U;
236 | Error(err: ResponseError): U;
237 | Loading(progress: number): U;
238 | NotAsked(): U;
239 | }
240 | ```
241 |
242 | A `ResponseError` is a superagent error which has a `Response` attached.
243 |
244 | If a case is not handled, a runtime error can occur. For this reason, it's recommended to use TypeScript in strict mode.
245 |
246 | #### `rmap(f: (t: T) => U): RemoteData`
247 |
248 | `rmap` is used for applying functions to the `Ok` value. A `RemoteData` would have an `Ok` value of type `T`. Applying a function that translates from `T => U` would return a `RemoteData`.
249 |
250 |
251 | ## FAQ
252 |
253 | **Doesn't this go against Cycle's idioms? You're using a source method for write effects‽**
254 |
255 | Yes, it does conflict with Cycle's current ideology. However, HTTP is a complex problem to model in Cycle, as it is a combination of intertwined read and write effects.
256 |
257 | I think it's important to be willing to try different approaches. Perhaps we will discover subtle tradeoffs that we never would have encountered if we weren't willing to think differently.
258 |
259 |
260 | **Maybe this is okay for GET requests, but do you seriously advocate using it for POST/PUT/DELETE/etc?**
261 |
262 | I'm personally not sure. I think it depends on what feedback you need to give the user on the outcome of the request.
263 |
264 | If it turns out that this feels very wrong, I'll consider handling POST/PUT/DELETE via sinks instead, like `@cycle/http`.
265 |
266 | ## Types
267 |
268 | `cycle-remote-data` provides a few useful type definitions.
269 |
270 | ```js
271 | import {RemoteDataSource, RemoteData, RemoteResponse} from 'cycle-remote-data';
272 | ```
273 |
274 | `RemoteResponse` is a shorthand for `RemoteData`, which is the type of the items in the stream return by `request`.
275 |
276 | ## Acknowledgments
277 |
278 | `cycle-remote-data` is inspired by the excellent [remotedata package](http://package.elm-lang.org/packages/krisajenkins/remotedata/4.3.3) for Elm, by [krisajenkins](https://github.com/krisajenkins).
279 |
280 |
281 | ## License
282 |
283 | MIT
284 |
285 |
--------------------------------------------------------------------------------