From 6d91c1d19f24fda1c803bfd077d5514ce69d6d0c Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Wed, 20 Dec 2023 11:01:24 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(react)=20add=20useControllableState?= =?UTF-8?q?=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This hook is used to create a state that can be controlled by the parent. If not the state is handled internally. We start to have this redundant use case across Cunningham, so creating a dedicated hook reduces the components verbosity and complexity. --- .changeset/flat-dogs-wink.md | 5 ++ .../src/hooks/useControllableState.spec.tsx | 75 +++++++++++++++++++ .../react/src/hooks/useControllableState.ts | 39 ++++++++++ 3 files changed, 119 insertions(+) create mode 100644 .changeset/flat-dogs-wink.md create mode 100644 packages/react/src/hooks/useControllableState.spec.tsx create mode 100644 packages/react/src/hooks/useControllableState.ts diff --git a/.changeset/flat-dogs-wink.md b/.changeset/flat-dogs-wink.md new file mode 100644 index 0000000..09c349c --- /dev/null +++ b/.changeset/flat-dogs-wink.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": minor +--- + +add useControllableState hook diff --git a/packages/react/src/hooks/useControllableState.spec.tsx b/packages/react/src/hooks/useControllableState.spec.tsx new file mode 100644 index 0000000..9a4333f --- /dev/null +++ b/packages/react/src/hooks/useControllableState.spec.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useControllableState } from ":/hooks/useControllableState"; +import { Button } from ":/components/Button"; + +describe("useControllableState", () => { + const TestComponent = (props: { + value?: string; + setValue?: (value: string) => void; + }) => { + const [value, setValue] = useControllableState( + "default", + props.value, + props.setValue, + ); + + return ( +
+
Controllable value: {value}|
+ +
+ ); + }; + + it("works non controlled", async () => { + render(); + + screen.getByText("Controllable value: default|"); + + // Change internal state. + const $button = screen.getByRole("button", { name: "Set value" }); + await userEvent.click($button); + screen.getByText("Controllable value: custom value|"); + }); + + it("work controlled", async () => { + const Wrapper = () => { + const [value, setValue] = React.useState("controlled value"); + return ( + <> + + +
Controlled value: {value}|
+ + ); + }; + render(); + + const user = userEvent.setup(); + + // Default value is controlled. + screen.getByText("Controllable value: controlled value|"); + screen.getByText("Controlled value: controlled value|"); + + // Set value from parent. + const $button = screen.getByRole("button", { + name: "Set controlled value", + }); + + await user.click($button); + screen.getByText("Controllable value: new controlled value|"); + screen.getByText("Controlled value: new controlled value|"); + + // Set value from child. + const $buttonNested = screen.getByRole("button", { + name: "Set value", + }); + await user.click($buttonNested); + screen.getByText("Controllable value: custom value|"); + screen.getByText("Controlled value: custom value|"); + }); +}); diff --git a/packages/react/src/hooks/useControllableState.ts b/packages/react/src/hooks/useControllableState.ts new file mode 100644 index 0000000..ae7581b --- /dev/null +++ b/packages/react/src/hooks/useControllableState.ts @@ -0,0 +1,39 @@ +import React, { useEffect } from "react"; + +/** + * This hook is used to create a state that can be controlled by the parent. If not the state is handled internally. + * + * @param defaultValue if not controlled by the parent, this is the default value + * @param propsValue if controlled by the parent, this is the value + * @param propsCallback if controlled by the parent, this is the callback to call when the value changes + */ +export const useControllableState = ( + defaultValue: T, + propsValue?: T, + propsCallback?: (value: T) => void, +): [T, (value: T) => void] => { + const [state, setState] = React.useState( + typeof propsValue === "undefined" ? defaultValue : propsValue, + ); + + // Bottom-Up. + const onChange = (value: T) => { + if (propsCallback) { + propsCallback(value); + } else { + setState(value); + } + }; + + // Top-Down. + useEffect(() => { + if (!propsCallback) { + return; + } + if (typeof propsValue !== "undefined" && propsValue !== state) { + setState(propsValue); + } + }, [propsValue]); + + return [state, onChange]; +};