From 77008c81a54671100523dfc14056487e93bbbb83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 28 May 2025 08:13:16 +0100 Subject: [PATCH 01/25] web: add radashi dependency With the idea to write and maintain less code, documentation, and tests for the utils it already provides. --- web/package-lock.json | 10 ++++++++++ web/package.json | 1 + 2 files changed, 11 insertions(+) diff --git a/web/package-lock.json b/web/package-lock.json index 06ac714fef..7d73693074 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,6 +16,7 @@ "axios": "^1.7.3", "fast-sort": "^3.4.0", "ipaddr.js": "^2.1.0", + "radashi": "^12.5.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.3.0", @@ -14248,6 +14249,15 @@ "node": ">= 10" } }, + "node_modules/radashi": { + "version": "12.5.1", + "resolved": "https://registry.npmjs.org/radashi/-/radashi-12.5.1.tgz", + "integrity": "sha512-gcznSPJe2SCIuWf6QTqSHZRyMQU6hnUk4NpR7LmmNmdK1BGOXdnHuNIO4VzNw+feu0Wsnv/AYmxqwUYDBatPMA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", diff --git a/web/package.json b/web/package.json index 4a094923ec..7dcef739e4 100644 --- a/web/package.json +++ b/web/package.json @@ -107,6 +107,7 @@ "axios": "^1.7.3", "fast-sort": "^3.4.0", "ipaddr.js": "^2.1.0", + "radashi": "^12.5.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.3.0", From 607ff10f204a6af545490204bf6b6291a2c8cb95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 28 May 2025 08:32:34 +0100 Subject: [PATCH 02/25] refactor(web): use isEmpty and isNullish from radashi And drop the custom utils/isEmpty --- .../components/core/ChangeProductOption.tsx | 2 +- web/src/components/core/EmailInput.tsx | 3 +- web/src/components/core/Page.tsx | 3 +- web/src/components/layout/Loading.tsx | 2 +- .../components/network/WifiConnectionForm.tsx | 4 +- .../components/network/WifiNetworksList.tsx | 4 +- .../product/ProductRegistrationPage.tsx | 7 +-- .../product/ProductSelectionPage.tsx | 6 +-- .../components/software/SoftwareConflicts.tsx | 4 +- .../storage/EncryptionSettingsPage.tsx | 2 +- web/src/components/system/HostnamePage.tsx | 2 +- web/src/components/users/FirstUser.tsx | 2 +- web/src/components/users/RootUser.tsx | 2 +- web/src/components/users/RootUserForm.tsx | 2 +- web/src/utils.test.ts | 43 ------------------- web/src/utils.ts | 36 ---------------- 16 files changed, 24 insertions(+), 100 deletions(-) diff --git a/web/src/components/core/ChangeProductOption.tsx b/web/src/components/core/ChangeProductOption.tsx index daa01b0743..5cf81e2331 100644 --- a/web/src/components/core/ChangeProductOption.tsx +++ b/web/src/components/core/ChangeProductOption.tsx @@ -25,8 +25,8 @@ import { DropdownItem, DropdownItemProps } from "@patternfly/react-core"; import { useHref, useLocation } from "react-router-dom"; import { useProduct, useRegistration } from "~/queries/software"; import { PRODUCT as PATHS, SIDE_PATHS } from "~/routes/paths"; +import { isEmpty } from "radashi"; import { _ } from "~/i18n"; -import { isEmpty } from "~/utils"; /** * DropdownItem Option for navigating to the selection product. diff --git a/web/src/components/core/EmailInput.tsx b/web/src/components/core/EmailInput.tsx index 5d3bf09335..e7634e2312 100644 --- a/web/src/components/core/EmailInput.tsx +++ b/web/src/components/core/EmailInput.tsx @@ -22,7 +22,8 @@ import React, { useEffect, useState } from "react"; import { InputGroup, TextInput, TextInputProps } from "@patternfly/react-core"; -import { isEmpty, noop } from "~/utils"; +import { isEmpty } from "radashi"; +import { noop } from "~/utils"; /** * Email validation. diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index 11504282f1..e4c9b3b4c5 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -45,7 +45,8 @@ import Link, { LinkProps } from "~/components/core/Link"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import flexStyles from "@patternfly/react-styles/css/utilities/Flex/flex"; import { useLocation, useNavigate } from "react-router-dom"; -import { isEmpty, isObject } from "~/utils"; +import { isObject } from "~/utils"; +import { isEmpty } from "radashi"; import { SIDE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; diff --git a/web/src/components/layout/Loading.tsx b/web/src/components/layout/Loading.tsx index f9e29502fe..6efd993010 100644 --- a/web/src/components/layout/Loading.tsx +++ b/web/src/components/layout/Loading.tsx @@ -26,7 +26,7 @@ import { PlainLayout } from "~/components/layout"; import { LayoutProps } from "~/components/layout/Layout"; import sizingStyles from "@patternfly/react-styles/css/utilities/Sizing/sizing"; import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; -import { isEmpty } from "~/utils"; +import { isEmpty } from "radashi"; import { _ } from "~/i18n"; /** diff --git a/web/src/components/network/WifiConnectionForm.tsx b/web/src/components/network/WifiConnectionForm.tsx index b8c2035f14..1da7875d18 100644 --- a/web/src/components/network/WifiConnectionForm.tsx +++ b/web/src/components/network/WifiConnectionForm.tsx @@ -34,9 +34,9 @@ import { import { Page, PasswordInput } from "~/components/core"; import { useAddConnectionMutation, useConnectionMutation, useConnections } from "~/queries/network"; import { Connection, ConnectionState, WifiNetwork, Wireless } from "~/types/network"; -import { _ } from "~/i18n"; +import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; -import { isEmpty } from "~/utils"; +import { _ } from "~/i18n"; const securityOptions = [ // TRANSLATORS: WiFi authentication mode diff --git a/web/src/components/network/WifiNetworksList.tsx b/web/src/components/network/WifiNetworksList.tsx index a099167551..a754502fbd 100644 --- a/web/src/components/network/WifiNetworksList.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -40,10 +40,10 @@ import Icon, { IconProps } from "~/components/layout/Icon"; import { Connection, ConnectionState, WifiNetwork, WifiNetworkStatus } from "~/types/network"; import { useConnections, useNetworkChanges, useWifiNetworks } from "~/queries/network"; import { NETWORK as PATHS } from "~/routes/paths"; -import { isEmpty } from "~/utils"; +import { isEmpty } from "radashi"; import { formatIp } from "~/utils/network"; -import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; +import { _ } from "~/i18n"; const NetworkSignal = ({ id, signal }) => { let label: string; diff --git a/web/src/components/product/ProductRegistrationPage.tsx b/web/src/components/product/ProductRegistrationPage.tsx index ddb4bf44a6..d4539f5ae8 100644 --- a/web/src/components/product/ProductRegistrationPage.tsx +++ b/web/src/components/product/ProductRegistrationPage.tsx @@ -38,15 +38,16 @@ import { TextInput, } from "@patternfly/react-core"; import { Link, Page } from "~/components/core"; +import RegistrationExtension from "./RegistrationExtension"; +import RegistrationCodeInput from "./RegistrationCodeInput"; import { RegistrationInfo } from "~/types/software"; import { HOSTNAME } from "~/routes/paths"; import { useProduct, useRegistration, useRegisterMutation, useAddons } from "~/queries/software"; import { useHostname } from "~/queries/system"; -import { isEmpty, mask } from "~/utils"; +import { isEmpty } from "radashi"; +import { mask } from "~/utils"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; -import RegistrationExtension from "./RegistrationExtension"; -import RegistrationCodeInput from "./RegistrationCodeInput"; const FORM_ID = "productRegistration"; const EMAIL_LABEL = "Email"; diff --git a/web/src/components/product/ProductSelectionPage.tsx b/web/src/components/product/ProductSelectionPage.tsx index 001c36e5f6..24080e6252 100644 --- a/web/src/components/product/ProductSelectionPage.tsx +++ b/web/src/components/product/ProductSelectionPage.tsx @@ -42,11 +42,11 @@ import { Page } from "~/components/core"; import { useConfigMutation, useProduct, useRegistration } from "~/queries/software"; import pfTextStyles from "@patternfly/react-styles/css/utilities/Text/text"; import pfRadioStyles from "@patternfly/react-styles/css/components/Radio/radio"; -import { sprintf } from "sprintf-js"; -import { _ } from "~/i18n"; import { PATHS } from "~/router"; -import { isEmpty } from "~/utils"; import { Product } from "~/types/software"; +import { isEmpty } from "radashi"; +import { sprintf } from "sprintf-js"; +import { _ } from "~/i18n"; import LicenseDialog from "./LicenseDialog"; const ResponsiveGridItem = ({ children }) => ( diff --git a/web/src/components/software/SoftwareConflicts.tsx b/web/src/components/software/SoftwareConflicts.tsx index e076129958..7fc888fda3 100644 --- a/web/src/components/software/SoftwareConflicts.tsx +++ b/web/src/components/software/SoftwareConflicts.tsx @@ -45,7 +45,7 @@ import { Icon } from "~/components/layout"; import { Page, SubtleContent } from "~/components/core"; import { ConflictSolutionOption } from "~/types/software"; import { useConflicts, useConflictsChanges, useConflictsMutation } from "~/queries/software"; -import { isEmpty } from "~/utils"; +import { isNullish } from "radashi"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; @@ -131,7 +131,7 @@ const ConflictsForm = ({ conflict }): React.ReactNode => { const onSubmit = async (e) => { e.preventDefault(); - if (!isEmpty(chosenSolution)) { + if (!isNullish(chosenSolution)) { setError(null); solve({ conflictId: conflict.id, solutionId: chosenSolution }); } else { diff --git a/web/src/components/storage/EncryptionSettingsPage.tsx b/web/src/components/storage/EncryptionSettingsPage.tsx index 2d3e41659e..a9cae28610 100644 --- a/web/src/components/storage/EncryptionSettingsPage.tsx +++ b/web/src/components/storage/EncryptionSettingsPage.tsx @@ -27,7 +27,7 @@ import { NestedContent, Page, PasswordAndConfirmationInput } from "~/components/ import { useEncryptionMethods } from "~/queries/storage"; import { useEncryption } from "~/queries/storage/config-model"; import { apiModel } from "~/api/storage/types"; -import { isEmpty } from "~/utils"; +import { isEmpty } from "radashi"; import { _ } from "~/i18n"; /** diff --git a/web/src/components/system/HostnamePage.tsx b/web/src/components/system/HostnamePage.tsx index e1896b4b20..0bdf10152f 100644 --- a/web/src/components/system/HostnamePage.tsx +++ b/web/src/components/system/HostnamePage.tsx @@ -34,7 +34,7 @@ import { import { NestedContent, Page } from "~/components/core"; import { useProduct, useRegistration } from "~/queries/software"; import { useHostname, useHostnameMutation } from "~/queries/system"; -import { isEmpty } from "~/utils"; +import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; diff --git a/web/src/components/users/FirstUser.tsx b/web/src/components/users/FirstUser.tsx index dc6653054f..e8ce01288c 100644 --- a/web/src/components/users/FirstUser.tsx +++ b/web/src/components/users/FirstUser.tsx @@ -33,7 +33,7 @@ import { import { Link, Page, SplitButton } from "~/components/core"; import { useFirstUser, useFirstUserChanges, useRemoveFirstUserMutation } from "~/queries/users"; import { PATHS } from "~/routes/users"; -import { isEmpty } from "~/utils"; +import { isEmpty } from "radashi"; import { _ } from "~/i18n"; const UserActions = () => { diff --git a/web/src/components/users/RootUser.tsx b/web/src/components/users/RootUser.tsx index 861e1c15ee..d947542b10 100644 --- a/web/src/components/users/RootUser.tsx +++ b/web/src/components/users/RootUser.tsx @@ -33,7 +33,7 @@ import { import { Link, Page } from "~/components/core"; import { useRootUser, useRootUserChanges } from "~/queries/users"; import { USER } from "~/routes/paths"; -import { isEmpty } from "~/utils"; +import { isEmpty } from "radashi"; import { _ } from "~/i18n"; const SSHKeyLabel = ({ sshKey }) => { diff --git a/web/src/components/users/RootUserForm.tsx b/web/src/components/users/RootUserForm.tsx index 7a45eb6e7b..aa59468ded 100644 --- a/web/src/components/users/RootUserForm.tsx +++ b/web/src/components/users/RootUserForm.tsx @@ -35,7 +35,7 @@ import { useNavigate } from "react-router-dom"; import { NestedContent, Page, PasswordAndConfirmationInput } from "~/components/core"; import { useRootUser, useRootUserMutation } from "~/queries/users"; import { RootUser } from "~/types/users"; -import { isEmpty } from "~/utils"; +import { isEmpty } from "radashi"; import { _ } from "~/i18n"; const AVAILABLE_METHODS = ["password", "sshPublicKey"] as const; diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 8a1d64d876..99ced7f4e0 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -30,7 +30,6 @@ import { localConnection, isObject, slugify, - isEmpty, } from "./utils"; describe("noop", () => { @@ -165,48 +164,6 @@ describe("isObject", () => { }); }); -describe("isEmpty", () => { - it("returns true when called with null", () => { - expect(isEmpty(null)).toBe(true); - }); - - it("returns true when called with undefined", () => { - expect(isEmpty(undefined)).toBe(true); - }); - - it("returns false when called with a function", () => { - expect(isEmpty(() => {})).toBe(false); - }); - - it("returns false when called with a number", () => { - expect(isEmpty(1)).toBe(false); - }); - - it("returns true when called with an empty string", () => { - expect(isEmpty("")).toBe(true); - }); - - it("returns false when called with a not empty string", () => { - expect(isEmpty("not empty")).toBe(false); - }); - - it("returns true when called with an empty array", () => { - expect(isEmpty([])).toBe(true); - }); - - it("returns false when called with a not empty array", () => { - expect(isEmpty([""])).toBe(false); - }); - - it("returns true when called with an empty object", () => { - expect(isEmpty({})).toBe(true); - }); - - it("returns false when called with a not empty object", () => { - expect(isEmpty({ not: "empty" })).toBe(false); - }); -}); - describe("slugify", () => { it("converts given input into a slug", () => { expect(slugify("Agama! / Network 1")).toEqual("agama-network-1"); diff --git a/web/src/utils.ts b/web/src/utils.ts index 4e1e6973ab..71a863406c 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -50,41 +50,6 @@ const isObjectEmpty = (value: object) => { return Object.keys(value).length === 0; }; -/** - * Whether given value is empty or not - * - * @param value - the value to be checked - * @return false if value is a function, a not empty object, or a not - * empty string; true otherwise - */ -const isEmpty = (value) => { - if (value === null || value === undefined) { - return true; - } - - if (typeof value === "function") { - return false; - } - - if (typeof value === "number" && !Number.isNaN(value)) { - return false; - } - - if (typeof value === "string") { - return value.trim() === ""; - } - - if (Array.isArray(value)) { - return value.length === 0; - } - - if (isObject(value)) { - return isObjectEmpty(value); - } - - return true; -}; - /** * Returns an empty function useful to be used as a default callback. * @@ -444,7 +409,6 @@ const getBreakpoint = (width: number): "default" | "sm" | "md" | "lg" | "xl" | " export { noop, identity, - isEmpty, isObject, isObjectEmpty, partition, From c47c44b134c07f3a38aa21aef74315fd2e5287f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 28 May 2025 08:47:34 +0100 Subject: [PATCH 03/25] refactor(web): use `fork` from radashi And drop the custom utils/partition --- web/src/components/core/Popup.tsx | 4 ++-- .../components/questions/QuestionActions.tsx | 4 ++-- .../storage/ProposalActionsDialog.tsx | 4 ++-- web/src/utils.test.ts | 11 --------- web/src/utils.ts | 23 ------------------- 5 files changed, 6 insertions(+), 40 deletions(-) diff --git a/web/src/components/core/Popup.tsx b/web/src/components/core/Popup.tsx index ab2a6e3353..30a3978454 100644 --- a/web/src/components/core/Popup.tsx +++ b/web/src/components/core/Popup.tsx @@ -32,8 +32,8 @@ import { ModalProps, } from "@patternfly/react-core"; import { Loading } from "~/components/layout"; +import { fork } from "radashi"; import { _ } from "~/i18n"; -import { partition } from "~/utils"; type ButtonWithoutVariantProps = Omit; type PredefinedAction = React.PropsWithChildren; @@ -212,7 +212,7 @@ const Popup = ({ children, ...props }: PopupProps) => { - const [actions, content] = partition(React.Children.toArray(children), (child) => + const [actions, content] = fork(React.Children.toArray(children), (child) => isValidElement(child) ? child.type === Actions : false, ); diff --git a/web/src/components/questions/QuestionActions.tsx b/web/src/components/questions/QuestionActions.tsx index ac07040f40..07965a9e9e 100644 --- a/web/src/components/questions/QuestionActions.tsx +++ b/web/src/components/questions/QuestionActions.tsx @@ -21,8 +21,8 @@ */ import React from "react"; -import { partition } from "~/utils"; import { Popup } from "~/components/core"; +import { fork } from "radashi"; /** * Returns given text capitalized @@ -56,7 +56,7 @@ export default function QuestionActions({ actionCallback: (action: string) => void; conditions?: { disable?: { [key: string]: boolean } }; }): React.ReactNode { - let [[primaryAction], secondaryActions] = partition(actions, (a: string) => a === defaultAction); + let [[primaryAction], secondaryActions] = fork(actions, (a: string) => a === defaultAction); // Ensure there is always a primary action if (!primaryAction) [primaryAction, ...secondaryActions] = secondaryActions; diff --git a/web/src/components/storage/ProposalActionsDialog.tsx b/web/src/components/storage/ProposalActionsDialog.tsx index 280ea5e983..885d5e4e05 100644 --- a/web/src/components/storage/ProposalActionsDialog.tsx +++ b/web/src/components/storage/ProposalActionsDialog.tsx @@ -24,7 +24,7 @@ import React, { useState } from "react"; import { List, ListItem, ExpandableSection } from "@patternfly/react-core"; import { n_ } from "~/i18n"; import { sprintf } from "sprintf-js"; -import { partition } from "~/utils"; +import { fork } from "radashi"; import { Action } from "~/types/storage"; const ActionsList = ({ actions }: { actions: Action[] }) => { @@ -66,7 +66,7 @@ export default function ProposalActionsDialog({ if (actions.length === 0) return null; - const [generalActions, subvolActions] = partition(actions, (a: Action) => !a.subvol); + const [generalActions, subvolActions] = fork(actions, (a: Action) => !a.subvol); const toggleText = isExpanded ? // TRANSLATORS: show/hide toggle action, this is a clickable link sprintf( diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 99ced7f4e0..9917630cee 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -22,7 +22,6 @@ import { classNames, - partition, compact, uniq, noop, @@ -39,16 +38,6 @@ describe("noop", () => { }); }); -describe("partition", () => { - it("returns two groups of elements that do and do not satisfy provided filter", () => { - const numbers = [1, 2, 3, 4, 5, 6]; - const [odd, even] = partition(numbers, (number) => number % 2 !== 0); - - expect(odd).toEqual([1, 3, 5]); - expect(even).toEqual([2, 4, 6]); - }); -}); - describe("compact", () => { it("removes null and undefined values", () => { expect(compact([])).toEqual([]); diff --git a/web/src/utils.ts b/web/src/utils.ts index 71a863406c..9bb8b33b22 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -62,28 +62,6 @@ const noop = () => undefined; */ const identity = (i) => i; -/** - * Returns a new array with a given collection split into two groups, the first holding elements - * satisfying the filter and the second with those which do not. - * - * @param collection - the collection to be filtered - * @param filter - the function to be used as filter - * @return a pair of arrays, [passing, failing] - */ -const partition = ( - collection: Array, - filter: (element: T) => boolean, -): [Array, Array] => { - const pass = []; - const fail = []; - - collection.forEach((element) => { - filter(element) ? pass.push(element) : fail.push(element); - }); - - return [pass, fail]; -}; - /** * Generates a new array without null and undefined values. */ @@ -411,7 +389,6 @@ export { identity, isObject, isObjectEmpty, - partition, compact, uniq, classNames, From 171df1352974c256eb09c0ef518e4db127e42b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 28 May 2025 08:59:44 +0100 Subject: [PATCH 04/25] refactor(web): use `isObject` from radashi And drop the custom utils/isObject --- web/src/components/core/Page.tsx | 3 +-- web/src/test-utils.tsx | 8 +++---- web/src/types/network.ts | 2 +- web/src/utils.test.ts | 40 -------------------------------- web/src/utils.ts | 19 --------------- 5 files changed, 6 insertions(+), 66 deletions(-) diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index e4c9b3b4c5..20674f8ecb 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -45,8 +45,7 @@ import Link, { LinkProps } from "~/components/core/Link"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import flexStyles from "@patternfly/react-styles/css/utilities/Flex/flex"; import { useLocation, useNavigate } from "react-router-dom"; -import { isObject } from "~/utils"; -import { isEmpty } from "radashi"; +import { isEmpty, isObject } from "radashi"; import { SIDE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index b0f36e8ee8..f03e073a6e 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -30,14 +30,14 @@ import React from "react"; import { MemoryRouter, useParams } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; import { render } from "@testing-library/react"; - import { createClient } from "~/client/index"; import { InstallerClientProvider } from "~/context/installer"; -import { noop, isObject } from "./utils"; -import { InstallerL10nProvider } from "./context/installerL10n"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { InstallerL10nProvider } from "~/context/installerL10n"; +import { isObject } from "radashi"; +import { noop } from "./utils"; /** * Internal mock for manipulating routes, using ["/"] by default diff --git a/web/src/types/network.ts b/web/src/types/network.ts index bbf65fe58c..6719f5344a 100644 --- a/web/src/types/network.ts +++ b/web/src/types/network.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { isObject } from "~/utils"; +import { isObject } from "radashi"; import { buildAddress, buildAddresses, diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 9917630cee..2413cec98d 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -27,7 +27,6 @@ import { noop, toValidationError, localConnection, - isObject, slugify, } from "./utils"; @@ -114,45 +113,6 @@ describe("localConnection", () => { }); }); -describe("isObject", () => { - it("returns true when called with an object", () => { - expect(isObject({ dummy: "object" })).toBe(true); - }); - - it("returns false when called with null", () => { - expect(isObject(null)).toBe(false); - }); - - it("returns false when called with undefined", () => { - expect(isObject(undefined)).toBe(false); - }); - - it("returns false when called with a string", () => { - expect(isObject("dummy string")).toBe(false); - }); - - it("returns false when called with an array", () => { - expect(isObject(["dummy", "array"])).toBe(false); - }); - - it("returns false when called with a date", () => { - expect(isObject(new Date())).toBe(false); - }); - - it("returns false when called with regexp", () => { - expect(isObject(/aRegExp/i)).toBe(false); - }); - - it("returns false when called with a set", () => { - expect(isObject(new Set(["dummy", "set"]))).toBe(false); - }); - - it("returns false when called with a map", () => { - const map = new Map([["dummy", "map"]]); - expect(isObject(map)).toBe(false); - }); -}); - describe("slugify", () => { it("converts given input into a slug", () => { expect(slugify("Agama! / Network 1")).toEqual("agama-network-1"); diff --git a/web/src/utils.ts b/web/src/utils.ts index 9bb8b33b22..2a1f274f6a 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -22,24 +22,6 @@ import { useEffect, useRef, useCallback, useState } from "react"; -/** - * Returns true when given value is an - * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object Object} - * - * Borrowed from https://dev.to/alesm0101/how-to-check-if-a-value-is-an-object-in-javascript-3pin - * - * @param value - the value to be checked - * @return true when given value is an object; false otherwise - */ -const isObject = (value) => - typeof value === "object" && - value !== null && - !Array.isArray(value) && - !(value instanceof RegExp) && - !(value instanceof Date) && - !(value instanceof Set) && - !(value instanceof Map); - /** * Whether given object is empty or not * @@ -387,7 +369,6 @@ const getBreakpoint = (width: number): "default" | "sm" | "md" | "lg" | "xl" | " export { noop, identity, - isObject, isObjectEmpty, compact, uniq, From a552fc57033441cce259baf95ecd653c04047e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 28 May 2025 09:03:16 +0100 Subject: [PATCH 05/25] refactor(web): drop custom isObjectEmpty util And use radashi/isEmpty instead, which also checks "object with no enumerable keys" --- web/src/components/overview/SoftwareSection.tsx | 4 ++-- web/src/utils.ts | 11 ----------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/web/src/components/overview/SoftwareSection.tsx b/web/src/components/overview/SoftwareSection.tsx index fa81c36998..df497c35d8 100644 --- a/web/src/components/overview/SoftwareSection.tsx +++ b/web/src/components/overview/SoftwareSection.tsx @@ -24,7 +24,7 @@ import React from "react"; import { Content, List, ListItem } from "@patternfly/react-core"; import { SelectedBy } from "~/types/software"; import { usePatterns, useProposal, useProposalChanges } from "~/queries/software"; -import { isObjectEmpty } from "~/utils"; +import { isEmpty } from "radashi"; import { _ } from "~/i18n"; export default function SoftwareSection(): React.ReactNode { @@ -33,7 +33,7 @@ export default function SoftwareSection(): React.ReactNode { useProposalChanges(); - if (isObjectEmpty(proposal.patterns)) return; + if (isEmpty(proposal.patterns)) return; const TextWithoutList = () => { return ( diff --git a/web/src/utils.ts b/web/src/utils.ts index 2a1f274f6a..799a0b637b 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -22,16 +22,6 @@ import { useEffect, useRef, useCallback, useState } from "react"; -/** - * Whether given object is empty or not - * - * @param value - the value to be checked - * @return true when given value is an empty object; false otherwise - */ -const isObjectEmpty = (value: object) => { - return Object.keys(value).length === 0; -}; - /** * Returns an empty function useful to be used as a default callback. * @@ -369,7 +359,6 @@ const getBreakpoint = (width: number): "default" | "sm" | "md" | "lg" | "xl" | " export { noop, identity, - isObjectEmpty, compact, uniq, classNames, From 95bb5d36e83a25d533d9f9a5bd7be2c18eb37580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 28 May 2025 09:09:42 +0100 Subject: [PATCH 06/25] refactor(web): use `noop` from radashi And drop the custom utils/noop --- web/src/components/core/EmailInput.tsx | 3 +-- web/src/components/core/ListSearch.tsx | 3 ++- web/src/components/core/ServerError.test.tsx | 6 +++--- web/src/context/installerL10n.test.tsx | 3 ++- web/src/hooks/useNodeSiblings.ts | 2 +- web/src/test-utils.tsx | 3 +-- web/src/utils.test.ts | 17 +---------------- web/src/utils.ts | 8 -------- 8 files changed, 11 insertions(+), 34 deletions(-) diff --git a/web/src/components/core/EmailInput.tsx b/web/src/components/core/EmailInput.tsx index e7634e2312..4c69888140 100644 --- a/web/src/components/core/EmailInput.tsx +++ b/web/src/components/core/EmailInput.tsx @@ -22,8 +22,7 @@ import React, { useEffect, useState } from "react"; import { InputGroup, TextInput, TextInputProps } from "@patternfly/react-core"; -import { isEmpty } from "radashi"; -import { noop } from "~/utils"; +import { isEmpty, noop } from "radashi"; /** * Email validation. diff --git a/web/src/components/core/ListSearch.tsx b/web/src/components/core/ListSearch.tsx index 780feb574b..fb50db9420 100644 --- a/web/src/components/core/ListSearch.tsx +++ b/web/src/components/core/ListSearch.tsx @@ -22,8 +22,9 @@ import React, { useState } from "react"; import { SearchInput } from "@patternfly/react-core"; +import { noop } from "radashi"; +import { useDebounce } from "~/utils"; import { _ } from "~/i18n"; -import { noop, useDebounce } from "~/utils"; type ListSearchProps = { /** Text to display as placeholder for the search input. */ diff --git a/web/src/components/core/ServerError.test.tsx b/web/src/components/core/ServerError.test.tsx index 4b52ee8b48..3a486c6e02 100644 --- a/web/src/components/core/ServerError.test.tsx +++ b/web/src/components/core/ServerError.test.tsx @@ -23,9 +23,9 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; - -import * as utils from "~/utils"; import { ServerError } from "~/components/core"; +import { noop } from "radashi"; +import * as utils from "~/utils"; jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
ProductRegistrationAlert Mock
@@ -58,7 +58,7 @@ describe("ServerError", () => { }); it("calls location.reload when user clicks on 'Reload'", async () => { - jest.spyOn(utils, "locationReload").mockImplementation(utils.noop); + jest.spyOn(utils, "locationReload").mockImplementation(noop); const { user } = installerRender(); const reloadButton = await screen.findByRole("button", { name: /Reload/i }); await user.click(reloadButton); diff --git a/web/src/context/installerL10n.test.tsx b/web/src/context/installerL10n.test.tsx index 92a2187919..42711edc60 100644 --- a/web/src/context/installerL10n.test.tsx +++ b/web/src/context/installerL10n.test.tsx @@ -26,6 +26,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import { InstallerL10nProvider } from "~/context/installerL10n"; import { InstallerClientProvider } from "./installer"; import * as utils from "~/utils"; +import { noop } from "radashi"; const mockFetchConfigFn = jest.fn(); const mockUpdateConfigFn = jest.fn(); @@ -77,7 +78,7 @@ const TranslatedContent = () => { describe("InstallerL10nProvider", () => { beforeAll(() => { - jest.spyOn(utils, "locationReload").mockImplementation(utils.noop); + jest.spyOn(utils, "locationReload").mockImplementation(noop); jest.spyOn(utils, "setLocationSearch"); mockUpdateConfigFn.mockResolvedValue(true); diff --git a/web/src/hooks/useNodeSiblings.ts b/web/src/hooks/useNodeSiblings.ts index d6418c0979..f83e115661 100644 --- a/web/src/hooks/useNodeSiblings.ts +++ b/web/src/hooks/useNodeSiblings.ts @@ -1,4 +1,4 @@ -import { noop } from "~/utils"; +import { noop } from "radashi"; type AddAttributeFn = HTMLElement["setAttribute"]; type RemoveAttributeFn = HTMLElement["removeAttribute"]; diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index f03e073a6e..29704d201e 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -36,8 +36,7 @@ import { render } from "@testing-library/react"; import { createClient } from "~/client/index"; import { InstallerClientProvider } from "~/context/installer"; import { InstallerL10nProvider } from "~/context/installerL10n"; -import { isObject } from "radashi"; -import { noop } from "./utils"; +import { isObject, noop } from "radashi"; /** * Internal mock for manipulating routes, using ["/"] by default diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 2413cec98d..75629c0ff3 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -20,22 +20,7 @@ * find current contact information at www.suse.com. */ -import { - classNames, - compact, - uniq, - noop, - toValidationError, - localConnection, - slugify, -} from "./utils"; - -describe("noop", () => { - it("returns undefined", () => { - const result = noop(); - expect(result).toBeUndefined(); - }); -}); +import { classNames, compact, uniq, toValidationError, localConnection, slugify } from "./utils"; describe("compact", () => { it("removes null and undefined values", () => { diff --git a/web/src/utils.ts b/web/src/utils.ts index 799a0b637b..6b38a98462 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -22,13 +22,6 @@ import { useEffect, useRef, useCallback, useState } from "react"; -/** - * Returns an empty function useful to be used as a default callback. - * - * @return empty function - */ -const noop = () => undefined; - /** * @return identity function */ @@ -357,7 +350,6 @@ const getBreakpoint = (width: number): "default" | "sm" | "md" | "lg" | "xl" | " }; export { - noop, identity, compact, uniq, From 08ecd75d289f736b69ad6dacbd2e17e6f28b0cf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 28 May 2025 09:13:49 +0100 Subject: [PATCH 07/25] refactor(web): drop utils/identity No longer used since eeeb61a0fac23568acb07ab4e3293ca063f5ca24. --- web/src/utils.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/web/src/utils.ts b/web/src/utils.ts index 6b38a98462..1feb74550e 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -22,11 +22,6 @@ import { useEffect, useRef, useCallback, useState } from "react"; -/** - * @return identity function - */ -const identity = (i) => i; - /** * Generates a new array without null and undefined values. */ @@ -350,7 +345,6 @@ const getBreakpoint = (width: number): "default" | "sm" | "md" | "lg" | "xl" | " }; export { - identity, compact, uniq, classNames, From 822c90214b7373f20b7c548bee11edb10bd91347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 28 May 2025 09:40:30 +0100 Subject: [PATCH 08/25] refactor(web): use `unique` from radashi And drop the custom utils/uniq --- web/src/components/storage/DevicesManager.js | 7 ++++--- .../components/storage/LogicalVolumePage.tsx | 9 +++++---- web/src/components/storage/PartitionPage.tsx | 7 ++++--- web/src/utils.test.ts | 18 +----------------- web/src/utils.ts | 8 -------- 5 files changed, 14 insertions(+), 35 deletions(-) diff --git a/web/src/components/storage/DevicesManager.js b/web/src/components/storage/DevicesManager.js index d5e9624ce0..bbb4c73ccc 100644 --- a/web/src/components/storage/DevicesManager.js +++ b/web/src/components/storage/DevicesManager.js @@ -22,7 +22,8 @@ // @ts-check -import { compact, uniq } from "~/utils"; +import { unique } from "radashi"; +import { compact } from "~/utils"; /** * @typedef {import ("~/types/storage").Action} Action @@ -158,7 +159,7 @@ export default class DevicesManager { .filter((d) => this.#isUsed(d) || knownNames.includes(d.name)) .map((d) => d.sid); - return compact(uniq(sids).map((sid) => this.stagingDevice(sid))); + return compact(unique(sids).map((sid) => this.stagingDevice(sid))); } /** @@ -236,7 +237,7 @@ export default class DevicesManager { * @returns {boolean} */ #isUsed(device) { - const sids = uniq(compact(this.actions.map((a) => a.device))); + const sids = unique(compact(this.actions.map((a) => a.device))); const partitions = device.partitionTable?.partitions || []; const lvmLvs = device.logicalVolumes || []; diff --git a/web/src/components/storage/LogicalVolumePage.tsx b/web/src/components/storage/LogicalVolumePage.tsx index a540238be9..26e0ceb0b6 100644 --- a/web/src/components/storage/LogicalVolumePage.tsx +++ b/web/src/components/storage/LogicalVolumePage.tsx @@ -52,9 +52,6 @@ import { SelectWrapperProps as SelectProps } from "~/components/core/SelectWrapp import SelectTypeaheadCreatable from "~/components/core/SelectTypeaheadCreatable"; import AutoSizeText from "~/components/storage/AutoSizeText"; import { deviceSize, filesystemLabel, parseToBytes } from "~/components/storage/utils"; -import { compact, uniq } from "~/utils"; -import { _ } from "~/i18n"; -import { sprintf } from "sprintf-js"; import { useApiModel, useSolvedApiModel } from "~/hooks/storage/api-model"; import { useModel } from "~/hooks/storage/model"; import { useMissingMountPaths, useVolume } from "~/hooks/storage/product"; @@ -65,6 +62,10 @@ import { buildLogicalVolumeName } from "~/helpers/storage/api-model"; import { apiModel } from "~/api/storage/types"; import { data } from "~/types/storage"; import { STORAGE as PATHS } from "~/routes/paths"; +import { unique } from "radashi"; +import { compact } from "~/utils"; +import { sprintf } from "sprintf-js"; +import { _ } from "~/i18n"; const NO_VALUE = ""; const BTRFS_SNAPSHOTS = "btrfsSnapshots"; @@ -225,7 +226,7 @@ function useUsableFilesystems(mountPoint: string): string[] { return [BTRFS_SNAPSHOTS, ...allValues]; }; - return uniq([defaultFilesystem, ...volumeFilesystems()]); + return unique([defaultFilesystem, ...volumeFilesystems()]); }, [volume, defaultFilesystem]); return usableFilesystems; diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index 3229225457..5d3a210edf 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -69,7 +69,8 @@ import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { apiModel } from "~/api/storage/types"; import { STORAGE as PATHS } from "~/routes/paths"; -import { compact, uniq } from "~/utils"; +import { unique } from "radashi"; +import { compact } from "~/utils"; const NO_VALUE = ""; const NEW_PARTITION = "new"; @@ -288,7 +289,7 @@ function useUsableFilesystems(mountPoint: string): string[] { return [BTRFS_SNAPSHOTS, ...allValues]; }; - return uniq([defaultFilesystem, ...volumeFilesystems()]); + return unique([defaultFilesystem, ...volumeFilesystems()]); }, [volume, defaultFilesystem]); return usableFilesystems; @@ -468,7 +469,7 @@ function useAutoRefreshFilesystem(handler, value: FormValue) { // Select default filesystem for the mount point if the partition has no filesystem. if (mountPoint !== NO_VALUE && target !== NEW_PARTITION && !partitionFilesystem) handler(defaultFilesystem); - // Reuse the filesystem from the partition if possble. + // Reuse the filesystem from the partition if possible. if (mountPoint !== NO_VALUE && target !== NEW_PARTITION && partitionFilesystem) { // const reuse = usableFilesystems.includes(partitionFilesystem); const reuse = usableFilesystems.includes(partitionFilesystem); diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 75629c0ff3..735691157a 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { classNames, compact, uniq, toValidationError, localConnection, slugify } from "./utils"; +import { classNames, compact, toValidationError, localConnection, slugify } from "./utils"; describe("compact", () => { it("removes null and undefined values", () => { @@ -36,22 +36,6 @@ describe("compact", () => { }); }); -describe("uniq", () => { - it("removes duplicated values", () => { - expect(uniq([])).toEqual([]); - expect(uniq([undefined, null, null, 0, 1, NaN, false, true, false, "test"])).toEqual([ - undefined, - null, - 0, - 1, - NaN, - false, - true, - "test", - ]); - }); -}); - describe("classNames", () => { it("join given arguments, ignoring falsy values", () => { const includeClass = true; diff --git a/web/src/utils.ts b/web/src/utils.ts index 1feb74550e..519ec148a9 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -29,13 +29,6 @@ const compact = (collection: Array) => { return collection.filter((e) => e !== null && e !== undefined); }; -/** - * Generates a new array without duplicates. - */ -const uniq = (collection: Array) => { - return [...new Set(collection)]; -}; - /** * Simple utility function to help building className conditionally * @@ -346,7 +339,6 @@ const getBreakpoint = (width: number): "default" | "sm" | "md" | "lg" | "xl" | " export { compact, - uniq, classNames, useCancellablePromise, useLocalStorage, From ef16597f14725c2709f197a8956464f65b19f9e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 28 May 2025 09:44:07 +0100 Subject: [PATCH 09/25] refactor(web): drop utils/classNames Since it has not been used for a while and is no longer necessary. --- web/src/utils.test.ts | 12 +----------- web/src/utils.ts | 17 ----------------- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 735691157a..3ac3436b1e 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { classNames, compact, toValidationError, localConnection, slugify } from "./utils"; +import { compact, toValidationError, localConnection, slugify } from "./utils"; describe("compact", () => { it("removes null and undefined values", () => { @@ -36,16 +36,6 @@ describe("compact", () => { }); }); -describe("classNames", () => { - it("join given arguments, ignoring falsy values", () => { - const includeClass = true; - - expect( - classNames("bg-yellow", !includeClass && "h-24", undefined, null, includeClass && "w-24"), - ).toEqual("bg-yellow w-24"); - }); -}); - describe("toValidationError", () => { it("converts an issue to a validation error", () => { const issue = { diff --git a/web/src/utils.ts b/web/src/utils.ts index 519ec148a9..56711a504c 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -29,22 +29,6 @@ const compact = (collection: Array) => { return collection.filter((e) => e !== null && e !== undefined); }; -/** - * Simple utility function to help building className conditionally - * - * @example - * // returns "bg-yellow w-24" - * classNames("bg-yellow", true && "w-24", false && "h-24"); - * - * @todo Use https://github.com/JedWatson/classnames instead? - * - * @param classes - CSS classes to join - * @returns CSS classes joined together after ignoring falsy values - */ -const classNames = (...classes) => { - return classes.filter((item) => !!item).join(" "); -}; - /** * Convert any string into a slug * @@ -339,7 +323,6 @@ const getBreakpoint = (width: number): "default" | "sm" | "md" | "lg" | "xl" | " export { compact, - classNames, useCancellablePromise, useLocalStorage, useDebounce, From 34ca814ee4f547d1351892036613c9fe7221b6b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 28 May 2025 10:09:23 +0100 Subject: [PATCH 10/25] refactor(web): drop utils/slugify No longer used since 62bfdf384998181bffc3c28be05f143d47a7aa4e --- web/src/utils.test.ts | 8 +------- web/src/utils.ts | 32 -------------------------------- 2 files changed, 1 insertion(+), 39 deletions(-) diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 3ac3436b1e..ad07c86484 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { compact, toValidationError, localConnection, slugify } from "./utils"; +import { compact, toValidationError, localConnection } from "./utils"; describe("compact", () => { it("removes null and undefined values", () => { @@ -71,9 +71,3 @@ describe("localConnection", () => { }); }); }); - -describe("slugify", () => { - it("converts given input into a slug", () => { - expect(slugify("Agama! / Network 1")).toEqual("agama-network-1"); - }); -}); diff --git a/web/src/utils.ts b/web/src/utils.ts index 56711a504c..5b611be39b 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -29,37 +29,6 @@ const compact = (collection: Array) => { return collection.filter((e) => e !== null && e !== undefined); }; -/** - * Convert any string into a slug - * - * Borrowed from https://jasonwatmore.com/vanilla-js-slugify-a-string-in-javascript - * - * @example - * slugify("Agama! / Network 1"); - * // returns "agama-network-1" - * - * @param input - the string to slugify - * @returns the slug - */ -const slugify = (input: string) => { - if (!input) return ""; - - return ( - input - // make lower case and trim - .toLowerCase() - .trim() - // remove accents from charaters - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - // replace invalid chars with spaces - .replace(/[^a-z0-9\s-]/g, " ") - .trim() - // replace multiple spaces or hyphens with a single hyphen - .replace(/[\s-]+/g, "-") - ); -}; - type CancellableWrapper = { /** Cancellable promise */ promise: Promise; @@ -331,7 +300,6 @@ export { locationReload, setLocationSearch, localConnection, - slugify, timezoneTime, mask, getBreakpoint, From e7358d8fbf620890f785ca0bcaf7765438d96d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Sat, 31 May 2025 17:46:28 +0100 Subject: [PATCH 11/25] fix(web): make `hex` util more robust - Return 0 when sanitized input contains non-numeric characters - Improve documentation for clarity and completeness - Add comprehensive unit tests covering edge cases Note: This util has a specialized behavior and might better belong in the storage/DASD namespace rather than as a general-purpose utility. --- web/src/utils.test.ts | 28 +++++++++++++++++++++++++++- web/src/utils.ts | 26 ++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index ad07c86484..40d94a7468 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { compact, toValidationError, localConnection } from "./utils"; +import { compact, toValidationError, localConnection, hex } from "./utils"; describe("compact", () => { it("removes null and undefined values", () => { @@ -36,6 +36,32 @@ describe("compact", () => { }); }); +describe("hex", () => { + it("parses numeric dot strings as hex", () => { + expect(hex("0.0.0160")).toBe(352); // "000160" + expect(hex("1.2.3")).toBe(291); // "123" + expect(hex("123")).toBe(291); // "123" + }); + + it("returns 0 for strings with letters or invalid characters", () => { + expect(hex("1A")).toBe(0); + expect(hex("1A.3F")).toBe(0); + expect(hex("xyz")).toBe(0); + expect(hex("123Z")).toBe(0); + }); + + it("returns 0 for values resulting in empty string", () => { + expect(hex("..")).toBe(0); + expect(hex("")).toBe(0); + }); + + it("allows leading or trailing dots", () => { + expect(hex(".123")).toBe(291); + expect(hex("123.")).toBe(291); + expect(hex(".1.2.3.")).toBe(291); + }); +}); + describe("toValidationError", () => { it("converts an issue to a validation error", () => { const issue = { diff --git a/web/src/utils.ts b/web/src/utils.ts index 5b611be39b..238fd0b939 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -164,11 +164,29 @@ const useDebounce = (callback: Function, delay: number) => { }; /** - * Convert given string to a hexadecimal number + * Parses a "numeric dot string" as a hexadecimal number. + * + * Accepts only strings containing digits (`0–9`) and dots (`.`), + * for example: `"0.0.0160"` or `"123"`. Dots are removed before parsing. + * + * If the cleaned string contains any non-digit characters (such as letters), + * or is not a valid integer string, the function returns `0`. + * + * @example + * + * ```ts + * hex("0.0.0.160"); // Returns 352 + * hex("1.2.3"); // Returns 291 + * hex("1.A.3"); // Returns 0 (letters are not allowed) + * hex(".."); // Returns 0 (empty string before removing dots) + * ``` + * + * @param value - A string representing a dot-separated numeric value + * @returns The number parsed as hexadecimal (base-16) integer, or `0` if invalid */ -const hex = (value: string) => { - const sanitizedValue = value.replaceAll(".", ""); - return parseInt(sanitizedValue, 16); +const hex = (value: string): number => { + const sanitizedValued = value.replaceAll(".", ""); + return /^[0-9]+$/.test(sanitizedValued) ? parseInt(sanitizedValued, 16) : 0; }; /** From 9c2582cfaca17cab25b496425a73e1f739caf067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Sat, 31 May 2025 18:49:51 +0100 Subject: [PATCH 12/25] fix(util): make `mask` more efficient and robust Replaced the RegExp-based masking (which involved pattern matching) with a simpler implementation using native string methods wich helps reducing complexity and improving performance. Added comprehensive unit tests covering edge cases including negative, zero, and NaN values for the visible parameter to ensure robustness. --- web/src/utils.test.ts | 38 +++++++++++++++++++++++++++++++++++++- web/src/utils.ts | 30 +++++++++++++++++++++++++++--- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 40d94a7468..6e8edc869c 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { compact, toValidationError, localConnection, hex } from "./utils"; +import { compact, toValidationError, localConnection, hex, mask } from "./utils"; describe("compact", () => { it("removes null and undefined values", () => { @@ -62,6 +62,42 @@ describe("hex", () => { }); }); +describe("mask", () => { + it("masks all but the last 4 characters by default", () => { + expect(mask("123456789")).toBe("*****6789"); + expect(mask("abcd")).toBe("abcd"); + expect(mask("abcde")).toBe("*bcde"); + }); + + it("respects custom visible count", () => { + expect(mask("secret", 2)).toBe("****et"); + expect(mask("secret", 0)).toBe("******"); + expect(mask("secret", 6)).toBe("secret"); + expect(mask("secret", 10)).toBe("secret"); + }); + + it("uses custom mask character", () => { + expect(mask("secret", 3, "#")).toBe("###ret"); + expect(mask("secret", 1, "X")).toBe("XXXXXt"); + expect(mask("secret", 2, "!")).toBe("!!!!et"); + }); + + it("handles empty and short input values", () => { + expect(mask("")).toBe(""); + expect(mask("a")).toBe("a"); + expect(mask("ab", 5)).toBe("ab"); + }); + + it("handles negative or NaN visible values safely", () => { + expect(mask("secret", -2)).toBe("******"); + expect(mask("secret", NaN)).toBe("******"); + }); + + it("masks with empty character (no masking)", () => { + expect(mask("secret", 2, "")).toBe("et"); + }); +}); + describe("toValidationError", () => { it("converts an issue to a validation error", () => { const issue = { diff --git a/web/src/utils.ts b/web/src/utils.ts index 238fd0b939..2329ce019e 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -273,9 +273,33 @@ const timezoneTime = (timezone: string, date: Date = new Date()): string | undef } }; -const mask = (value, visible = 4, character = "*") => { - const regex = new RegExp(`.(?=(.{${visible}}))`, "g"); - return value.replace(regex, character); +/** + * Masks all but the last `visible` characters of a string. + * + * Replaces each character in the input string with `maskChar` ("*" by default), + * except for the last `visible` (4 by default) characters, which are left + * unchanged. If `visible` is greater than or equal to the string length, the + * input is returned as-is. + * + * @example + * ```ts + * mask("123456789"); // "*****6789" + * mask("secret", 2); // "****et" + * mask("secret", 6); // "secret" + * mask("secret", 3, "#"); // "###ret" + * ``` + * + * @param value - The input string to mask + * @param visible - Number of trailing characters to leave unmasked (default: 4) + * @param maskChar - The character to use for masking (default: "*") + * @returns The masked string with only the last `visible` characters shown + */ +const mask = (value: string, visible: number = 4, maskChar: string = "*"): string => { + const length = value.length; + const safeVisible = Number.isFinite(visible) && visible > 0 ? visible : 0; + const maskedLength = Math.max(0, length - safeVisible); + const visiblePart = safeVisible === 0 ? "" : value.slice(-safeVisible); + return maskChar.repeat(maskedLength) + visiblePart; }; const agamaWidthBreakpoints = { From 4523c3406b8e17b48e666b78a080224adb0ae9e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Sat, 31 May 2025 19:14:48 +0100 Subject: [PATCH 13/25] fix(web): extract useCancellablePromise hook from utils Moved useCancellablePromise from utils to its own file under the hooks/ directory for better code organization. No tests added, as this hook is likely to be removed once storage ZFCP components are updated, similar to how it was removed from other parts of Agama after adopting TanStack Query (see commits 688397aa5, 5043e964e, or 78b50f2b9 for reference). --- .../storage/zfcp/ZFCPControllersTable.tsx | 2 +- .../storage/zfcp/ZFCPDiskActivationPage.tsx | 2 +- .../storage/zfcp/ZFCPDisksTable.tsx | 2 +- web/src/hooks/use-cancellable-promise.ts | 101 ++++++++++++++++++ web/src/utils.ts | 81 +------------- 5 files changed, 105 insertions(+), 83 deletions(-) create mode 100644 web/src/hooks/use-cancellable-promise.ts diff --git a/web/src/components/storage/zfcp/ZFCPControllersTable.tsx b/web/src/components/storage/zfcp/ZFCPControllersTable.tsx index 0502b6ee9c..7aab404b07 100644 --- a/web/src/components/storage/zfcp/ZFCPControllersTable.tsx +++ b/web/src/components/storage/zfcp/ZFCPControllersTable.tsx @@ -2,7 +2,7 @@ import { Skeleton } from "@patternfly/react-core"; import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; import React, { useState } from "react"; import { _ } from "~/i18n"; -import { useCancellablePromise } from "~/utils"; +import { useCancellablePromise } from "~/hooks/use-cancellable-promise"; import { RowActions } from "../../core"; import { ZFCPController } from "~/types/zfcp"; import { activateZFCPController } from "~/api/storage/zfcp"; diff --git a/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx b/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx index 58b13a6a88..1f4d8a0ce4 100644 --- a/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx +++ b/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx @@ -24,7 +24,7 @@ import React, { useState } from "react"; import { Content, Grid, GridItem } from "@patternfly/react-core"; import { Page } from "~/components/core"; import { _ } from "~/i18n"; -import { useCancellablePromise } from "~/utils"; +import { useCancellablePromise } from "~/hooks/use-cancellable-promise"; import { LUNInfo } from "~/types/zfcp"; import { activateZFCPDisk } from "~/api/storage/zfcp"; import { PATHS } from "~/routes/storage"; diff --git a/web/src/components/storage/zfcp/ZFCPDisksTable.tsx b/web/src/components/storage/zfcp/ZFCPDisksTable.tsx index a8860b2695..29b9ed1873 100644 --- a/web/src/components/storage/zfcp/ZFCPDisksTable.tsx +++ b/web/src/components/storage/zfcp/ZFCPDisksTable.tsx @@ -24,7 +24,7 @@ import React, { useState } from "react"; import { deactivateZFCPDisk } from "~/api/storage/zfcp"; import { useZFCPControllers, useZFCPDisks } from "~/queries/storage/zfcp"; import { ZFCPDisk } from "~/types/zfcp"; -import { useCancellablePromise } from "~/utils"; +import { useCancellablePromise } from "~/hooks/use-cancellable-promise"; import RowActions from "../../core/RowActions"; import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; import { Skeleton } from "@patternfly/react-core"; diff --git a/web/src/hooks/use-cancellable-promise.ts b/web/src/hooks/use-cancellable-promise.ts new file mode 100644 index 0000000000..751c75419f --- /dev/null +++ b/web/src/hooks/use-cancellable-promise.ts @@ -0,0 +1,101 @@ +/* + * Copyright (c) [2022-2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { useEffect, useRef, useCallback } from "react"; + +type CancellableWrapper = { + /** Cancellable promise */ + promise: Promise; + /** Function for cancelling the promise */ + cancel: Function; +}; + +/** + * Creates a wrapper object with a cancellable promise and a function for canceling the promise + * + * @see useCancellablePromise + */ +const makeCancellable = (promise: Promise): CancellableWrapper => { + let isCanceled = false; + + const cancellablePromise: Promise = new Promise((resolve, reject) => { + promise + .then((value) => !isCanceled && resolve(value)) + .catch((error) => !isCanceled && reject(error)); + }); + + return { + promise: cancellablePromise, + cancel() { + isCanceled = true; + }, + }; +}; + +/** + * Allows using promises in a safer way. + * + * This hook is useful for safely performing actions that modify a React component after resolving + * a promise (e.g., setting the component state once a D-Bus call is answered). Note that nothing + * guarantees that a React component is still mounted when a promise is resolved. + * + * @see {@link https://overreacted.io/a-complete-guide-to-useeffect/#speaking-of-race-conditions|Race conditions} + * + * The hook provides a function for making promises cancellable. All cancellable promises are + * automatically canceled once the component is unmounted. Note that the promises are not really + * canceled. In this context, a canceled promise means that the promise will be neither resolved nor + * rejected. Canceled promises will be destroyed by the garbage collector after unmounting the + * component. + * + * @see {@link https://rajeshnaroth.medium.com/writing-a-react-hook-to-cancel-promises-when-a-component-unmounts-526efabf251f|Cancel promises} + * + * @example + * + * const { cancellablePromise } = useCancellablePromise(); + * const [state, setState] = useState(); + * + * useEffect(() => { + * const promise = new Promise((resolve) => setTimeout(() => resolve("success"), 6000)); + * // The state is only set if the promise is not canceled + * cancellablePromise(promise).then(setState); + * }, [setState, cancellablePromise]); + */ +export const useCancellablePromise = () => { + const promises = useRef>>(); + + useEffect(() => { + promises.current = []; + + return () => { + promises.current.forEach((p) => p.cancel()); + promises.current = []; + }; + }, []); + + const cancellablePromise = useCallback((promise: Promise): Promise => { + const cancellableWrapper: CancellableWrapper = makeCancellable(promise); + promises.current.push(cancellableWrapper); + return cancellableWrapper.promise; + }, []); + + return { cancellablePromise }; +}; diff --git a/web/src/utils.ts b/web/src/utils.ts index 2329ce019e..130ec6940d 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { useEffect, useRef, useCallback, useState } from "react"; +import { useEffect, useRef, useState } from "react"; /** * Generates a new array without null and undefined values. @@ -29,84 +29,6 @@ const compact = (collection: Array) => { return collection.filter((e) => e !== null && e !== undefined); }; -type CancellableWrapper = { - /** Cancellable promise */ - promise: Promise; - /** Function for cancelling the promise */ - cancel: Function; -}; - -/** - * Creates a wrapper object with a cancellable promise and a function for canceling the promise - * - * @see useCancellablePromise - */ -const makeCancellable = (promise: Promise): CancellableWrapper => { - let isCanceled = false; - - const cancellablePromise: Promise = new Promise((resolve, reject) => { - promise - .then((value) => !isCanceled && resolve(value)) - .catch((error) => !isCanceled && reject(error)); - }); - - return { - promise: cancellablePromise, - cancel() { - isCanceled = true; - }, - }; -}; - -/** - * Allows using promises in a safer way. - * - * This hook is useful for safely performing actions that modify a React component after resolving - * a promise (e.g., setting the component state once a D-Bus call is answered). Note that nothing - * guarantees that a React component is still mounted when a promise is resolved. - * - * @see {@link https://overreacted.io/a-complete-guide-to-useeffect/#speaking-of-race-conditions|Race conditions} - * - * The hook provides a function for making promises cancellable. All cancellable promises are - * automatically canceled once the component is unmounted. Note that the promises are not really - * canceled. In this context, a canceled promise means that the promise will be neither resolved nor - * rejected. Canceled promises will be destroyed by the garbage collector after unmounting the - * component. - * - * @see {@link https://rajeshnaroth.medium.com/writing-a-react-hook-to-cancel-promises-when-a-component-unmounts-526efabf251f|Cancel promises} - * - * @example - * - * const { cancellablePromise } = useCancellablePromise(); - * const [state, setState] = useState(); - * - * useEffect(() => { - * const promise = new Promise((resolve) => setTimeout(() => resolve("success"), 6000)); - * // The state is only set if the promise is not canceled - * cancellablePromise(promise).then(setState); - * }, [setState, cancellablePromise]); - */ -const useCancellablePromise = () => { - const promises = useRef>>(); - - useEffect(() => { - promises.current = []; - - return () => { - promises.current.forEach((p) => p.cancel()); - promises.current = []; - }; - }, []); - - const cancellablePromise = useCallback((promise: Promise): Promise => { - const cancellableWrapper: CancellableWrapper = makeCancellable(promise); - promises.current.push(cancellableWrapper); - return cancellableWrapper.promise; - }, []); - - return { cancellablePromise }; -}; - /** Hook for using local storage * * @see {@link https://www.robinwieruch.de/react-uselocalstorage-hook/} @@ -334,7 +256,6 @@ const getBreakpoint = (width: number): "default" | "sm" | "md" | "lg" | "xl" | " export { compact, - useCancellablePromise, useLocalStorage, useDebounce, hex, From 0ac4ae15161c4c8d8fb7c4b4dbad580a464c12fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 2 Jun 2025 13:21:14 +0100 Subject: [PATCH 14/25] fix(web): extract useLocalStorage hook from utils Moved useLocalStorage from utils to its own file under the hooks/ directory for better code organization. No tests were added, as the hook is currently used by only one component, and its future in the codebase is uncertain. The component may be refactored and stop relying on this hook for data persistence. --- .../components/storage/iscsi/DiscoverForm.tsx | 2 +- web/src/hooks/use-local-storage.ts | 40 +++++++++++++++++++ web/src/utils.ts | 20 +--------- 3 files changed, 42 insertions(+), 20 deletions(-) create mode 100644 web/src/hooks/use-local-storage.ts diff --git a/web/src/components/storage/iscsi/DiscoverForm.tsx b/web/src/components/storage/iscsi/DiscoverForm.tsx index 4dbd3dec5c..043aa9731f 100644 --- a/web/src/components/storage/iscsi/DiscoverForm.tsx +++ b/web/src/components/storage/iscsi/DiscoverForm.tsx @@ -24,7 +24,7 @@ import React, { useEffect, useRef, useState } from "react"; import { Alert, Form, FormGroup, TextInput } from "@patternfly/react-core"; import { FormValidationError, Popup } from "~/components/core"; import { AuthFields } from "~/components/storage/iscsi"; -import { useLocalStorage } from "~/utils"; +import { useLocalStorage } from "~/hooks/use-local-storage"; import { isValidIp } from "~/utils/network"; import { _ } from "~/i18n"; diff --git a/web/src/hooks/use-local-storage.ts b/web/src/hooks/use-local-storage.ts new file mode 100644 index 0000000000..5056cc239a --- /dev/null +++ b/web/src/hooks/use-local-storage.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) [2022-2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { useEffect, useState } from "react"; + +/** Hook for using local storage + * + * @see {@link https://www.robinwieruch.de/react-uselocalstorage-hook/} + * + * @param storageKey + * @param fallbackState + */ +export const useLocalStorage = (storageKey: string, fallbackState) => { + const [value, setValue] = useState(JSON.parse(localStorage.getItem(storageKey)) ?? fallbackState); + + useEffect(() => { + localStorage.setItem(storageKey, JSON.stringify(value)); + }, [value, storageKey]); + + return [value, setValue]; +}; diff --git a/web/src/utils.ts b/web/src/utils.ts index 130ec6940d..3e7408efba 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; /** * Generates a new array without null and undefined values. @@ -29,23 +29,6 @@ const compact = (collection: Array) => { return collection.filter((e) => e !== null && e !== undefined); }; -/** Hook for using local storage - * - * @see {@link https://www.robinwieruch.de/react-uselocalstorage-hook/} - * - * @param storageKey - * @param fallbackState - */ -const useLocalStorage = (storageKey: string, fallbackState) => { - const [value, setValue] = useState(JSON.parse(localStorage.getItem(storageKey)) ?? fallbackState); - - useEffect(() => { - localStorage.setItem(storageKey, JSON.stringify(value)); - }, [value, storageKey]); - - return [value, setValue]; -}; - /** * Debounce hook. * @@ -256,7 +239,6 @@ const getBreakpoint = (width: number): "default" | "sm" | "md" | "lg" | "xl" | " export { compact, - useLocalStorage, useDebounce, hex, toValidationError, From b04f68efc86286e1592ff6d3813b7b0b7f8acdd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 2 Jun 2025 13:40:06 +0100 Subject: [PATCH 15/25] refactor(web): extract useDebounce hook from utils Moved useDebounce from utils to its own file under the hooks/ directory for better code organization. No tests were added, as the hook is currently used by only one component and it could be replaced by the "debounce" Radashi util function. --- web/src/components/core/ListSearch.tsx | 2 +- web/src/hooks/use-debounce.ts | 62 ++++++++++++++++++++++++++ web/src/utils.ts | 42 ----------------- 3 files changed, 63 insertions(+), 43 deletions(-) create mode 100644 web/src/hooks/use-debounce.ts diff --git a/web/src/components/core/ListSearch.tsx b/web/src/components/core/ListSearch.tsx index fb50db9420..71649d47f6 100644 --- a/web/src/components/core/ListSearch.tsx +++ b/web/src/components/core/ListSearch.tsx @@ -23,7 +23,7 @@ import React, { useState } from "react"; import { SearchInput } from "@patternfly/react-core"; import { noop } from "radashi"; -import { useDebounce } from "~/utils"; +import { useDebounce } from "~/hooks/use-debounce"; import { _ } from "~/i18n"; type ListSearchProps = { diff --git a/web/src/hooks/use-debounce.ts b/web/src/hooks/use-debounce.ts new file mode 100644 index 0000000000..1114d8cebd --- /dev/null +++ b/web/src/hooks/use-debounce.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) [2022-2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { useEffect, useRef } from "react"; + +/** + * Debounce hook. + * + * Source {@link https://designtechworld.medium.com/create-a-custom-debounce-hook-in-react-114f3f245260} + * + * @param callback - Function to be called after some delay. + * @param delay - Delay in milliseconds. + * + * @example + * + * const log = useDebounce(console.log, 1000); + * log("test ", 1) // The message will be logged after at least 1 second. + * log("test ", 2) // Subsequent calls cancels pending calls. + */ +export const useDebounce = (callback: Function, delay: number) => { + const timeoutRef = useRef | null>(null); + + useEffect(() => { + // Cleanup the previous timeout on re-render + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const debouncedCallback = (...args) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + callback(...args); + }, delay); + }; + + return debouncedCallback; +}; diff --git a/web/src/utils.ts b/web/src/utils.ts index 3e7408efba..beb602784e 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -20,8 +20,6 @@ * find current contact information at www.suse.com. */ -import { useEffect, useRef } from "react"; - /** * Generates a new array without null and undefined values. */ @@ -29,45 +27,6 @@ const compact = (collection: Array) => { return collection.filter((e) => e !== null && e !== undefined); }; -/** - * Debounce hook. - * - * Source {@link https://designtechworld.medium.com/create-a-custom-debounce-hook-in-react-114f3f245260} - * - * @param callback - Function to be called after some delay. - * @param delay - Delay in milliseconds. - * - * @example - * - * const log = useDebounce(console.log, 1000); - * log("test ", 1) // The message will be logged after at least 1 second. - * log("test ", 2) // Subsequent calls cancels pending calls. - */ -const useDebounce = (callback: Function, delay: number) => { - const timeoutRef = useRef(null); - - useEffect(() => { - // Cleanup the previous timeout on re-render - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }; - }, []); - - const debouncedCallback = (...args) => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - - timeoutRef.current = setTimeout(() => { - callback(...args); - }, delay); - }; - - return debouncedCallback; -}; - /** * Parses a "numeric dot string" as a hexadecimal number. * @@ -239,7 +198,6 @@ const getBreakpoint = (width: number): "default" | "sm" | "md" | "lg" | "xl" | " export { compact, - useDebounce, hex, toValidationError, locationReload, From 7fae38462f61cc1942269afd2aef33b4971f0f99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 2 Jun 2025 14:58:07 +0100 Subject: [PATCH 16/25] refactor(web): replace useDebounce hook with Radashi/debounce Replaces the custom useDebounce React hook with Radashi debounce function. This simplifies the code by removing the need for a hook while preserving the same behavior. The radashi implementation is also well-tested and offers additional configuration options and methods, which might be useful in the future. --- web/src/components/core/ListSearch.tsx | 15 ++++--- web/src/hooks/use-debounce.ts | 62 -------------------------- 2 files changed, 8 insertions(+), 69 deletions(-) delete mode 100644 web/src/hooks/use-debounce.ts diff --git a/web/src/components/core/ListSearch.tsx b/web/src/components/core/ListSearch.tsx index 71649d47f6..d9b510da2d 100644 --- a/web/src/components/core/ListSearch.tsx +++ b/web/src/components/core/ListSearch.tsx @@ -22,8 +22,7 @@ import React, { useState } from "react"; import { SearchInput } from "@patternfly/react-core"; -import { noop } from "radashi"; -import { useDebounce } from "~/hooks/use-debounce"; +import { debounce, noop } from "radashi"; import { _ } from "~/i18n"; type ListSearchProps = { @@ -45,6 +44,12 @@ function search(elements: T[], term: string): T[] { return elements.filter(match); } +function filterList(elements: ListSearchProps["elements"], term: string, action: Function) { + action(search(elements, term)); +} + +const searchHandler = debounce({ delay: 500 }, filterList); + /** * Input field for searching in a given list of elements. * @component @@ -62,13 +67,9 @@ export default function ListSearch({ onChangeProp(result); }; - const searchHandler = useDebounce((term: string) => { - updateResult(search(elements, term)); - }, 500); - const onChange = (value: string) => { setValue(value); - searchHandler(value); + searchHandler(elements, value, updateResult); }; const onClear = () => { diff --git a/web/src/hooks/use-debounce.ts b/web/src/hooks/use-debounce.ts deleted file mode 100644 index 1114d8cebd..0000000000 --- a/web/src/hooks/use-debounce.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) [2022-2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import { useEffect, useRef } from "react"; - -/** - * Debounce hook. - * - * Source {@link https://designtechworld.medium.com/create-a-custom-debounce-hook-in-react-114f3f245260} - * - * @param callback - Function to be called after some delay. - * @param delay - Delay in milliseconds. - * - * @example - * - * const log = useDebounce(console.log, 1000); - * log("test ", 1) // The message will be logged after at least 1 second. - * log("test ", 2) // Subsequent calls cancels pending calls. - */ -export const useDebounce = (callback: Function, delay: number) => { - const timeoutRef = useRef | null>(null); - - useEffect(() => { - // Cleanup the previous timeout on re-render - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }; - }, []); - - const debouncedCallback = (...args) => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - - timeoutRef.current = setTimeout(() => { - callback(...args); - }, delay); - }; - - return debouncedCallback; -}; From 0cf119e3ad71f0bdf05051e4c8d6ac1d210dcbfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 2 Jun 2025 15:12:40 +0100 Subject: [PATCH 17/25] fix(web): move breakpoint overrides to Layout component The Layout component is currently the only consumer of the custom breakpoints, so it makes little sense to keep them in a shared utils file where they unnecessarily pollute the global namespace. As part of the move, the breakpoints and related logic have been properly documented for maintainability. --- web/src/components/layout/Layout.tsx | 56 +++++++++++++++++++++++++++- web/src/utils.ts | 32 ---------------- 2 files changed, 55 insertions(+), 33 deletions(-) diff --git a/web/src/components/layout/Layout.tsx b/web/src/components/layout/Layout.tsx index 968aac813d..b19613c4ba 100644 --- a/web/src/components/layout/Layout.tsx +++ b/web/src/components/layout/Layout.tsx @@ -28,7 +28,6 @@ import Header, { HeaderProps } from "~/components/layout/Header"; import { Loading, Sidebar } from "~/components/layout"; import { IssuesDrawer, SkipTo } from "~/components/core"; import { ROOT } from "~/routes/paths"; -import { agamaWidthBreakpoints, getBreakpoint } from "~/utils"; export type LayoutProps = React.PropsWithChildren<{ className?: string; @@ -48,6 +47,61 @@ const focusDrawer = (drawer: HTMLElement | null) => { firstTabbableItem?.focus(); }; +/** + * Custom width breakpoints for the Agama application layout. + * + * These values override PatternFly's default breakpoints to better fit + * Agama's responsive design needs. Each value is specified in pixels + * and is derived from rem-based measurements, multiplied by the standard + * root font size (16px). + * + * Breakpoints: + * - sm: 36rem → 576px + * - md: 48rem → 768px + * - lg: 64rem → 1024px + * - xl: 75rem → 1200px + * - 2xl: 90rem → 1440px + */ +const agamaWidthBreakpoints = { + sm: parseInt("36rem") * 16, + md: parseInt("48rem") * 16, + lg: parseInt("64rem") * 16, + xl: parseInt("75rem") * 16, + "2xl": parseInt("90rem") * 16, +}; + +/** + * Maps a viewport width (in pixels) to the appropriate Agama breakpoint. + * + * This function is used to determine the responsive breakpoint level + * based on the current viewport width, aligning with the custom + * `agamaWidthBreakpoints`. + * + * @param width - The current viewport width in pixels. + * @returns A breakpoint string: 'default', 'sm', 'md', 'lg', 'xl', or '2xl'. + */ +const getBreakpoint = (width: number): "default" | "sm" | "md" | "lg" | "xl" | "2xl" => { + if (width === null) { + return null; + } + if (width >= agamaWidthBreakpoints["2xl"]) { + return "2xl"; + } + if (width >= agamaWidthBreakpoints.xl) { + return "xl"; + } + if (width >= agamaWidthBreakpoints.lg) { + return "lg"; + } + if (width >= agamaWidthBreakpoints.md) { + return "md"; + } + if (width >= agamaWidthBreakpoints.sm) { + return "sm"; + } + return "default"; +}; + /** * Component for laying out the application content inside a PF/Page that might * or might not mount a header and a sidebar depending on the given props. diff --git a/web/src/utils.ts b/web/src/utils.ts index beb602784e..4e16db15cb 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -166,36 +166,6 @@ const mask = (value: string, visible: number = 4, maskChar: string = "*"): strin return maskChar.repeat(maskedLength) + visiblePart; }; -const agamaWidthBreakpoints = { - sm: parseInt("36rem") * 16, - md: parseInt("48rem") * 16, - lg: parseInt("64rem") * 16, - xl: parseInt("75rem") * 16, - "2xl": parseInt("90rem") * 16, -}; - -const getBreakpoint = (width: number): "default" | "sm" | "md" | "lg" | "xl" | "2xl" => { - if (width === null) { - return null; - } - if (width >= agamaWidthBreakpoints["2xl"]) { - return "2xl"; - } - if (width >= agamaWidthBreakpoints.xl) { - return "xl"; - } - if (width >= agamaWidthBreakpoints.lg) { - return "lg"; - } - if (width >= agamaWidthBreakpoints.md) { - return "md"; - } - if (width >= agamaWidthBreakpoints.sm) { - return "sm"; - } - return "default"; -}; - export { compact, hex, @@ -205,6 +175,4 @@ export { localConnection, timezoneTime, mask, - getBreakpoint, - agamaWidthBreakpoints, }; From e14487bbd24a2b1491abf09422eab91d6b044f88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 2 Jun 2025 15:26:47 +0100 Subject: [PATCH 18/25] fix(web): remove unused `toValidationError` utility The `toValidationError` function is no longer in use. It was originally introduced in the storage area implementation (#540, commit 1fc1f6f8) but its usage was gradually phased out across later changes (e.g., #1112), until it became fully unused in commit 9cfc9c7b (part of #1972). Removing it to reduce dead code and simplify the utils namespace. --- web/src/utils.test.ts | 14 +------------- web/src/utils.ts | 21 +-------------------- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 6e8edc869c..5a24794b44 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { compact, toValidationError, localConnection, hex, mask } from "./utils"; +import { compact, localConnection, hex, mask } from "./utils"; describe("compact", () => { it("removes null and undefined values", () => { @@ -98,18 +98,6 @@ describe("mask", () => { }); }); -describe("toValidationError", () => { - it("converts an issue to a validation error", () => { - const issue = { - description: "Issue 1", - details: "Details issue 1", - source: "config", - severity: "warn", - }; - expect(toValidationError(issue)).toEqual({ message: "Issue 1" }); - }); -}); - const localURL = new URL("http://127.0.0.90/"); const localURL2 = new URL("http://localhost:9090/"); const remoteURL = new URL("http://example.com"); diff --git a/web/src/utils.ts b/web/src/utils.ts index 4e16db15cb..5ff2bdff97 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -53,16 +53,6 @@ const hex = (value: string): number => { return /^[0-9]+$/.test(sanitizedValued) ? parseInt(sanitizedValued, 16) : 0; }; -/** - * Converts an issue to a validation error - * - * @todo This conversion will not be needed after adapting Section to directly work with issues. - * - * @param {import("~/types/issues").Issue} issue - * @returns {import("~/types/issues").ValidationError} - */ -const toValidationError = (issue) => ({ message: issue.description }); - /** * Wrapper around window.location.reload * @@ -166,13 +156,4 @@ const mask = (value: string, visible: number = 4, maskChar: string = "*"): strin return maskChar.repeat(maskedLength) + visiblePart; }; -export { - compact, - hex, - toValidationError, - locationReload, - setLocationSearch, - localConnection, - timezoneTime, - mask, -}; +export { compact, hex, locationReload, setLocationSearch, localConnection, timezoneTime, mask }; From c4a48f4a500e2980fd8f95cbd002c0accc5aed77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 2 Jun 2025 15:39:20 +0100 Subject: [PATCH 19/25] fix(web): complete testing for localConnection util --- web/src/utils.test.ts | 60 ++++++++++++++++++++++++++++++++----------- web/src/utils.ts | 2 +- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 5a24794b44..5ac0e5847f 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -98,26 +98,56 @@ describe("mask", () => { }); }); -const localURL = new URL("http://127.0.0.90/"); -const localURL2 = new URL("http://localhost:9090/"); -const remoteURL = new URL("http://example.com"); - describe("localConnection", () => { - describe("when the page URL is " + localURL, () => { - it("returns true", () => { - expect(localConnection(localURL)).toEqual(true); - }); + const originalEnv = process.env; + const localhostURL = new URL("http://localhost"); + const localIpURL = new URL("http://127.0.0.90"); + const remoteURL = new URL("http://example.com"); + + beforeEach(() => { + process.env = { ...originalEnv }; }); - describe("when the page URL is " + localURL2, () => { - it("returns true", () => { - expect(localConnection(localURL2)).toEqual(true); - }); + it("uses window.location when no argument is provided", () => { + const originalLocation = window.location; + delete window.location; + // @ts-expect-error: https://github.com/microsoft/TypeScript/issues/48949 + window.location = localhostURL; + + expect(localConnection()).toBe(true); + + // Restore + // @ts-expect-error: https://github.com/microsoft/TypeScript/issues/48949 + window.location = originalLocation; + }); + + it("returns true for 'localhost' hostname", () => { + expect(localConnection(localhostURL)).toBe(true); + }); + + it("returns true for 127.x.x.x IPs", () => { + expect(localConnection(new URL(localIpURL))).toBe(true); + }); + + it("returns false for non-local hostnames", () => { + expect(localConnection(remoteURL)).toBe(false); }); - describe("when the page URL is " + remoteURL, () => { - it("returns false", () => { - expect(localConnection(remoteURL)).toEqual(false); + describe("but LOCAL_CONNECTION environment variable is '1'", () => { + beforeEach(() => { + process.env.LOCAL_CONNECTION = "1"; + }); + + it("returns true for 'localhost' hostname", () => { + expect(localConnection(localhostURL)).toBe(true); + }); + + it("returns true for 127.x.x.x IPs", () => { + expect(localConnection(localIpURL)).toBe(true); + }); + + it("returns true for non-local hostnames", () => { + expect(localConnection(remoteURL)).toBe(true); }); }); }); diff --git a/web/src/utils.ts b/web/src/utils.ts index 5ff2bdff97..eb63dd282a 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -82,7 +82,7 @@ const setLocationSearch = (query: string) => { }; /** - * WetherAgama server is running locally or not. + * Whether Agama server is running locally or not. * * This function should be used only in special cases, the Agama behavior should * be the same regardless of the user connection. From 2139c2f85235950f15557e075e3da1659f30585e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 2 Jun 2025 17:17:32 +0100 Subject: [PATCH 20/25] fix(web): add missing tests for `timezoneTime` util --- web/src/utils.test.ts | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 5ac0e5847f..ef535bf242 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { compact, localConnection, hex, mask } from "./utils"; +import { compact, localConnection, hex, mask, timezoneTime } from "./utils"; describe("compact", () => { it("removes null and undefined values", () => { @@ -98,6 +98,44 @@ describe("mask", () => { }); }); +describe("timezoneTime", () => { + const fixedDate = new Date("2023-01-01T12:34:56Z"); + + it("returns time in 24h format for a valid timezone", () => { + const time = timezoneTime("UTC", fixedDate); + expect(time).toBe("12:34"); + }); + + it("uses current date if no date provided", () => { + // Fake "now" + const now = new Date("2023-06-01T15:45:00Z"); + jest.useFakeTimers(); + jest.setSystemTime(now); + + const time = timezoneTime("UTC"); + expect(time).toBe("15:45"); + + jest.useRealTimers(); + }); + + it("returns undefined for invalid timezone", () => { + expect(timezoneTime("Invalid/Timezone", fixedDate)).toBeUndefined(); + }); + + it("rethrows unexpected errors", () => { + // To simulate unexpected error, mock Intl.DateTimeFormat to throw something else + const original = Intl.DateTimeFormat; + // @ts-expect-error because missing properties not needed for this test + Intl.DateTimeFormat = jest.fn(() => { + throw new Error("Unexpected"); + }); + + expect(() => timezoneTime("UTC", fixedDate)).toThrow("Unexpected"); + + Intl.DateTimeFormat = original; + }); +}); + describe("localConnection", () => { const originalEnv = process.env; const localhostURL = new URL("http://localhost"); From 9ad87b467ee3d03bf75747f6661f502f127cb76c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 2 Jun 2025 23:52:04 +0100 Subject: [PATCH 21/25] doc(web): small wording adjustments --- web/src/components/layout/Layout.tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/web/src/components/layout/Layout.tsx b/web/src/components/layout/Layout.tsx index b19613c4ba..322e3ab8d6 100644 --- a/web/src/components/layout/Layout.tsx +++ b/web/src/components/layout/Layout.tsx @@ -48,19 +48,18 @@ const focusDrawer = (drawer: HTMLElement | null) => { }; /** - * Custom width breakpoints for the Agama application layout. + * Custom width breakpoints for the Agama layout. * - * These values override PatternFly's default breakpoints to better fit - * Agama's responsive design needs. Each value is specified in pixels - * and is derived from rem-based measurements, multiplied by the standard - * root font size (16px). + * These values override PatternFly's default breakpoints to better fit Agama's + * needs. Each value is specified in pixels and is derived from rem-based + * measurements, multiplied by the standard root font size (16px). * * Breakpoints: - * - sm: 36rem → 576px - * - md: 48rem → 768px - * - lg: 64rem → 1024px - * - xl: 75rem → 1200px - * - 2xl: 90rem → 1440px + * - sm: 36rem / 576px + * - md: 48rem / 768px + * - lg: 64rem / 1024px + * - xl: 75rem / 1200px + * - 2xl: 90rem / 1440px */ const agamaWidthBreakpoints = { sm: parseInt("36rem") * 16, From 65564d97ae8ab9f7e6c43a338d481e12df1e456d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 6 Jun 2025 09:05:01 +0100 Subject: [PATCH 22/25] chore(web): remove unused useNodeSiblings hook The hook has not been used since commit 726c63270f237e29ebf38f1069ab42b63019f20c. --- web/src/hooks/useNodeSiblings.test.tsx | 73 -------------------------- web/src/hooks/useNodeSiblings.ts | 33 ------------ 2 files changed, 106 deletions(-) delete mode 100644 web/src/hooks/useNodeSiblings.test.tsx delete mode 100644 web/src/hooks/useNodeSiblings.ts diff --git a/web/src/hooks/useNodeSiblings.test.tsx b/web/src/hooks/useNodeSiblings.test.tsx deleted file mode 100644 index 5052fd651b..0000000000 --- a/web/src/hooks/useNodeSiblings.test.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from "react"; -import { screen, renderHook } from "@testing-library/react"; -import useNodeSiblings from "./useNodeSiblings"; -import { plainRender } from "~/test-utils"; - -const TestingComponent = () => ( -
-
-
-
-
-
-
-
-); - -describe("useNodeSiblings", () => { - it("should return noop functions when node is not provided", () => { - const { result } = renderHook(() => useNodeSiblings(null)); - const [addAttribute, removeAttribute] = result.current; - - expect(addAttribute).toBeInstanceOf(Function); - expect(removeAttribute).toBeInstanceOf(Function); - expect(addAttribute).toEqual(expect.any(Function)); - expect(removeAttribute).toEqual(expect.any(Function)); - - // Call the noop functions to ensure they don't throw any errors - expect(() => addAttribute("attribute", "value")).not.toThrow(); - expect(() => removeAttribute("attribute")).not.toThrow(); - }); - - it("should add attribute to all siblings when addAttribute is called", () => { - plainRender(); - const targetNode = screen.getByRole("region", { name: "Second sibling" }); - const firstSibling = screen.getByRole("region", { name: "First sibling" }); - const thirdSibling = screen.getByRole("region", { name: "Third sibling" }); - const noSibling = screen.getByRole("region", { name: "Not a sibling" }); - const { result } = renderHook(() => useNodeSiblings(targetNode)); - const [addAttribute] = result.current; - const attributeName = "attribute"; - const attributeValue = "value"; - - expect(firstSibling).not.toHaveAttribute(attributeName, attributeValue); - expect(thirdSibling).not.toHaveAttribute(attributeName, attributeValue); - expect(noSibling).not.toHaveAttribute(attributeName, attributeValue); - - addAttribute(attributeName, attributeValue); - - expect(firstSibling).toHaveAttribute(attributeName, attributeValue); - expect(thirdSibling).toHaveAttribute(attributeName, attributeValue); - expect(noSibling).not.toHaveAttribute(attributeName, attributeValue); - }); - - it("should remove attribute from all siblings when removeAttribute is called", () => { - plainRender(); - const targetNode = screen.getByRole("region", { name: "Second sibling" }); - const firstSibling = screen.getByRole("region", { name: "First sibling" }); - const thirdSibling = screen.getByRole("region", { name: "Third sibling" }); - const noSibling = screen.getByRole("region", { name: "Not a sibling" }); - const { result } = renderHook(() => useNodeSiblings(targetNode)); - const [, removeAttribute] = result.current; - - expect(firstSibling).toHaveAttribute("data-foo", "bar"); - expect(thirdSibling).toHaveAttribute("data-foo", "bar"); - expect(noSibling).toHaveAttribute("data-foo", "bar"); - - removeAttribute("data-foo"); - - expect(firstSibling).not.toHaveAttribute("data-foo", "bar"); - expect(thirdSibling).not.toHaveAttribute("data-foo", "bar"); - expect(noSibling).toHaveAttribute("data-foo", "bar"); - }); -}); diff --git a/web/src/hooks/useNodeSiblings.ts b/web/src/hooks/useNodeSiblings.ts deleted file mode 100644 index f83e115661..0000000000 --- a/web/src/hooks/useNodeSiblings.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { noop } from "radashi"; - -type AddAttributeFn = HTMLElement["setAttribute"]; -type RemoveAttributeFn = HTMLElement["removeAttribute"]; - -/** - * A hook for working with siblings of the node passed as parameter - * - * It returns an array with exactly two functions: - * - First for adding given attribute to siblings - * - Second for removing given attributes from siblings - */ -const useNodeSiblings = (node: HTMLElement): [AddAttributeFn, RemoveAttributeFn] => { - if (!node) return [noop, noop]; - - const siblings = [...node.parentNode.children].filter((n) => n !== node); - - const addAttribute: AddAttributeFn = (attribute, value) => { - siblings.forEach((sibling) => { - sibling.setAttribute(attribute, value); - }); - }; - - const removeAttribute: RemoveAttributeFn = (attribute: string) => { - siblings.forEach((sibling) => { - sibling.removeAttribute(attribute); - }); - }; - - return [addAttribute, removeAttribute]; -}; - -export default useNodeSiblings; From 12abfec6c77c799c298b4044cb0725053ec5cc10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 6 Jun 2025 09:19:38 +0100 Subject: [PATCH 23/25] fix(web): drop wrong reference to D-Bus --- web/src/hooks/use-cancellable-promise.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/hooks/use-cancellable-promise.ts b/web/src/hooks/use-cancellable-promise.ts index 751c75419f..05c1ffa21d 100644 --- a/web/src/hooks/use-cancellable-promise.ts +++ b/web/src/hooks/use-cancellable-promise.ts @@ -54,9 +54,9 @@ const makeCancellable = (promise: Promise): CancellableWrapper => { /** * Allows using promises in a safer way. * - * This hook is useful for safely performing actions that modify a React component after resolving - * a promise (e.g., setting the component state once a D-Bus call is answered). Note that nothing - * guarantees that a React component is still mounted when a promise is resolved. + * This hook is useful for safely performing actions that modify a React + * component after resolving a promise. Note that nothing guarantees that a + * React component is still mounted when a promise is resolved. * * @see {@link https://overreacted.io/a-complete-guide-to-useeffect/#speaking-of-race-conditions|Race conditions} * From 08099961e5f61a5a8651a3dda1a57a91edf962c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 6 Jun 2025 14:32:02 +0100 Subject: [PATCH 24/25] fix(web): allow boolean values in network#Connection options This makes the Connection type work as expected by supporting boolean values for connection options. This is related to the fact that the removed `isEmpty` utility incorrectly supported boolean values as well. --- web/src/types/network.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/types/network.ts b/web/src/types/network.ts index 80d74d5544..fe5ad5984c 100644 --- a/web/src/types/network.ts +++ b/web/src/types/network.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { isEmpty, isObject } from "radashi"; +import { isBoolean, isEmpty, isObject } from "radashi"; import { buildAddress, buildAddresses, @@ -292,7 +292,7 @@ class Connection { if (!isObject(options)) return; for (const [key, value] of Object.entries(options)) { - if (!isEmpty(value)) this[key] = value; + if (isBoolean(value) || !isEmpty(value)) this[key] = value; } } From 631515e82a54462973a18a084954bf92c8f3c5f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 6 Jun 2025 14:48:54 +0100 Subject: [PATCH 25/25] doc(web): add entry to the changes file --- web/package/agama-web-ui.changes | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index f58a317206..ac911f0e8b 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,11 @@ +------------------------------------------------------------------- +Fri Jun 6 13:44:14 UTC 2025 - David Diaz + +- Replaced custom utility functions with a well-tested, + community-driven third-party dependency, refactored remaining + helpers, and removed unused code to improve maintainability + (gh#agama-project/agama#2412). + ------------------------------------------------------------------- Fri Jun 6 12:32:23 UTC 2025 - Knut Anderssen