Skip to content
Merged
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
70 changes: 62 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ This library brings the elmish pattern to react.
- [Subscriptions](#subscriptions)
- [Working with external sources of events](#working-with-external-sources-of-events)
- [Cleanup subscriptions](#cleanup-subscriptions)
- [Immutability](#immutability)
- [Testing](#testing)
- [Setup](#setup)
- [Error handling](#error-handling)
- [React life cycle management](#react-life-cycle-management)
Expand All @@ -26,7 +28,7 @@ This library brings the elmish pattern to react.
- [With an `UpdateMap`](#with-an-updatemap)
- [With an update function](#with-an-update-function)
- [Merge multiple subscriptions](#merge-multiple-subscriptions)
- [Testing](#testing)
- [Testing](#testing-1)
- [Testing the init function](#testing-the-init-function)
- [Testing the update handler](#testing-the-update-handler)
- [Combine update and execCmd](#combine-update-and-execcmd)
Expand Down Expand Up @@ -456,6 +458,58 @@ function subscription (model: Model): SubscriptionResult<Message> {

The destructor is called when the component is removed from the DOM.

## Immutability

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.

```tsx
import { useElmish } from "react-elmish/immutable";

function App(props: Props): JSX.Element {
const [model, dispatch] = useElmish({ props, init, update, name: "App" });

model.value = 42; // This will throw an error

return (
// ...
);
}
```

You can simply update the draft of the model like this:

```ts
import { type UpdateMap } from "react-elmish/immutable";

const updateMap: UpdateMap<Props, Model, Message> = {
increment(_msg, model) {
model.value += 1;

return [];
},

decrement(_msg, model) {
model.value -= 1;

return [];
},

commandOnly() {
// This will not update the model but only dispatch a command
return [cmd.ofMsg(Msg.increment())];
},

doNothing() {
// This does nothing
return [];
},
};
```

### Testing

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.

## Setup

**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.
Expand Down Expand Up @@ -912,7 +966,7 @@ const subscription = mergeSubscriptions(LoadSettings.subscription, localSubscrip

## Testing

To test your **update** handler you can use some helper functions in `react-elmish/dist/Testing`:
To test your **update** handler you can use some helper functions in `react-elmish/testing`:

| Function | Description |
| --- | --- |
Expand All @@ -926,7 +980,7 @@ To test your **update** handler you can use some helper functions in `react-elmi
### Testing the init function

```ts
import { initAndExecCmd } from "react-elmish/dist/Testing";
import { initAndExecCmd } from "react-elmish/testing";
import { init, Msg } from "./MyComponent";

it("initializes the model correctly", async () => {
Expand All @@ -947,7 +1001,7 @@ it("initializes the model correctly", async () => {
**Note**: When using an `UpdateMap`, you can get an `update` function by calling `getUpdateFn`:

```ts
import { getUpdateFn } from "react-elmish/dist/Testing";
import { getUpdateFn } from "react-elmish/testing";
import { updateMap } from "./MyComponent";

const updateFn = getUpdateFn(updateMap);
Expand All @@ -959,7 +1013,7 @@ const [model, cmd] = updateFn(msg, model, props);
A simple test:

```ts
import { getCreateUpdateArgs, createUpdateArgsFactory, execCmd } from "react-elmish/dist/Testing";
import { getCreateUpdateArgs, createUpdateArgsFactory, execCmd } from "react-elmish/testing";
import { init, Msg } from "./MyComponent";

const createUpdateArgs = getCreateUpdateArgs(init, () => ({ /* initial props */ }));
Expand Down Expand Up @@ -992,7 +1046,7 @@ It also resolves for `attempt` functions if the called functions succeed. And it
There is an alternative function `getUpdateAndExecCmdFn` to get the `update` function for an update map, which immediately invokes the command and returns the messages.

```ts
import { createUpdateArgs, getUpdateAndExecCmdFn } from "react-elmish/dist/Testing";
import { createUpdateArgs, getUpdateAndExecCmdFn } from "react-elmish/testing";

const updateAndExecCmdFn = getUpdateAndExecCmdFn(updateMap);

Expand All @@ -1019,7 +1073,7 @@ it("returns the correct cmd", async () => {
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:

```ts
import { getCreateModelAndProps, execSubscription } from "react-elmish/dist/Testing";
import { getCreateModelAndProps, execSubscription } from "react-elmish/testing";
import { init, Msg, subscription } from "./MyComponent";

const createModelAndProps = getCreateModelAndProps(init, () => ({ /* initial props */ }));
Expand All @@ -1046,7 +1100,7 @@ it("dispatches the eventTriggered message", async () => {
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.

```tsx
import { renderWithModel } from "react-elmish/dist/Testing";
import { renderWithModel } from "react-elmish/testing";
import { fireEvent, render, screen } from "@testing-library/react";

it("renders the correct value", () => {
Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
"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",
"semantic-release": "semantic-release"
},
"dependencies": {
"immer": "10.1.1"
},
"peerDependencies": {
"react": ">=16.8.0 <20"
},
Expand Down Expand Up @@ -53,6 +56,11 @@
"files": [
"dist/**/*"
],
"main": "dist/index.js",
"exports": {
".": "./dist/index.js",
"./testing": "./dist/testing/index.js",
"./immutable": "./dist/immutable/index.js",
"./immutable/testing": "./dist/immutable/testing/index.js"
},
"types": "dist/index.d.ts"
}
17 changes: 17 additions & 0 deletions src/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ type UpdateMap<TProps, TModel, TMessage extends Message> = {
) => UpdateReturnType<TModel, TMessage>;
};

/**
* The return type of the `subscription` function.
* @template TMessage The type of the messages discriminated union.
*/
type SubscriptionResult<TMessage> = [Cmd<TMessage>, (() => void)?] | SubscriptionFunction<TMessage>[];
type SubscriptionFunction<TMessage> = (dispatch: Dispatch<TMessage>) => (() => void) | undefined;
type Subscription<TProps, TModel, TMessage> = (model: TModel, props: TProps) => SubscriptionResult<TMessage>;

function subscriptionIsFunctionArray(subscription: SubscriptionResult<unknown>): subscription is SubscriptionFunction<unknown>[] {
return typeof subscription[0] === "function";
}

export type {
CallBaseFunction,
Cmd,
Expand All @@ -91,9 +103,14 @@ export type {
MsgSource,
Nullable,
Sub,
Subscription,
SubscriptionFunction,
SubscriptionResult,
UpdateFunction,
UpdateFunctionOptions,
UpdateMap,
UpdateMapFunction,
UpdateReturnType,
};

export { subscriptionIsFunctionArray };
71 changes: 71 additions & 0 deletions src/immutable/ElmComponent.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { render, type RenderResult } from "@testing-library/react";
import type { JSX } from "react";
import { cmd } from "../cmd";
import type { Cmd } from "../Types";
import { ElmComponent } from "./ElmComponent";
import type { UpdateReturnType } from "./Types";

describe("ElmComponent", () => {
it("calls the init function", () => {
// arrange
const init = jest.fn().mockReturnValue([{}, []]);
const update = jest.fn();
const props: Props = {
init,
update,
};

// act
renderComponent(props);

// assert
expect(init).toHaveBeenCalledWith(props);
});

it("calls the initial command", () => {
// arrange
const message: Message = { name: "Test" };
const init = jest.fn().mockReturnValue([{ value: 42 }, cmd.ofMsg(message)]);
const update = jest.fn((): UpdateReturnType<Message> => []);
const props: Props = {
init,
update,
};

// act
renderComponent(props);

// assert
expect(update).toHaveBeenCalledTimes(1);
});
});

interface Message {
name: "Test";
}

interface Model {
value: number;
}

interface Props {
init: () => [Model, Cmd<Message>];
update: (model: Model, msg: Message, props: Props) => UpdateReturnType<Message>;
}

class TestComponent extends ElmComponent<Model, Message, Props> {
public constructor(props: Props) {
super(props, props.init, "Test");
}

public update = this.props.update;

// eslint-disable-next-line @typescript-eslint/class-methods-use-this
public override render(): JSX.Element {
return <div />;
}
}

function renderComponent(props: Props): RenderResult {
return render(<TestComponent {...props} />);
}
Loading