diff --git a/package.json b/package.json index 7bdf37b..ab21db3 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,9 @@ "homepage": "https://github.com/sergiodxa/redux-duck/", "devDependencies": { "@types/jest": "^24.0.23", + "flux-standard-action": "^2.1.1", "husky": "^3.1.0", + "redux": "^4.0.4", "tsdx": "^0.11.0", "tslib": "^1.10.0", "typescript": "^3.7.2" diff --git a/src/index.ts b/src/index.ts index 00643e6..1ae450d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,44 @@ -type AppName = string; -type DuckName = string; -type ActionName = string; -type ActionType = string; +import { Reducer, Action, AnyAction } from 'redux'; +import { + FSA, + FSAWithPayload, + FSAWithPayloadAndMeta, +} from 'flux-standard-action'; -export type FSA = { - type: ActionType; - payload?: Payload; - meta?: Meta; - error?: boolean; -}; +export type AppName = string; +export type DuckName = string; +export type ActionName = string; + +export type ActionType = string; // AppName/DuckName/ActionName or just DuckName/ActionName -type CaseFn = (state: State, action?: FSA) => State; +// Ducks define FSA actions +export type ActionCreator< + A extends FSA = FSA +> = A extends FSAWithPayloadAndMeta + ? (a: A['payload'], b: A['meta']) => A + : A extends FSAWithPayload + ? (a: A['payload']) => A + : A extends FSA + ? (a?: A['payload'], b?: A['meta']) => A + : never; -type Case = { - [type: string]: CaseFn; +// Ducks can listen for any actions (FSA or not) +export type ActionHandlers = { + [T in A['type']]?: (x: S, y: Extract) => S; }; +export interface DuckBuilder { + defineType: (a: ActionName) => ActionType; + createAction: ( + a: T, + b?: boolean + ) => ActionCreator>>; + createReducer: ( + a: ActionHandlers, + b: S + ) => Reducer; +} + const defaultAction: FSA = { type: '@@INVALID' }; function validateCases(cases: ActionType[]): void { @@ -40,40 +63,36 @@ function validateCases(cases: ActionType[]): void { } } -export function createDuck(name: DuckName, app?: AppName) { - function defineType(type: ActionName): ActionType { - if (app) { - return `${app}/${name}/${type}`; - } - return `${name}/${type}`; - } - - function createReducer(cases: Case, defaultState: State) { - validateCases(Object.keys(cases)); - - return function reducer(state = defaultState, action = defaultAction) { - for (const caseName in cases) { - if (action.type === caseName) return cases[caseName](state, action); +export function createDuck(name: DuckName, app?: AppName): DuckBuilder { + return { + defineType: function(type) { + if (app) { + return `${app}/${name}/${type}`; } - return state; - }; - } + return `${name}/${type}`; + }, - function createAction( - type: ActionType, - isError = false - ) { - return function actionCreator( - payload?: Payload, - meta?: Meta - ): FSA { - return { type, payload, error: isError, meta }; - }; - } + createReducer: function(cases, defaultState) { + validateCases(Object.keys(cases)); - return { - defineType, - createReducer, - createAction, + return function reducer(state = defaultState, action = defaultAction) { + for (const caseName in cases) { + if (action.type === caseName) { + const handler = cases[caseName]; + if (typeof handler === 'undefined') { + throw new Error(`missing handler for ${caseName}`); + } + return handler(state, action); + } + } + return state; + }; + }, + + createAction: function(type, isError = false) { + return function actionCreator(payload?, meta?) { + return { type, payload, error: isError, meta }; + }; + }, }; } diff --git a/test/index.test.ts b/test/index.test.ts index 1d6afc7..d71674f 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,7 +1,6 @@ +import { AnyAction } from 'redux'; import { createDuck } from '../src'; -type CountState = { count: number }; - describe('Redux Duck', () => { describe('Define Type', () => { test('Without App Name', () => { @@ -22,9 +21,7 @@ describe('Redux Duck', () => { const duck = createDuck('duck-name', 'app-name'); const type = duck.defineType('action-name'); - const action = duck.createAction<{ id: number }, { analytics: string }>( - type - ); + const action = duck.createAction(type); expect(typeof action).toBe('function'); expect(action()).toEqual({ type, @@ -50,10 +47,7 @@ describe('Redux Duck', () => { const duck = createDuck('duck-name', 'app-name'); const type = duck.defineType('action-name'); - const action = duck.createAction<{ id: number }, { analytics: string }>( - type, - true - ); + const action = duck.createAction(type, true); expect(typeof action).toBe('function'); expect(action()).toEqual({ type, @@ -79,9 +73,9 @@ describe('Redux Duck', () => { test('Create Reducer', () => { const duck = createDuck('duck-name', 'app-name'); const type = duck.defineType('action-name'); - const action = duck.createAction(type); + const action = duck.createAction(type); - const reducer = duck.createReducer( + const reducer = duck.createReducer( { [type]: state => { return { @@ -94,7 +88,9 @@ describe('Redux Duck', () => { expect(typeof reducer).toBe('function'); expect(reducer(undefined, action())).toEqual({ count: 1 }); - expect(reducer({ count: 2 })).toEqual({ count: 2 }); + expect(reducer({ count: 2 }, (undefined as unknown) as AnyAction)).toEqual({ + count: 2, + }); }); describe('Errors', () => { diff --git a/test/types.test.ts b/test/types.test.ts new file mode 100644 index 0000000..692fc4f --- /dev/null +++ b/test/types.test.ts @@ -0,0 +1,135 @@ +// This example code explains how to setup app "Pond" with two ducks, "Alfred" and "Winnie" + +import { FSAAuto as FSA } from 'flux-standard-action'; + +import { createDuck, DuckBuilder } from '../src'; + +type Food = string; + +// State Interfaces + +export interface AlfredState { + readonly belly: Food[]; + readonly happy: boolean; +} + +export interface WinnieState { + readonly home: boolean; +} + +// Action Types + +enum AlfredActions { + EAT = 'pond/alfred/EAT', + SLEEP = 'pond/alfred/SLEEP', +} +enum WinnieActions { + GO_TO_WORK = 'pond/winnie/GO_TO_WORK', + RETURN_HOME = 'pond/winnie/RETURN_HOME', +} + +// Action Structures + +type AlfredAction = + | FSA< + AlfredActions.EAT, + { + readonly food: Food; + }, + { + comment: string; + } + > + | FSA; + +type WinnieAction = + | FSA + | FSA; + +// Duck Initialization + +const initialAlfred: AlfredState = { + belly: [], + happy: true, +}; +const initialWinnie: WinnieState = { + home: true, +}; + +// Global Configuration + +type PondAction = AlfredAction | WinnieAction; + +type PondDuckBuilder = DuckBuilder; + +// Duck Builders + +const alfredBuilder: PondDuckBuilder = createDuck('alfred', 'pond'); +const winnieBuilder: PondDuckBuilder = createDuck('winnie', 'pond'); + +// Ducks + +// In practice Alfred and Winnie should be their on modules. +// Here we just define an object for the respective exports. +// +// The module exports should match the "Redux Reducer Bundles" +// specification https://github.com/erikras/ducks-modular-redux +// +// TypeScript modules can not currently implement interfaces +// https://github.com/microsoft/TypeScript/issues/420 + +const winnieExports = { + WinnieActions, + default: winnieBuilder.createReducer( + { + [WinnieActions.GO_TO_WORK]: state => ({ + ...state, + home: false, + }), + [WinnieActions.RETURN_HOME]: state => ({ + ...state, + home: true, + }), + // Alfred doesn't export any action types so + // Winnie's state can not change based on them. + }, + initialWinnie + ), + goToWork: winnieBuilder.createAction(WinnieActions.GO_TO_WORK), + returnHome: winnieBuilder.createAction(WinnieActions.RETURN_HOME), +}; + +const alfredExports = { + default: alfredBuilder.createReducer( + { + [AlfredActions.EAT]: (state, { payload: { food } }) => ({ + ...state, + belly: [...state.belly, food], + }), + [AlfredActions.SLEEP]: state => ({ + ...state, + belly: [], + }), + // Winnie's action types are exported and + // can therefore cause changes in Alfred. + [winnieExports.WinnieActions.GO_TO_WORK]: state => ({ + ...state, + happy: false, + }), + [winnieExports.WinnieActions.RETURN_HOME]: state => ({ + ...state, + happy: true, + }), + }, + initialAlfred + ), + eat: alfredBuilder.createAction(AlfredActions.EAT), + sleep: alfredBuilder.createAction(AlfredActions.SLEEP), +}; + +describe('Redux Duck', () => { + test('Type Definitions', () => { + expect(winnieExports).toBeDefined(); + expect(alfredExports).toBeDefined(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 8d8ccf0..3c477ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2493,6 +2493,13 @@ flatted@^2.0.0: resolved "https://registry.able.co/flatted/-/flatted-2.0.1.tgz#69e57caa8f0eacbc281d2e2cb458d46fdb449e08" integrity sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg== +flux-standard-action@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/flux-standard-action/-/flux-standard-action-2.1.1.tgz#b2e204145ea8e4294d6b0f178d8e8c416802948f" + integrity sha512-W86GzmXmIiTVq/dpYVd2HtTIUX9c35Iq3ao3xR6qcKtuXgbu+BDEj72op5VnEIe/kpuSbhl+I8kT1iS2hpcusw== + dependencies: + lodash "^4.17.15" + for-in@^1.0.2: version "1.0.2" resolved "https://registry.able.co/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -4713,6 +4720,14 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" +redux@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.4.tgz#4ee1aeb164b63d6a1bcc57ae4aa0b6e6fa7a3796" + integrity sha512-vKv4WdiJxOWKxK0yRoaK3Y4pxxB0ilzVx6dszU2W8wLxlb2yikRph4iV/ymtdJ6ZxpBLFbyrxklnT5yBbQSl3Q== + dependencies: + loose-envify "^1.4.0" + symbol-observable "^1.2.0" + regenerate-unicode-properties@^8.1.0: version "8.1.0" resolved "https://registry.able.co/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz#ef51e0f0ea4ad424b77bf7cb41f3e015c70a3f0e" @@ -5486,6 +5501,11 @@ supports-color@^6.1.0: dependencies: has-flag "^3.0.0" +symbol-observable@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== + symbol-tree@^3.2.2: version "3.2.4" resolved "https://registry.able.co/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"