Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
fc2dacd
commit 90343ea
Showing
9 changed files
with
898 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
built | ||
node_modules | ||
.DS_Store | ||
.vscode |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
language: node_js | ||
node_js: | ||
- "stable" | ||
- "4.1" | ||
- "4.0" | ||
- "0.12" | ||
- "0.11" | ||
- "0.10" | ||
before_install: | ||
- npm install -g typescript | ||
- npm install -g jasmine |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,140 @@ | ||
import { Map } from "immutable"; | ||
import { createStore, Unsubscribe } from "redux" | ||
|
||
export interface Action<Type, Payload> { | ||
type: Type, | ||
payload: Payload | ||
} | ||
|
||
export function action<State, Type, Payload>( | ||
type: Type, | ||
reduce: (state: State, payload: Payload) => State) { | ||
|
||
function create(payload: Payload) { | ||
return { type: type, payload }; | ||
} | ||
|
||
const meta = { type, reduce }; | ||
const result: typeof create & typeof meta = create as any; | ||
result.type = type; | ||
result.reduce = reduce; | ||
|
||
return result; | ||
} | ||
|
||
export interface Store<State, ActionType> { | ||
dispatch(action: ActionType): void; | ||
getState(): State; | ||
subscribe(listener: () => void): Unsubscribe; | ||
} | ||
|
||
export interface Cursor<State, ActionType> { | ||
(action: ActionType): void; | ||
(): State; | ||
} | ||
|
||
export function createStoreCursor<State, ActionType>( | ||
store: Store<State, ActionType> | ||
): Cursor<State, ActionType> { | ||
|
||
const dummyState = {} as State; | ||
|
||
return (action?: ActionType) => { | ||
if (action) { | ||
store.dispatch(action); | ||
return dummyState; | ||
} | ||
return store.getState(); | ||
}; | ||
} | ||
|
||
export interface Reducer<State, Types extends Action<string, any>> { | ||
|
||
(state: State, action: Types): State; | ||
|
||
add<TypeName extends string, Payload>(action: { | ||
type: TypeName, | ||
reduce: (state: State, action: Payload) => State | ||
}): Reducer<State, Types | Action<TypeName, Payload>>; | ||
|
||
createStore(): Store<State, Types>; | ||
|
||
readonly actionType: Types; | ||
readonly cursorType: Cursor<State, Types> | ||
} | ||
|
||
export function reducer<State>(init: State) { | ||
|
||
type ActionMap<State> = Map<string, (state: State, payload: any) => State>; | ||
|
||
function combine<State, Types extends Action<string, any>>( | ||
init: State, | ||
map: ActionMap<State> | ||
): Reducer<State, Types> { | ||
|
||
function reduce(state: State, action: Types) { | ||
if (state === undefined) { | ||
return init; | ||
} | ||
|
||
var handler = map.get(action.type as any); | ||
if (!handler) { | ||
throw new Error(`Unrecognised action: ${action.type}`); | ||
} | ||
return handler(state, action.payload); | ||
} | ||
|
||
function add<TypeName extends string, Payload>(action: { | ||
type: TypeName, | ||
reduce: (state: State, payload: Payload) => State | ||
}) { | ||
const type = action.type as string; | ||
if (map.has(type)) { | ||
throw new Error(`Duplicate action: ${type}`); | ||
} | ||
|
||
return combine<State, Types | Action<TypeName, Payload>>( | ||
init, map.set(type, action.reduce)); | ||
}; | ||
|
||
function store() { | ||
return createStore(reduce); | ||
} | ||
|
||
const meta = { | ||
add, | ||
createStore: store, | ||
actionType: {} as Types, | ||
cursorType: {} as Cursor<State, Types> | ||
}; | ||
|
||
const result: typeof reduce & typeof meta = reduce as any; | ||
result.add = add; | ||
result.createStore = store; | ||
|
||
return result; | ||
} | ||
|
||
return combine<State, never>(init, Map<any, any>()); | ||
} | ||
|
||
export function defineCursor<ContainerState, ContainerActionType, Address, TargetState, TargetActionType>( | ||
fetch: (container: ContainerState, address: Address) => TargetState, | ||
update: (address: Address, action: TargetActionType) => ContainerActionType | ||
) { | ||
const dummyState = {} as TargetState; | ||
|
||
return (container: Cursor<ContainerState, ContainerActionType>, | ||
address: Address, snapshot?: boolean): Cursor<TargetState, TargetActionType> => { | ||
|
||
const snapshotValue = snapshot ? fetch(container(), address) : dummyState; | ||
|
||
return (targetAction?: TargetActionType) => { | ||
if (targetAction) { | ||
container(update(address, targetAction)); | ||
return dummyState; | ||
} | ||
return snapshot ? snapshotValue : fetch(container(), address); | ||
}; | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
{ | ||
"name": "immuto", | ||
"version": "0.9.0", | ||
"description": "Very strongly typed Redux adaptation for TypeScript", | ||
"main": "built/index.js", | ||
"typings": "built/index.d.ts", | ||
"scripts": { | ||
"test": "echo \"Error: no test specified\" && exit 1" | ||
}, | ||
"repository": { | ||
"type": "git", | ||
"url": "git+https://github.com/danielearwicker/immuto.git" | ||
}, | ||
"keywords": [ | ||
"redux", | ||
"react", | ||
"typescript" | ||
], | ||
"author": "Daniel Earwicker <dan@earwicker.com>", | ||
"license": "MIT", | ||
"bugs": { | ||
"url": "https://github.com/danielearwicker/immuto/issues" | ||
}, | ||
"homepage": "https://github.com/danielearwicker/immuto#readme", | ||
"dependencies": { | ||
"immutable": "^3.8.1", | ||
"redux": "^3.5.2" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,180 @@ | ||
import { Action, Reducer, Store, action, reducer, defineCursor, createStoreCursor } from "../index"; | ||
import { List } from "immutable"; | ||
|
||
export class Book { | ||
|
||
constructor( | ||
public readonly title: string, | ||
public readonly price: number, | ||
public readonly authors: List<string>) { | ||
} | ||
|
||
static readonly setTitle = action<Book, "SET_TITLE", string>( | ||
"SET_TITLE", (book, title) => new Book(title, book.price, book.authors)); | ||
|
||
static readonly setPrice = action<Book, "SET_PRICE", number>( | ||
"SET_PRICE", (book, price) => new Book(book.title, price, book.authors)); | ||
|
||
static readonly addAuthor = action<Book, "SET_AUTHOR", string>( | ||
"SET_AUTHOR", (book, author) => new Book(book.title, book.price, book.authors.push(author))); | ||
|
||
static readonly reduce = reducer<Book>(new Book("", 0, List<string>())) | ||
.add(Book.setTitle) | ||
.add(Book.setPrice) | ||
.add(Book.addAuthor); | ||
} | ||
|
||
class Shelf { | ||
|
||
constructor( | ||
public readonly description: string, | ||
public readonly books: List<Book>) { | ||
} | ||
|
||
static readonly setDescription = action<Shelf, "SET_DESCRIPTION", string>( | ||
"SET_DESCRIPTION", (shelf, descr) => new Shelf(descr, shelf.books)); | ||
|
||
static readonly addBook = action<Shelf, "ADD_BOOK", string>( | ||
"ADD_BOOK", (shelf, title) => new Shelf(shelf.description, shelf.books.push(new Book(title, 0, List<string>())))); | ||
|
||
static readonly updateBook = action<Shelf, "UPDATE_BOOK", [number, typeof Book.reduce.actionType]>( | ||
"UPDATE_BOOK", (shelf, args) => new Shelf(shelf.description, shelf.books.update(args[0], b => Book.reduce(b, args[1])))); | ||
|
||
static readonly reduce = reducer<Shelf>(new Shelf("", List<Book>())) | ||
.add(Shelf.setDescription) | ||
.add(Shelf.addBook) | ||
.add(Shelf.updateBook); | ||
|
||
static readonly bookAt = defineCursor( | ||
(shelf: Shelf, pos: number) => shelf.books.get(pos), | ||
(pos: number, bookAction: typeof Book.reduce.actionType) => Shelf.updateBook([pos, bookAction])); | ||
} | ||
|
||
class Shop { | ||
constructor( | ||
public readonly name: string, | ||
public readonly shelves: List<Shelf>) { | ||
} | ||
|
||
static readonly setName = action<Shop, "SET_NAME", string>( | ||
"SET_NAME", (shop, name) => new Shop(name, shop.shelves)); | ||
|
||
static readonly addShelf = action<Shop, "ADD_SHELF", string>( | ||
"ADD_SHELF", (shop, name) => new Shop(shop.name, shop.shelves.push(new Shelf(name, List<Book>())))); | ||
|
||
static readonly updateShelf = action<Shop, "UPDATE_SHELF", [number, typeof Shelf.reduce.actionType]>( | ||
"UPDATE_SHELF", (shop, args) => new Shop(shop.name, shop.shelves.update(args[0], s => Shelf.reduce(s, args[1])))); | ||
|
||
static readonly reduce = reducer<Shop>(new Shop("", List<Shelf>())) | ||
.add(Shop.setName) | ||
.add(Shop.addShelf) | ||
.add(Shop.updateShelf); | ||
|
||
static readonly shelfAt = defineCursor( | ||
(shop: Shop, pos: number) => shop.shelves.get(pos), | ||
(pos: number, shelfAction: typeof Shelf.reduce.actionType) => Shop.updateShelf([pos, shelfAction])); | ||
} | ||
|
||
const enableLogging = false; | ||
|
||
function logStore<State, Types>(store: Store<State, Types>) { | ||
if (enableLogging) { | ||
store.subscribe(() => { | ||
console.log(""); | ||
console.log(JSON.stringify(store.getState())); | ||
console.log(""); | ||
}); | ||
} | ||
return store; | ||
} | ||
|
||
describe("immuto", () => { | ||
|
||
it("has an initial state available via cursor", () => { | ||
|
||
const book = createStoreCursor(logStore(Book.reduce.createStore()))(); | ||
|
||
expect(book.title).toEqual(""); | ||
expect(book.price).toEqual(0); | ||
expect(book.authors.count()).toEqual(0); | ||
|
||
expect(JSON.stringify(book)).toEqual(`{"title":"","price":0,"authors":[]}`); | ||
}); | ||
|
||
it("can be updated via late-bound cursors", () => { | ||
|
||
const shelf = createStoreCursor(logStore(Shelf.reduce.createStore())); | ||
shelf(Shelf.setDescription("Romance")); | ||
|
||
expect(JSON.stringify(shelf())).toEqual(`{"description":"Romance","books":[]}`); | ||
|
||
// Create cursor here, before adding book! | ||
const firstBook = Shelf.bookAt(shelf, 0); | ||
|
||
shelf(Shelf.addBook("1985")); | ||
|
||
expect(JSON.stringify(shelf())).toEqual(`{"description":"Romance","books":[{"title":"1985","price":0,"authors":[]}]}`); | ||
|
||
expect(firstBook().title).toEqual("1985"); | ||
expect(firstBook().price).toEqual(0); | ||
|
||
firstBook(Book.setPrice(5.99)); | ||
|
||
expect(JSON.stringify(shelf())).toEqual(`{"description":"Romance","books":[{"title":"1985","price":5.99,"authors":[]}]}`); | ||
|
||
expect(firstBook().price).toEqual(5.99); | ||
}); | ||
|
||
it("can be updated via snapshot cursors", () => { | ||
|
||
const shelf = createStoreCursor(logStore(Shelf.reduce.createStore())); | ||
shelf(Shelf.setDescription("Romance")); | ||
shelf(Shelf.addBook("1985")); | ||
|
||
expect(JSON.stringify(shelf())).toEqual(`{"description":"Romance","books":[{"title":"1985","price":0,"authors":[]}]}`); | ||
|
||
// ask for snapshot cursor, so have to do it after adding book | ||
const firstBook = Shelf.bookAt(shelf, 0, true); | ||
|
||
expect(firstBook().title).toEqual("1985"); | ||
expect(firstBook().price).toEqual(0); | ||
|
||
firstBook(Book.setPrice(5.99)); | ||
|
||
expect(JSON.stringify(shelf())).toEqual(`{"description":"Romance","books":[{"title":"1985","price":5.99,"authors":[]}]}`); | ||
|
||
// firstBook is a snapshot so doesn't see the change | ||
expect(firstBook().price).toEqual(0); | ||
|
||
// take another shapshot | ||
const firstBookAgain = Shelf.bookAt(shelf, 0, true); | ||
expect(firstBookAgain().price).toEqual(5.99); | ||
}); | ||
|
||
it("supports nested layers of cursors", () => { | ||
|
||
const shop = createStoreCursor(logStore(Shop.reduce.createStore())); | ||
shop(Shop.setName("Buy the Book, Inc.")); | ||
|
||
shop(Shop.addShelf("Adventure")); | ||
|
||
const firstShelf = Shop.shelfAt(shop, 0); | ||
expect(firstShelf().description).toEqual("Adventure"); | ||
|
||
firstShelf(Shelf.addBook("Indiana Smith")); | ||
|
||
expect(JSON.stringify(shop())).toEqual(`{"name":"Buy the Book, Inc.","shelves":[{"description":"Adventure","books":[{"title":"Indiana Smith","price":0,"authors":[]}]}]}`); | ||
|
||
const firstBookOfFirstShelf = Shelf.bookAt(firstShelf, 0); | ||
expect(firstBookOfFirstShelf().title).toEqual("Indiana Smith"); | ||
|
||
firstBookOfFirstShelf(Book.setPrice(4.99)); | ||
firstBookOfFirstShelf(Book.addAuthor("Jim Orwell")); | ||
|
||
expect(JSON.stringify(shop())).toEqual(`{"name":"Buy the Book, Inc.","shelves":[{"description":"Adventure","books":[{"title":"Indiana Smith","price":4.99,"authors":["Jim Orwell"]}]}]}`); | ||
|
||
expect(firstBookOfFirstShelf().title).toEqual("Indiana Smith"); | ||
expect(firstBookOfFirstShelf().price).toEqual(4.99); | ||
expect(firstBookOfFirstShelf().authors.first()).toEqual("Jim Orwell"); | ||
}); | ||
}); |
Oops, something went wrong.