From 61b2689864e108fe7b4e7a7e5d46d06e63f9e43e Mon Sep 17 00:00:00 2001 From: Jon Koops Date: Tue, 30 Nov 2021 14:07:44 +0100 Subject: [PATCH] Add useToggle hook to manage toggleable state (#1600) --- package-lock.json | 61 +++++++++++++++++++ package.json | 1 + src/PageHeader.tsx | 12 +--- src/authentication/AuthenticationSection.tsx | 5 +- src/authentication/FlowDetails.tsx | 5 +- .../components/EditFlowDropdown.tsx | 2 +- .../components/FlowRequirementDropdown.tsx | 2 +- .../components/modals/AddSubFlowModal.tsx | 6 +- src/authentication/form/FlowType.tsx | 4 +- src/client-scopes/ChangeTypeDropdown.tsx | 2 +- src/client-scopes/ClientScopesSection.tsx | 4 +- src/client-scopes/add/MapperDialog.test.tsx | 11 ++-- src/client-scopes/details/ScopeForm.tsx | 4 +- src/client-scopes/details/SearchFilter.tsx | 9 +-- src/client-scopes/form/ClientScopeForm.tsx | 7 ++- src/clients/AdvancedTab.tsx | 2 +- src/clients/ClientDetails.tsx | 20 +++--- src/clients/ClientSettings.tsx | 4 +- src/clients/add/GeneralSettings.tsx | 4 +- src/clients/add/SamlConfig.tsx | 2 +- src/clients/add/SamlSignature.tsx | 6 +- src/clients/advanced/AdvancedSettings.tsx | 2 +- .../advanced/AuthenticationOverrides.tsx | 4 +- .../advanced/FineGrainOpenIdConnect.tsx | 22 +++---- src/clients/advanced/TokenLifespan.tsx | 2 +- src/clients/credentials/Credentials.tsx | 2 +- src/clients/credentials/SignedJWT.tsx | 4 +- src/clients/keys/GenerateKeyDialog.tsx | 2 +- src/clients/keys/ImportKeyDialog.tsx | 2 +- src/clients/keys/Keys.tsx | 13 ++-- src/clients/scopes/AddScopeDialog.tsx | 29 ++++----- src/utils/useToggle.test.ts | 41 +++++++++++++ src/utils/useToggle.ts | 13 ++++ 33 files changed, 197 insertions(+), 112 deletions(-) create mode 100644 src/utils/useToggle.test.ts create mode 100644 src/utils/useToggle.ts diff --git a/package-lock.json b/package-lock.json index d9fb6d8471..f8c84e8c07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@testing-library/cypress": "^8.0.2", "@testing-library/jest-dom": "^5.15.1", "@testing-library/react": "^12.1.1", + "@testing-library/react-hooks": "^7.0.2", "@types/dagre": "^0.7.45", "@types/file-saver": "^2.0.4", "@types/lodash": "^4.14.177", @@ -4483,6 +4484,35 @@ "react-dom": "*" } }, + "node_modules/@testing-library/react-hooks": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz", + "integrity": "sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/react": ">=16.9.0", + "@types/react-dom": ">=16.9.0", + "@types/react-test-renderer": ">=16.9.0", + "react-error-boundary": "^3.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0", + "react-test-renderer": ">=16.9.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-test-renderer": { + "optional": true + } + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -5002,6 +5032,15 @@ "@types/react-router": "*" } }, + "node_modules/@types/react-test-renderer": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz", + "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -24660,6 +24699,19 @@ "@testing-library/dom": "^8.0.0" } }, + "@testing-library/react-hooks": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz", + "integrity": "sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@types/react": ">=16.9.0", + "@types/react-dom": ">=16.9.0", + "@types/react-test-renderer": ">=16.9.0", + "react-error-boundary": "^3.1.0" + } + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -25176,6 +25228,15 @@ "@types/react-router": "*" } }, + "@types/react-test-renderer": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz", + "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", diff --git a/package.json b/package.json index 842af35541..1f95bcb4c1 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@testing-library/cypress": "^8.0.2", "@testing-library/jest-dom": "^5.15.1", "@testing-library/react": "^12.1.1", + "@testing-library/react-hooks": "^7.0.2", "@types/dagre": "^0.7.45", "@types/file-saver": "^2.0.4", "@types/lodash": "^4.14.177", diff --git a/src/PageHeader.tsx b/src/PageHeader.tsx index 3e0983082a..b6a875ae7a 100644 --- a/src/PageHeader.tsx +++ b/src/PageHeader.tsx @@ -132,16 +132,12 @@ export const Header = () => { const KebabDropdown = () => { const [isDropdownOpen, setDropdownOpen] = useState(false); - const onDropdownToggle = () => { - setDropdownOpen(!isDropdownOpen); - }; - return ( } + toggle={} isOpen={isDropdownOpen} dropdownItems={kebabDropdownItems} /> @@ -152,10 +148,6 @@ export const Header = () => { const { whoAmI } = useWhoAmI(); const [isDropdownOpen, setDropdownOpen] = useState(false); - const onDropdownToggle = () => { - setDropdownOpen(!isDropdownOpen); - }; - return ( { id="user-dropdown" isOpen={isDropdownOpen} toggle={ - + {whoAmI.getDisplayName()} } diff --git a/src/authentication/AuthenticationSection.tsx b/src/authentication/AuthenticationSection.tsx index 248f9dd6c6..d5604fa163 100644 --- a/src/authentication/AuthenticationSection.tsx +++ b/src/authentication/AuthenticationSection.tsx @@ -24,6 +24,7 @@ import { useRealm } from "../context/realm-context/RealmContext"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { useAlerts } from "../components/alert/Alerts"; import { toUpperCase } from "../util"; +import useToggle from "../utils/useToggle"; import { DuplicateFlowModal } from "./DuplicateFlowModal"; import { toCreateFlow } from "./routes/CreateFlow"; import { toFlow } from "./routes/Flow"; @@ -55,7 +56,7 @@ export default function AuthenticationSection() { const { addAlert, addError } = useAlerts(); const [selectedFlow, setSelectedFlow] = useState(); - const [open, setOpen] = useState(false); + const [open, toggleOpen, setOpen] = useToggle(); const loader = async () => { const clients = await adminClient.clients.find(); @@ -202,7 +203,7 @@ export default function AuthenticationSection() { setOpen(!open)} + toggleDialog={toggleOpen} onComplete={() => { refresh(); setOpen(false); diff --git a/src/authentication/FlowDetails.tsx b/src/authentication/FlowDetails.tsx index c33cae1d2c..25a0c7ea2a 100644 --- a/src/authentication/FlowDetails.tsx +++ b/src/authentication/FlowDetails.tsx @@ -40,6 +40,7 @@ import { AddSubFlowModal, Flow } from "./components/modals/AddSubFlowModal"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { DuplicateFlowModal } from "./DuplicateFlowModal"; import { useRealm } from "../context/realm-context/RealmContext"; +import useToggle from "../utils/useToggle"; import { toAuthentication } from "./routes/Authentication"; import { EditFlowModal } from "./EditFlowModal"; @@ -69,7 +70,7 @@ export default function FlowDetails() { const [showAddSubFlowDialog, setShowSubFlowDialog] = useState(); const [selectedExecution, setSelectedExecution] = useState(); - const [open, setOpen] = useState(false); + const [open, toggleOpen, setOpen] = useToggle(); const [edit, setEdit] = useState(false); useFetch( @@ -280,7 +281,7 @@ export default function FlowDetails() { setOpen(!open)} + toggleDialog={toggleOpen} onComplete={() => { refresh(); setOpen(false); diff --git a/src/authentication/components/EditFlowDropdown.tsx b/src/authentication/components/EditFlowDropdown.tsx index 056e881577..27a21a16fd 100644 --- a/src/authentication/components/EditFlowDropdown.tsx +++ b/src/authentication/components/EditFlowDropdown.tsx @@ -47,7 +47,7 @@ export const EditFlowDropdown = ({ data-testid={`${execution.displayName}-edit-dropdown`} isOpen={open} toggle={ - setOpen(open)}> + } diff --git a/src/authentication/components/FlowRequirementDropdown.tsx b/src/authentication/components/FlowRequirementDropdown.tsx index 7d739a63f1..fa052458d6 100644 --- a/src/authentication/components/FlowRequirementDropdown.tsx +++ b/src/authentication/components/FlowRequirementDropdown.tsx @@ -28,7 +28,7 @@ export const FlowRequirementDropdown = ({ setOpen(!open)} + onToggle={setOpen} onSelect={(_, value) => { onChange(value as string); setOpen(false); @@ -194,9 +194,9 @@ export const AddSubFlowModal = ({ setOpen(!open)} + onToggle={setOpen} onSelect={(_, value) => { - onChange(value as string); + onChange(value.toString()); setOpen(false); }} selections={t(`top-level-flow-type.${value}`)} diff --git a/src/client-scopes/ChangeTypeDropdown.tsx b/src/client-scopes/ChangeTypeDropdown.tsx index 0bc261801e..8528087c29 100644 --- a/src/client-scopes/ChangeTypeDropdown.tsx +++ b/src/client-scopes/ChangeTypeDropdown.tsx @@ -37,7 +37,7 @@ export const ChangeTypeDropdown = ({ selections={[]} isDisabled={selectedRows.length === 0} placeholderText={t("changeTypeTo")} - onToggle={(isExpanded) => setOpen(isExpanded)} + onToggle={setOpen} onSelect={async (_, value) => { try { await Promise.all( diff --git a/src/client-scopes/ClientScopesSection.tsx b/src/client-scopes/ClientScopesSection.tsx index 342ded148f..54330fe14b 100644 --- a/src/client-scopes/ClientScopesSection.tsx +++ b/src/client-scopes/ClientScopesSection.tsx @@ -221,9 +221,7 @@ export default function ClientScopesSection() { setKebabOpen(!kebabOpen)} /> - } + toggle={} isOpen={kebabOpen} isPlain dropdownItems={[ diff --git a/src/client-scopes/add/MapperDialog.test.tsx b/src/client-scopes/add/MapperDialog.test.tsx index a1cf6b3aab..074b793715 100644 --- a/src/client-scopes/add/MapperDialog.test.tsx +++ b/src/client-scopes/add/MapperDialog.test.tsx @@ -1,7 +1,7 @@ /** * @jest-environment jsdom */ -import React, { useState } from "react"; +import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; import { Button } from "@patternfly/react-core"; @@ -9,6 +9,7 @@ import type { ServerInfoRepresentation } from "@keycloak/keycloak-admin-client/l import type WhoAmIRepresentation from "@keycloak/keycloak-admin-client/lib/defs/whoAmIRepresentation"; import { ServerInfoContext } from "../../context/server-info/ServerInfoProvider"; import serverInfo from "../../context/server-info/__tests__/mock.json"; +import useToggle from "../../utils/useToggle"; import { AddMapperDialog, AddMapperDialogModalProps } from "./MapperDialog"; import { WhoAmI, WhoAmIContext } from "../../context/whoami/WhoAmI"; @@ -16,7 +17,7 @@ import whoami from "../../context/whoami/__tests__/mock-whoami.json"; describe("MapperDialog", () => { const Test = (args: AddMapperDialogModalProps) => { - const [open, setOpen] = useState(false); + const [open, toggleOpen, setOpen] = useToggle(); return ( { whoAmI: new WhoAmI(whoami as WhoAmIRepresentation), }} > - setOpen(!open)} - /> + diff --git a/src/client-scopes/details/ScopeForm.tsx b/src/client-scopes/details/ScopeForm.tsx index fd7589f2c9..40290cb95f 100644 --- a/src/client-scopes/details/ScopeForm.tsx +++ b/src/client-scopes/details/ScopeForm.tsx @@ -142,7 +142,7 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { variant={SelectVariant.single} isOpen={openType} selections={value} - onToggle={() => setOpenType(!openType)} + onToggle={setOpenType} onSelect={(_, value) => { onChange(value); setOpenType(false); @@ -173,7 +173,7 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { setOpen(open)} + onToggle={setOpen} isOpen={open} selections={[ type === AllClientScopes.none @@ -142,7 +139,7 @@ export const SearchToolbar = ({ setLoginThemeOpen(!loginThemeOpen)} + onToggle={setLoginThemeOpen} onSelect={(_, value) => { - onChange(value as string); + onChange(value.toString()); setLoginThemeOpen(false); }} selections={value || t("common:choose")} diff --git a/src/clients/add/GeneralSettings.tsx b/src/clients/add/GeneralSettings.tsx index aa1b0abc56..9409a07ef4 100644 --- a/src/clients/add/GeneralSettings.tsx +++ b/src/clients/add/GeneralSettings.tsx @@ -42,9 +42,9 @@ export const GeneralSettings = () => { render={({ onChange, value }) => ( setNameFormatOpen(open)} + onToggle={setNameFormatOpen} onSelect={(_, value) => { onChange(value.toString()); setNameFormatOpen(false); diff --git a/src/clients/add/SamlSignature.tsx b/src/clients/add/SamlSignature.tsx index 2ba93eefc3..9fe71d7e68 100644 --- a/src/clients/add/SamlSignature.tsx +++ b/src/clients/add/SamlSignature.tsx @@ -85,7 +85,7 @@ export const SamlSignature = () => { render={({ onChange, value }) => ( setKeyOpen(open)} + onToggle={setKeyOpen} onSelect={(_, value) => { onChange(value.toString()); setKeyOpen(false); @@ -167,7 +167,7 @@ export const SamlSignature = () => { render={({ onChange, value }) => ( setOpen(!open)} + onToggle={setOpen} isOpen={open} onSelect={(_, value) => { onChange(value); diff --git a/src/clients/advanced/AuthenticationOverrides.tsx b/src/clients/advanced/AuthenticationOverrides.tsx index 9206a42148..fe342a6f22 100644 --- a/src/clients/advanced/AuthenticationOverrides.tsx +++ b/src/clients/advanced/AuthenticationOverrides.tsx @@ -76,7 +76,7 @@ export const AuthenticationOverrides = ({ setDirectGrantOpen(!directGrantOpen)} + onToggle={setDirectGrantOpen} isOpen={directGrantOpen} onSelect={(_, value) => { onChange(value); diff --git a/src/clients/advanced/FineGrainOpenIdConnect.tsx b/src/clients/advanced/FineGrainOpenIdConnect.tsx index 8942b35dd2..089f6ea9cb 100644 --- a/src/clients/advanced/FineGrainOpenIdConnect.tsx +++ b/src/clients/advanced/FineGrainOpenIdConnect.tsx @@ -125,7 +125,7 @@ export const FineGrainOpenIdConnect = ({ setIdTokenOpen(!idTokenOpen)} + onToggle={setIdTokenOpen} isOpen={idTokenOpen} onSelect={(_, value) => { onChange(value); @@ -189,9 +189,7 @@ export const FineGrainOpenIdConnect = ({ setIdTokenContentOpen(!idTokenContentOpen)} + onToggle={setIdTokenContentOpen} isOpen={idTokenContentOpen} onSelect={(_, value) => { onChange(value); @@ -255,9 +253,7 @@ export const FineGrainOpenIdConnect = ({ - setRequestObjectSignatureOpen(!requestObjectSignatureOpen) - } + onToggle={setRequestObjectSignatureOpen} isOpen={requestObjectSignatureOpen} onSelect={(_, value) => { onChange(value); @@ -323,9 +317,7 @@ export const FineGrainOpenIdConnect = ({ setOpen(isExpanded)} + onToggle={setOpen} isOpen={open} onSelect={(_, value) => { onChange(value); diff --git a/src/clients/credentials/Credentials.tsx b/src/clients/credentials/Credentials.tsx index ef7732ad30..e5698df349 100644 --- a/src/clients/credentials/Credentials.tsx +++ b/src/clients/credentials/Credentials.tsx @@ -155,7 +155,7 @@ export const Credentials = ({ clientId, save }: CredentialsProps) => { isOpen(!open)} + onToggle={isOpen} onSelect={(_, value) => { - onChange(value as string); + onChange(value.toString()); isOpen(false); }} selections={value || t("anyAlgorithm")} diff --git a/src/clients/keys/GenerateKeyDialog.tsx b/src/clients/keys/GenerateKeyDialog.tsx index d0349afdc9..eeb858e22d 100644 --- a/src/clients/keys/GenerateKeyDialog.tsx +++ b/src/clients/keys/GenerateKeyDialog.tsx @@ -61,7 +61,7 @@ export const KeyForm = ({ render={({ onChange, value }) => ( setOpenArchiveFormat(!openArchiveFormat)} + onToggle={setOpenArchiveFormat} onSelect={(_, value) => { onChange(value as string); setOpenArchiveFormat(false); diff --git a/src/clients/keys/Keys.tsx b/src/clients/keys/Keys.tsx index 385aba4c50..cd6d8f9e8d 100644 --- a/src/clients/keys/Keys.tsx +++ b/src/clients/keys/Keys.tsx @@ -26,6 +26,7 @@ import type { ClientForm } from "../ClientDetails"; import { GenerateKeyDialog } from "./GenerateKeyDialog"; import { useFetch, useAdminClient } from "../../context/auth/AdminClient"; import { useAlerts } from "../../components/alert/Alerts"; +import useToggle from "../../utils/useToggle"; import { ImportKeyDialog, ImportFile } from "./ImportKeyDialog"; import { Certificate } from "./Certificate"; @@ -47,8 +48,9 @@ export const Keys = ({ clientId, save }: KeysProps) => { const { addAlert, addError } = useAlerts(); const [keyInfo, setKeyInfo] = useState(); - const [openGenerateKeys, setOpenGenerateKeys] = useState(false); - const [openImportKeys, setOpenImportKeys] = useState(false); + const [openGenerateKeys, toggleOpenGenerateKeys, setOpenGenerateKeys] = + useToggle(); + const [openImportKeys, toggleOpenImportKeys, setOpenImportKeys] = useToggle(); const useJwksUrl = useWatch({ control, @@ -104,15 +106,12 @@ export const Keys = ({ clientId, save }: KeysProps) => { {openGenerateKeys && ( setOpenGenerateKeys(!openGenerateKeys)} + toggleDialog={toggleOpenGenerateKeys} save={generate} /> )} {openImportKeys && ( - setOpenImportKeys(!openImportKeys)} - save={importKey} - /> + )} diff --git a/src/clients/scopes/AddScopeDialog.tsx b/src/clients/scopes/AddScopeDialog.tsx index 7d2d92fba2..1852b7dc7d 100644 --- a/src/clients/scopes/AddScopeDialog.tsx +++ b/src/clients/scopes/AddScopeDialog.tsx @@ -26,9 +26,10 @@ import { clientScopeTypesDropdown, } from "../../components/client-scope/ClientScopeTypes"; import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable"; +import { getProtocolName } from "../utils"; +import useToggle from "../../utils/useToggle"; import "./client-scopes.css"; -import { getProtocolName } from "../utils"; export type AddScopeDialogProps = { clientScopes: ClientScopeRepresentation[]; @@ -68,11 +69,11 @@ export const AddScopeDialog = ({ const [key, setKey] = useState(0); const refresh = () => setKey(key + 1); - const [isFilterTypeDropdownOpen, setIsFilterTypeDropdownOpen] = - useState(false); + const [isFilterTypeDropdownOpen, toggleIsFilterTypeDropdownOpen] = + useToggle(); - const [isProtocolTypeDropdownOpen, setIsProtocolTypeDropdownOpen] = - useState(false); + const [isProtocolTypeDropdownOpen, toggleIsProtocolTypeDropdownOpen] = + useToggle(false); useEffect(() => { refresh(); @@ -97,14 +98,6 @@ export const AddScopeDialog = ({ toggleDialog(); }; - const onFilterTypeDropdownToggle = () => { - setIsFilterTypeDropdownOpen(!isFilterTypeDropdownOpen); - }; - - const onProtocolTypeDropdownToggle = () => { - setIsProtocolTypeDropdownOpen(!isProtocolTypeDropdownOpen); - }; - const onFilterTypeDropdownSelect = (filterType: string) => { if (filterType === FilterType.Name) { setFilterType(FilterType.Protocol); @@ -112,7 +105,7 @@ export const AddScopeDialog = ({ setFilterType(FilterType.Name); } - setIsFilterTypeDropdownOpen(!isFilterTypeDropdownOpen); + toggleIsFilterTypeDropdownOpen(); }; const onProtocolTypeDropdownSelect = (protocolType: string) => { @@ -124,7 +117,7 @@ export const AddScopeDialog = ({ setProtocolType(ProtocolType.All); } - setIsProtocolTypeDropdownOpen(!isProtocolTypeDropdownOpen); + toggleIsProtocolTypeDropdownOpen(); }; const protocolTypeOptions = [ @@ -226,7 +219,7 @@ export const AddScopeDialog = ({ toggle={ } > @@ -258,7 +251,7 @@ export const AddScopeDialog = ({ toggle={ } > @@ -279,7 +272,7 @@ export const AddScopeDialog = ({ variant={SelectVariant.single} className="kc-protocolType-select" aria-label="Select Input" - onToggle={onProtocolTypeDropdownToggle} + onToggle={toggleIsProtocolTypeDropdownOpen} onSelect={(_, value) => onProtocolTypeDropdownSelect(value.toString()) } diff --git a/src/utils/useToggle.test.ts b/src/utils/useToggle.test.ts new file mode 100644 index 0000000000..e397e12929 --- /dev/null +++ b/src/utils/useToggle.test.ts @@ -0,0 +1,41 @@ +/** + * @jest-environment jsdom + */ +import { act, renderHook } from "@testing-library/react-hooks"; +import useToggle from "./useToggle"; + +describe("useToggle", () => { + it("has a default value of false", () => { + const { result } = renderHook(() => useToggle()); + const [value] = result.current; + + expect(value).toBe(false); + }); + + it("uses the initial value", () => { + const { result } = renderHook(() => useToggle(true)); + const [value] = result.current; + + expect(value).toBe(true); + }); + + it("toggles the value", () => { + const { result } = renderHook(() => useToggle()); + const [, toggleValue] = result.current; + + act(() => toggleValue()); + + const [value] = result.current; + expect(value).toBe(true); + }); + + it("sets the value", () => { + const { result } = renderHook(() => useToggle()); + const [, , setValue] = result.current; + + act(() => setValue(true)); + + const [value] = result.current; + expect(value).toBe(true); + }); +}); diff --git a/src/utils/useToggle.ts b/src/utils/useToggle.ts new file mode 100644 index 0000000000..57696e2941 --- /dev/null +++ b/src/utils/useToggle.ts @@ -0,0 +1,13 @@ +import { useCallback, useState } from "react"; + +/** + * A hook that allows you toggle a boolean value, useful for toggle buttons, showing and hiding modals, etc. + * + * @param initialValue The initial value to use, false by default. + */ +export default function useToggle(initialValue = false) { + const [value, setValue] = useState(initialValue); + const toggleValue = useCallback(() => setValue((val) => !val), []); + + return [value, toggleValue, setValue] as const; +}