Skip to content

Commit 134bc1f

Browse files
authored
Merge pull request #55 from atheck/atheck/issue52
Atheck/issue52
2 parents 5b51e72 + 6a2fa81 commit 134bc1f

37 files changed

+1521
-56
lines changed

README.md

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ This library brings the elmish pattern to react.
1717
- [Subscriptions](#subscriptions)
1818
- [Working with external sources of events](#working-with-external-sources-of-events)
1919
- [Cleanup subscriptions](#cleanup-subscriptions)
20+
- [Immutability](#immutability)
21+
- [Testing](#testing)
2022
- [Setup](#setup)
2123
- [Error handling](#error-handling)
2224
- [React life cycle management](#react-life-cycle-management)
@@ -26,7 +28,7 @@ This library brings the elmish pattern to react.
2628
- [With an `UpdateMap`](#with-an-updatemap)
2729
- [With an update function](#with-an-update-function)
2830
- [Merge multiple subscriptions](#merge-multiple-subscriptions)
29-
- [Testing](#testing)
31+
- [Testing](#testing-1)
3032
- [Testing the init function](#testing-the-init-function)
3133
- [Testing the update handler](#testing-the-update-handler)
3234
- [Combine update and execCmd](#combine-update-and-execcmd)
@@ -456,6 +458,58 @@ function subscription (model: Model): SubscriptionResult<Message> {
456458
457459
The destructor is called when the component is removed from the DOM.
458460
461+
## Immutability
462+
463+
If you want to use immutable data structures, you can use the imports from "react-elmish/immutable". This version of the `useElmish` hook returns an immutable model.
464+
465+
```tsx
466+
import { useElmish } from "react-elmish/immutable";
467+
468+
function App(props: Props): JSX.Element {
469+
const [model, dispatch] = useElmish({ props, init, update, name: "App" });
470+
471+
model.value = 42; // This will throw an error
472+
473+
return (
474+
// ...
475+
);
476+
}
477+
```
478+
479+
You can simply update the draft of the model like this:
480+
481+
```ts
482+
import { type UpdateMap } from "react-elmish/immutable";
483+
484+
const updateMap: UpdateMap<Props, Model, Message> = {
485+
increment(_msg, model) {
486+
model.value += 1;
487+
488+
return [];
489+
},
490+
491+
decrement(_msg, model) {
492+
model.value -= 1;
493+
494+
return [];
495+
},
496+
497+
commandOnly() {
498+
// This will not update the model but only dispatch a command
499+
return [cmd.ofMsg(Msg.increment())];
500+
},
501+
502+
doNothing() {
503+
// This does nothing
504+
return [];
505+
},
506+
};
507+
```
508+
509+
### Testing
510+
511+
If you want to test your component with immutable data structures, you can use the `react-elmish/testing/immutable` module. This module provides the same functions as the normal testing module.
512+
459513
## Setup
460514
461515
**react-elmish** works without a setup. But if you want to use logging or some middleware, you can setup **react-elmish** at the start of your program.
@@ -912,7 +966,7 @@ const subscription = mergeSubscriptions(LoadSettings.subscription, localSubscrip
912966
913967
## Testing
914968
915-
To test your **update** handler you can use some helper functions in `react-elmish/dist/Testing`:
969+
To test your **update** handler you can use some helper functions in `react-elmish/testing`:
916970
917971
| Function | Description |
918972
| --- | --- |
@@ -926,7 +980,7 @@ To test your **update** handler you can use some helper functions in `react-elmi
926980
### Testing the init function
927981
928982
```ts
929-
import { initAndExecCmd } from "react-elmish/dist/Testing";
983+
import { initAndExecCmd } from "react-elmish/testing";
930984
import { init, Msg } from "./MyComponent";
931985

932986
it("initializes the model correctly", async () => {
@@ -947,7 +1001,7 @@ it("initializes the model correctly", async () => {
9471001
**Note**: When using an `UpdateMap`, you can get an `update` function by calling `getUpdateFn`:
9481002
9491003
```ts
950-
import { getUpdateFn } from "react-elmish/dist/Testing";
1004+
import { getUpdateFn } from "react-elmish/testing";
9511005
import { updateMap } from "./MyComponent";
9521006

9531007
const updateFn = getUpdateFn(updateMap);
@@ -959,7 +1013,7 @@ const [model, cmd] = updateFn(msg, model, props);
9591013
A simple test:
9601014
9611015
```ts
962-
import { getCreateUpdateArgs, createUpdateArgsFactory, execCmd } from "react-elmish/dist/Testing";
1016+
import { getCreateUpdateArgs, createUpdateArgsFactory, execCmd } from "react-elmish/testing";
9631017
import { init, Msg } from "./MyComponent";
9641018

9651019
const createUpdateArgs = getCreateUpdateArgs(init, () => ({ /* initial props */ }));
@@ -992,7 +1046,7 @@ It also resolves for `attempt` functions if the called functions succeed. And it
9921046
There is an alternative function `getUpdateAndExecCmdFn` to get the `update` function for an update map, which immediately invokes the command and returns the messages.
9931047
9941048
```ts
995-
import { createUpdateArgs, getUpdateAndExecCmdFn } from "react-elmish/dist/Testing";
1049+
import { createUpdateArgs, getUpdateAndExecCmdFn } from "react-elmish/testing";
9961050

9971051
const updateAndExecCmdFn = getUpdateAndExecCmdFn(updateMap);
9981052

@@ -1019,7 +1073,7 @@ it("returns the correct cmd", async () => {
10191073
It is almost the same as testing the `update` function. You can use the `getCreateModelAndProps` function to create a factory for the model and the props. Then use `execSubscription` to execute the subscriptions:
10201074
10211075
```ts
1022-
import { getCreateModelAndProps, execSubscription } from "react-elmish/dist/Testing";
1076+
import { getCreateModelAndProps, execSubscription } from "react-elmish/testing";
10231077
import { init, Msg, subscription } from "./MyComponent";
10241078

10251079
const createModelAndProps = getCreateModelAndProps(init, () => ({ /* initial props */ }));
@@ -1046,7 +1100,7 @@ it("dispatches the eventTriggered message", async () => {
10461100
To test UI components with a fake model you can use `renderWithModel` from the Testing namespace. The first parameter is a function to render your component (e.g. with **@testing-library/react**). The second parameter is the fake model. The third parameter is an optional options object, where you can also pass a fake `dispatch` function.
10471101
10481102
```tsx
1049-
import { renderWithModel } from "react-elmish/dist/Testing";
1103+
import { renderWithModel } from "react-elmish/testing";
10501104
import { fireEvent, render, screen } from "@testing-library/react";
10511105

10521106
it("renders the correct value", () => {

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
"update": "npx -y npm-check-updates -i --install never && npx -y npm-check-updates -i --target minor --install never && npx -y npm-check-updates -i --target patch --install never && npm update",
1515
"semantic-release": "semantic-release"
1616
},
17+
"dependencies": {
18+
"immer": "10.1.1"
19+
},
1720
"peerDependencies": {
1821
"react": ">=16.8.0 <20"
1922
},
@@ -53,6 +56,11 @@
5356
"files": [
5457
"dist/**/*"
5558
],
56-
"main": "dist/index.js",
59+
"exports": {
60+
".": "./dist/index.js",
61+
"./testing": "./dist/testing/index.js",
62+
"./immutable": "./dist/immutable/index.js",
63+
"./immutable/testing": "./dist/immutable/testing/index.js"
64+
},
5765
"types": "dist/index.d.ts"
5866
}

src/Types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ type UpdateMap<TProps, TModel, TMessage extends Message> = {
7979
) => UpdateReturnType<TModel, TMessage>;
8080
};
8181

82+
/**
83+
* The return type of the `subscription` function.
84+
* @template TMessage The type of the messages discriminated union.
85+
*/
86+
type SubscriptionResult<TMessage> = [Cmd<TMessage>, (() => void)?] | SubscriptionFunction<TMessage>[];
87+
type SubscriptionFunction<TMessage> = (dispatch: Dispatch<TMessage>) => (() => void) | undefined;
88+
type Subscription<TProps, TModel, TMessage> = (model: TModel, props: TProps) => SubscriptionResult<TMessage>;
89+
90+
function subscriptionIsFunctionArray(subscription: SubscriptionResult<unknown>): subscription is SubscriptionFunction<unknown>[] {
91+
return typeof subscription[0] === "function";
92+
}
93+
8294
export type {
8395
CallBaseFunction,
8496
Cmd,
@@ -91,9 +103,14 @@ export type {
91103
MsgSource,
92104
Nullable,
93105
Sub,
106+
Subscription,
107+
SubscriptionFunction,
108+
SubscriptionResult,
94109
UpdateFunction,
95110
UpdateFunctionOptions,
96111
UpdateMap,
97112
UpdateMapFunction,
98113
UpdateReturnType,
99114
};
115+
116+
export { subscriptionIsFunctionArray };
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { render, type RenderResult } from "@testing-library/react";
2+
import type { JSX } from "react";
3+
import { cmd } from "../cmd";
4+
import type { Cmd } from "../Types";
5+
import { ElmComponent } from "./ElmComponent";
6+
import type { UpdateReturnType } from "./Types";
7+
8+
describe("ElmComponent", () => {
9+
it("calls the init function", () => {
10+
// arrange
11+
const init = jest.fn().mockReturnValue([{}, []]);
12+
const update = jest.fn();
13+
const props: Props = {
14+
init,
15+
update,
16+
};
17+
18+
// act
19+
renderComponent(props);
20+
21+
// assert
22+
expect(init).toHaveBeenCalledWith(props);
23+
});
24+
25+
it("calls the initial command", () => {
26+
// arrange
27+
const message: Message = { name: "Test" };
28+
const init = jest.fn().mockReturnValue([{ value: 42 }, cmd.ofMsg(message)]);
29+
const update = jest.fn((): UpdateReturnType<Message> => []);
30+
const props: Props = {
31+
init,
32+
update,
33+
};
34+
35+
// act
36+
renderComponent(props);
37+
38+
// assert
39+
expect(update).toHaveBeenCalledTimes(1);
40+
});
41+
});
42+
43+
interface Message {
44+
name: "Test";
45+
}
46+
47+
interface Model {
48+
value: number;
49+
}
50+
51+
interface Props {
52+
init: () => [Model, Cmd<Message>];
53+
update: (model: Model, msg: Message, props: Props) => UpdateReturnType<Message>;
54+
}
55+
56+
class TestComponent extends ElmComponent<Model, Message, Props> {
57+
public constructor(props: Props) {
58+
super(props, props.init, "Test");
59+
}
60+
61+
public update = this.props.update;
62+
63+
// eslint-disable-next-line @typescript-eslint/class-methods-use-this
64+
public override render(): JSX.Element {
65+
return <div />;
66+
}
67+
}
68+
69+
function renderComponent(props: Props): RenderResult {
70+
return render(<TestComponent {...props} />);
71+
}

0 commit comments

Comments
 (0)