Skip to content
This repository has been archived by the owner on May 25, 2022. It is now read-only.

Improve backwards compatibility #15

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
109 changes: 64 additions & 45 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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<Payload = undefined, Meta = undefined> = {
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: State, action?: FSA) => State;
// Ducks define FSA actions
export type ActionCreator<
A extends FSA<any, any, any> = FSA<any, any, any>
> = A extends FSAWithPayloadAndMeta<any, any, any>
? (a: A['payload'], b: A['meta']) => A
: A extends FSAWithPayload<any, any, any>
? (a: A['payload']) => A
: A extends FSA
? (a?: A['payload'], b?: A['meta']) => A
: never;

type Case<State> = {
[type: string]: CaseFn<State>;
// Ducks can listen for any actions (FSA or not)
export type ActionHandlers<S = any, A extends Action = AnyAction> = {
[T in A['type']]?: (x: S, y: Extract<A, { type: T }>) => S;
};

export interface DuckBuilder<AppAction extends Action = AnyAction> {
defineType: (a: ActionName) => ActionType;
createAction: <T extends string & AppAction['type']>(
a: T,
b?: boolean
) => ActionCreator<Extract<AppAction, FSA<T, any, any>>>;
createReducer: <S>(
a: ActionHandlers<S, AppAction>,
b: S
) => Reducer<S, AppAction>;
}

const defaultAction: FSA = { type: '@@INVALID' };

function validateCases(cases: ActionType[]): void {
Expand All @@ -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<State>(cases: Case<State>, 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<Payload, Meta = undefined>(
type: ActionType,
isError = false
) {
return function actionCreator(
payload?: Payload,
meta?: Meta
): FSA<Payload, Meta> {
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 };
};
},
};
}
20 changes: 8 additions & 12 deletions test/index.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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<undefined, undefined>(type);
const action = duck.createAction(type);

const reducer = duck.createReducer<CountState>(
const reducer = duck.createReducer(
{
[type]: state => {
return {
Expand All @@ -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', () => {
Expand Down
135 changes: 135 additions & 0 deletions test/types.test.ts
Original file line number Diff line number Diff line change
@@ -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<AlfredActions.SLEEP>;

type WinnieAction =
| FSA<WinnieActions.GO_TO_WORK>
| FSA<WinnieActions.RETURN_HOME>;

// Duck Initialization

const initialAlfred: AlfredState = {
belly: [],
happy: true,
};
const initialWinnie: WinnieState = {
home: true,
};

// Global Configuration

type PondAction = AlfredAction | WinnieAction;

type PondDuckBuilder = DuckBuilder<PondAction>;

// 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<WinnieState>(
{
[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<AlfredState>(
{
[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();
});
});
20 changes: 20 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down