Skip to content

Commit

Permalink
feat(TaskGroup): add TaskGroup feature
Browse files Browse the repository at this point in the history
Allows grouping the state of multiple tasks.
  • Loading branch information
jeffijoe committed Sep 18, 2019
1 parent e202309 commit 5262d6b
Show file tree
Hide file tree
Showing 10 changed files with 288 additions and 29 deletions.
3 changes: 1 addition & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ notifications:
# Add additional versions here as appropriate.
node_js:
- 'stable'
- '10'
- '8'
- '7'
- '6'

# Lint errors should trigger a failure.
before_script:
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 2.0.0

- TypeScript definitions for public API based on the DefinitelyTyped ones.
- Ported codebase to basic TypeScript. A lot of exotic things going on, so definitely not idiomatic.
- Added `TaskGroup` for combining task result reactions into a single state.

## 1.0.4

- Add name argument for `setState` action ([#18](https://github.com/jeffijoe/mobx-task/pull/18) by [@cyclops26](https://github.com/cyclops26))
Expand Down
74 changes: 71 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@
[![Coveralls](https://img.shields.io/coveralls/jeffijoe/mobx-task.svg?maxAge=1000)](https://coveralls.io/github/jeffijoe/mobx-task)
[![npm](https://img.shields.io/npm/dt/mobx-task.svg?maxAge=1000)](https://www.npmjs.com/package/mobx-task)
[![npm](https://img.shields.io/npm/l/mobx-task.svg?maxAge=1000)](https://github.com/jeffijoe/mobx-task/blob/master/LICENSE.md)
[![node](https://img.shields.io/node/v/mobx-task.svg?maxAge=1000)](https://www.npmjs.com/package/mobx-task)
[![JavaScript Style Guide](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](http://standardjs.com/)
[![TypeScript definitions on DefinitelyTyped](https://definitelytyped.org/badges/standard-flat.svg)](http://definitelytyped.org)

Takes the suck out of managing state for async functions in MobX.

Expand All @@ -21,6 +18,7 @@ Table of Contents
* [Full example with classes and decorators](#full-example-with-classes-and-decorators)
* [Full example with plain observables](#full-example-with-plain-observables)
* [How does it work?](#how-does-it-work)
* [Task Groups](#task-groups)
* [API documentation](#api-documentation)
* [The task factory](#the-task-factory)
* [As a decorator](#as-a-decorator)
Expand All @@ -35,6 +33,7 @@ Table of Contents
* [`setState()`](#setstate)
* [`bind()`](#bind)
* [`reset()`](#reset)
* [`TaskGroup`](#taskgroup)
* [Gotchas](#gotchas)
* [Wrapping the task function](#wrapping-the-task-function)
* [Using the decorator on React Components](#using-the-decorator-on-react-components)
Expand Down Expand Up @@ -248,6 +247,54 @@ autorun(() => {
func().then(todos => { /*...*/ })
```

# Task Groups

<small>since `mobx-task v2.0.0` </small>

A `TaskGroup` is useful when you want to track pending, resolved and rejected state for multiple tasks but treat them as one.

Under the hood, a `TaskGroup` reacts to the start of any of the tasks (when `pending` flips to `true`), tracks the latest started task, and proxies all getters
to it. The first `pending` task (or the first task in the input array, if none are `pending`) is used as the initial task to proxy to.

**IMPORTANT**: Running the tasks concurrently will lead to wonky results. The intended use is for
tracking `pending`, `resolved` and `rejected` states of the _last run task_. You should prevent your users from
concurrently running tasks in the group.

```js
import { task, TaskGroup } from 'mobx-task'

const toggleTodo = task.resolved((id) => api.toggleTodo(id))
const deleteTodo = task.resolved((id) => { throw new Error('deleting todos is for quitters') })

const actions = TaskGroup([
toggleTodo,
deleteTodo
])

autorun(() => {
const whatsGoingOn = actions.match({
pending: () => 'Todo is being worked on',
resolved: () => 'Todo is ready to be worked on',
rejected: (err) => `Something failed on the todo: ${err.message}`
})
console.log(whatsGoingOn)
})

// initial log from autorun setup
// <- Todo is ready to be worked on

await toggleTodo('some-id')

// <- Todo is being worked on
// ...
// <- Todo is ready to be worked on

await deleteTodo('some-id')
// <- Todo is being worked on
// ...
// <- Something failed on the todo: deleting todos is for quitters
```

# API documentation

There's only a single exported member; `task`.
Expand Down Expand Up @@ -453,6 +500,27 @@ Resets the state to what it was when the task was initialized.

This means if you use `const t = task.resolved(fn)`, calling `t.reset()` will set the state to `resolved`.

## `TaskGroup`

Creates a `TaskGroup`. Takes an array of tasks to track. Implements the readable parts of the `Task`.

Uses the first task in the array as the proxy target.

```js
import { task, TaskGroup } from 'mobx-task'

const task1 = task(() => 42)
const task2 = task(() => 1337)
const group = TaskGroup([
task1,
task2
])

console.log(group.state)
console.log(group.resolved)
console.log(group.result)
```

# Gotchas

## Wrapping the task function
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mobx-task",
"version": "1.0.4",
"version": "0.0.0-development",
"description": "Removes boilerplate of tracking when an async function is running for MobX.",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
Expand All @@ -26,7 +26,7 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/jeffijoe/mobx-task.git"
"url": "https://github.com/jeffijoe/mobx-task.git"
},
"keywords": [
"async",
Expand Down Expand Up @@ -62,7 +62,7 @@
"mobx": "^5.13.0",
"prettier": "^1.17.1",
"rimraf": "^3.0.0",
"semantic-release": "^15.13.12",
"semantic-release": "^15.13.24",
"smid": "^0.1.1",
"ts-jest": "^24.0.2",
"tslint": "^5.16.0",
Expand Down
6 changes: 0 additions & 6 deletions src/__tests__/.eslintrc

This file was deleted.

96 changes: 96 additions & 0 deletions src/__tests__/task-group.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { TaskGroup } from '../task-group'
import { task } from '../task'
import { defer } from './defer'
import { throws } from 'smid'
import { reaction } from 'mobx'

// tslint:disable:await-promise no-floating-promises

test('task group', async () => {
const deferred1 = defer<number>()
const deferred2 = defer<number>()
const deferred3 = defer<number>()
const task1 = task.resolved(() => deferred1.promise)
const task2 = task.resolved(() => deferred2.promise)
const task3 = task.resolved(() => deferred3.promise)
const group = TaskGroup([task1, task2, task3])

const reactor = jest.fn()
reaction(
() =>
group.match({
pending: () => 'pending',
resolved: () => 'resolved',
rejected: () => 'rejected'
}),
v => reactor(v)
)

expect(group.state).toBe('resolved')
expect(group.resolved).toBe(true)
expect(group.match({ resolved: () => true })).toBe(true)

const p1 = task1()
expect(group.state).toBe('pending')
expect(expect(group.match({ pending: () => true })).toBe(true))

deferred1.resolve(123)
expect(await p1).toBe(123)
expect(group.state).toBe('resolved')
expect(expect(group.match({ resolved: () => true })).toBe(true))

const p3 = task3()
expect(group.state).toBe('pending')

deferred3.reject(new Error('nah'))
expect(await throws(p3)).toMatchObject({ message: 'nah' })

expect(reactor).toHaveBeenCalledTimes(4)
expect(reactor).toHaveBeenCalledWith('pending')
expect(reactor).toHaveBeenCalledWith('resolved')
expect(reactor).toHaveBeenCalledWith('rejected')
})

test('sets the initial task to the first pending found in the input', async () => {
const deferred1 = defer<number>()
const deferred2 = defer<number>()
const deferred3 = defer<number>()
const task1 = task.resolved(() => deferred1.promise)
const task2 = task(() => deferred2.promise)
const task3 = task.resolved(() => deferred3.promise)
const group = TaskGroup([task1, task2, task3])

expect(group.pending).toBe(true)

deferred2.resolve(2)
await task2()
expect(group.resolved).toBe(true)
})

test('only switches task on pending change', async () => {
const deferred1 = defer<number>()
const deferred2 = defer<number>()
const deferred3 = defer<number>()
const task1 = task.resolved(() => deferred1.promise)
const task2 = task.resolved(() => deferred2.promise)
const task3 = task.resolved(() => deferred3.promise)
const group = TaskGroup([task1, task2, task3])

const p1 = task1()
const p2 = task2()
deferred1.resolve(1)

await p1
expect(group.pending).toBe(true)

deferred2.resolve(2)
await p2
expect(group.resolved).toBe(true)
expect(group.match({ resolved: v => v * 10 })).toBe(20)
})

test('invalid tasks length', () => {
expect(() => TaskGroup([])).toThrowErrorMatchingInlineSnapshot(
`"TaskGroup: there must be at least one task in the array passed to TaskGroup."`
)
})
61 changes: 61 additions & 0 deletions src/task-group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Task } from './task'
import { observable, reaction, computed } from 'mobx'

type QueryableMethods =
| 'state'
| 'pending'
| 'resolved'
| 'rejected'
| 'result'
| 'error'
| 'match'

/**
* The keys that are being proxied.
*/
const keysToDefinePropertiesFor: Array<QueryableMethods> = [
'state',
'pending',
'resolved',
'rejected',
'result',
'error',
'match'
]

/**
* Task Group contains queryable properties of the task.
*/
export type TaskGroup<A extends any[], R> = Pick<Task<A, R>, QueryableMethods>

/**
* Creates a group of tasks where the state of the task that last change its' state
* is used.
*
* @param tasks
*/
export function TaskGroup<A extends any[], R>(
tasks: Array<Task<A, R>>
): TaskGroup<A, R> {
if (!tasks || tasks.length === 0) {
throw new TypeError(
'TaskGroup: there must be at least one task in the array passed to TaskGroup.'
)
}
const initialTask = tasks.find(t => t.pending) || tasks[0]
const latestTask = observable.box(initialTask, {
defaultDecorator: observable.ref
})
tasks.forEach(t => {
reaction(() => t.pending === true, pending => pending && latestTask.set(t))
})
const group: any = {}
keysToDefinePropertiesFor.forEach(key => {
const c = computed(() => latestTask.get()[key])
Object.defineProperty(group, key, {
configurable: false,
get: () => c.get()
})
})
return group
}
15 changes: 10 additions & 5 deletions src/task.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { observable, action } from 'mobx'
import { proxyGetters, promiseTry } from './utils'

/**
* Returns a type without the promise wrapper.
*/
export type WithoutPromise<T> = T extends Promise<infer P> ? P : T

/**
* Task status.
*/
Expand All @@ -17,7 +22,7 @@ export type TaskFunc<A extends any[], R> = (...args: A) => Promise<R>
export interface TaskOptions<A extends any[], R> {
state?: TaskStatus
error?: unknown
result?: R
result?: WithoutPromise<R>
args?: A
swallow?: boolean
}
Expand All @@ -28,7 +33,7 @@ export interface TaskOptions<A extends any[], R> {
export interface TaskMatchProps<T1, T2, T3, A extends any[], R = any> {
pending?: (...args: A) => T1
rejected?: (error: unknown) => T2
resolved?: (result: R) => T3
resolved?: (result: WithoutPromise<R>) => T3
}

/**
Expand Down Expand Up @@ -58,7 +63,7 @@ export interface TaskState<A extends any[], R> {
/**
* The result of the last invocation.
*/
readonly result?: R
readonly result?: WithoutPromise<R>
/**
* The error of the last failed invocation.
*/
Expand Down Expand Up @@ -96,7 +101,7 @@ export interface TaskMethods<A extends any[], R> {
/**
* Task function, state and methods.
*/
export type Task<A extends any[], R> = TaskFunc<A, R> &
export type Task<A extends any[], R> = TaskFunc<A, WithoutPromise<R>> &
TaskState<A, R> &
TaskMethods<A, R>

Expand Down Expand Up @@ -179,7 +184,7 @@ function createTask<A extends any[], R>(
;(task as Task<A, R>).setState({
state: 'resolved',
error: undefined,
result
result: result as WithoutPromise<R>
})
}
return result
Expand Down
2 changes: 0 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
"target": "es2015",
"module": "commonjs",
"moduleResolution": "node",
"importHelpers": true,
"noEmitHelpers": true,
"sourceMap": true,
"declaration": true,
"skipLibCheck": true,
Expand Down
Loading

0 comments on commit 5262d6b

Please sign in to comment.