diff --git a/web/package-lock.json b/web/package-lock.json index 1e90592e44..a2a71f21b7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,6 +16,7 @@ "axios": "^1.9.0", "fast-sort": "^3.4.1", "ipaddr.js": "^2.2.0", + "radashi": "^12.5.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.3.0", @@ -14663,6 +14664,15 @@ ], "license": "MIT" }, + "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 49bcbb9de1..4c60297086 100644 --- a/web/package.json +++ b/web/package.json @@ -104,6 +104,7 @@ "axios": "^1.9.0", "fast-sort": "^3.4.1", "ipaddr.js": "^2.2.0", + "radashi": "^12.5.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.3.0", 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 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..4c69888140 100644 --- a/web/src/components/core/EmailInput.tsx +++ b/web/src/components/core/EmailInput.tsx @@ -22,7 +22,7 @@ import React, { useEffect, useState } from "react"; import { InputGroup, TextInput, TextInputProps } from "@patternfly/react-core"; -import { isEmpty, 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..d9b510da2d 100644 --- a/web/src/components/core/ListSearch.tsx +++ b/web/src/components/core/ListSearch.tsx @@ -22,8 +22,8 @@ import React, { useState } from "react"; import { SearchInput } from "@patternfly/react-core"; +import { debounce, noop } from "radashi"; import { _ } from "~/i18n"; -import { noop, useDebounce } from "~/utils"; type ListSearchProps = { /** Text to display as placeholder for the search input. */ @@ -44,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 @@ -61,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/components/core/Page.tsx b/web/src/components/core/Page.tsx index 11504282f1..20674f8ecb 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -45,7 +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 { isEmpty, isObject } from "~/utils"; +import { isEmpty, isObject } from "radashi"; import { SIDE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; 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/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/components/layout/Layout.tsx b/web/src/components/layout/Layout.tsx index 968aac813d..322e3ab8d6 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,60 @@ const focusDrawer = (drawer: HTMLElement | null) => { firstTabbableItem?.focus(); }; +/** + * Custom width breakpoints for the Agama layout. + * + * 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 + */ +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/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 491ccacb6b..09ee8f0548 100644 --- a/web/src/components/network/WifiNetworksList.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -40,7 +40,7 @@ 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 { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; 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/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/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/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/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/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/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 c64add26ef..40e6a66d63 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; 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/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/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/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/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/use-cancellable-promise.ts b/web/src/hooks/use-cancellable-promise.ts new file mode 100644 index 0000000000..05c1ffa21d --- /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. 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/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/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 d6418c0979..0000000000 --- a/web/src/hooks/useNodeSiblings.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { noop } from "~/utils"; - -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; diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index b0f36e8ee8..29704d201e 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -30,14 +30,13 @@ 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, noop } from "radashi"; /** * Internal mock for manipulating routes, using ["/"] by default diff --git a/web/src/types/network.ts b/web/src/types/network.ts index 5ad0610373..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 "~/utils"; +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; } } diff --git a/web/src/utils.test.ts b/web/src/utils.test.ts index 643504146a..ef535bf242 100644 --- a/web/src/utils.test.ts +++ b/web/src/utils.test.ts @@ -20,35 +20,7 @@ * find current contact information at www.suse.com. */ -import { - classNames, - partition, - compact, - uniq, - noop, - toValidationError, - localConnection, - isObject, - slugify, - isEmpty, -} from "./utils"; - -describe("noop", () => { - it("returns undefined", () => { - const result = noop(); - expect(result).toBeUndefined(); - }); -}); - -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]); - }); -}); +import { compact, localConnection, hex, mask, timezoneTime } from "./utils"; describe("compact", () => { it("removes null and undefined values", () => { @@ -64,159 +36,156 @@ 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("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" }); -}); -describe("classNames", () => { - it("join given arguments, ignoring falsy values", () => { - const includeClass = true; + 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); + }); - expect( - classNames("bg-yellow", !includeClass && "h-24", undefined, null, includeClass && "w-24"), - ).toEqual("bg-yellow w-24"); + it("returns 0 for values resulting in empty string", () => { + expect(hex("..")).toBe(0); + expect(hex("")).toBe(0); }); -}); -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" }); + it("allows leading or trailing dots", () => { + expect(hex(".123")).toBe(291); + expect(hex("123.")).toBe(291); + expect(hex(".1.2.3.")).toBe(291); }); }); -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); - }); +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"); }); - describe("when the page URL is " + localURL2, () => { - it("returns true", () => { - expect(localConnection(localURL2)).toEqual(true); - }); + 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"); }); - describe("when the page URL is " + remoteURL, () => { - it("returns false", () => { - expect(localConnection(remoteURL)).toEqual(false); - }); + it("uses custom mask character", () => { + expect(mask("secret", 3, "#")).toBe("###ret"); + expect(mask("secret", 1, "X")).toBe("XXXXXt"); + expect(mask("secret", 2, "!")).toBe("!!!!et"); }); -}); -describe("isObject", () => { - it("returns true when called with an object", () => { - expect(isObject({ dummy: "object" })).toBe(true); + it("handles empty and short input values", () => { + expect(mask("")).toBe(""); + expect(mask("a")).toBe("a"); + expect(mask("ab", 5)).toBe("ab"); }); - it("returns false when called with null", () => { - expect(isObject(null)).toBe(false); + it("handles negative or NaN visible values safely", () => { + expect(mask("secret", -2)).toBe("******"); + expect(mask("secret", NaN)).toBe("******"); }); - it("returns false when called with undefined", () => { - expect(isObject(undefined)).toBe(false); + it("masks with empty character (no masking)", () => { + expect(mask("secret", 2, "")).toBe("et"); }); +}); - it("returns false when called with a string", () => { - expect(isObject("dummy string")).toBe(false); - }); +describe("timezoneTime", () => { + const fixedDate = new Date("2023-01-01T12:34:56Z"); - it("returns false when called with an array", () => { - expect(isObject(["dummy", "array"])).toBe(false); + it("returns time in 24h format for a valid timezone", () => { + const time = timezoneTime("UTC", fixedDate); + expect(time).toBe("12:34"); }); - it("returns false when called with a date", () => { - expect(isObject(new Date())).toBe(false); - }); + it("uses current date if no date provided", () => { + // Fake "now" + const now = new Date("2023-06-01T15:45:00Z"); + jest.useFakeTimers(); + jest.setSystemTime(now); - it("returns false when called with regexp", () => { - expect(isObject(/aRegExp/i)).toBe(false); - }); + const time = timezoneTime("UTC"); + expect(time).toBe("15:45"); - it("returns false when called with a set", () => { - expect(isObject(new Set(["dummy", "set"]))).toBe(false); + jest.useRealTimers(); }); - it("returns false when called with a map", () => { - const map = new Map([["dummy", "map"]]); - expect(isObject(map)).toBe(false); + it("returns undefined for invalid timezone", () => { + expect(timezoneTime("Invalid/Timezone", fixedDate)).toBeUndefined(); }); -}); -describe("isEmpty", () => { - it("returns true when called with null", () => { - expect(isEmpty(null)).toBe(true); - }); + 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"); + }); - it("returns true when called with undefined", () => { - expect(isEmpty(undefined)).toBe(true); - }); + expect(() => timezoneTime("UTC", fixedDate)).toThrow("Unexpected"); - it("returns false when called with a function", () => { - expect(isEmpty(() => {})).toBe(false); + Intl.DateTimeFormat = original; }); +}); - it("returns false when called with `true` (boolean)", () => { - expect(isEmpty(true)).toBe(false); - }); +describe("localConnection", () => { + 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"); - it("returns false when called with `false` (boolean)", () => { - expect(isEmpty(false)).toBe(false); + beforeEach(() => { + process.env = { ...originalEnv }; }); - it("returns false when called with a number", () => { - expect(isEmpty(1)).toBe(false); - }); + 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; - it("returns true when called with an empty string", () => { - expect(isEmpty("")).toBe(true); - }); + expect(localConnection()).toBe(true); - it("returns false when called with a not empty string", () => { - expect(isEmpty("not empty")).toBe(false); + // Restore + // @ts-expect-error: https://github.com/microsoft/TypeScript/issues/48949 + window.location = originalLocation; }); - it("returns true when called with an empty array", () => { - expect(isEmpty([])).toBe(true); + it("returns true for 'localhost' hostname", () => { + expect(localConnection(localhostURL)).toBe(true); }); - it("returns false when called with a not empty array", () => { - expect(isEmpty([""])).toBe(false); + it("returns true for 127.x.x.x IPs", () => { + expect(localConnection(new URL(localIpURL))).toBe(true); }); - it("returns true when called with an empty object", () => { - expect(isEmpty({})).toBe(true); + it("returns false for non-local hostnames", () => { + expect(localConnection(remoteURL)).toBe(false); }); - it("returns false when called with a not empty object", () => { - expect(isEmpty({ not: "empty" })).toBe(false); - }); -}); + describe("but LOCAL_CONNECTION environment variable is '1'", () => { + beforeEach(() => { + process.env.LOCAL_CONNECTION = "1"; + }); -describe("slugify", () => { - it("converts given input into a slug", () => { - expect(slugify("Agama! / Network 1")).toEqual("agama-network-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 946324e66d..eb63dd282a 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -20,109 +20,6 @@ * find current contact information at www.suse.com. */ -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 - * - * @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; -}; - -/** - * 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 === "boolean") { - return false; - } - - 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. - * - * @return empty function - */ -const noop = () => undefined; - -/** - * @return identity function - */ -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. */ @@ -131,211 +28,31 @@ const compact = (collection: Array) => { }; /** - * Generates a new array without duplicates. - */ -const uniq = (collection: Array) => { - return [...new Set(collection)]; -}; - -/** - * 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 - * - * 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; - /** 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. + * Parses a "numeric dot string" as a hexadecimal number. * - * @see {@link https://overreacted.io/a-complete-guide-to-useeffect/#speaking-of-race-conditions|Race conditions} + * Accepts only strings containing digits (`0–9`) and dots (`.`), + * for example: `"0.0.0160"` or `"123"`. Dots are removed before parsing. * - * 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} + * If the cleaned string contains any non-digit characters (such as letters), + * or is not a valid integer string, the function returns `0`. * * @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/} + * ```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 storageKey - * @param fallbackState + * @param value - A string representing a dot-separated numeric value + * @returns The number parsed as hexadecimal (base-16) integer, or `0` if invalid */ -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]; +const hex = (value: string): number => { + const sanitizedValued = value.replaceAll(".", ""); + return /^[0-9]+$/.test(sanitizedValued) ? parseInt(sanitizedValued, 16) : 0; }; -/** - * 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; -}; - -/** - * Convert given string to a hexadecimal number - */ -const hex = (value: string) => { - const sanitizedValue = value.replaceAll(".", ""); - return parseInt(sanitizedValue, 16); -}; - -/** - * 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 * @@ -365,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. @@ -410,62 +127,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); -}; - -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 { - noop, - identity, - isEmpty, - isObject, - isObjectEmpty, - partition, - compact, - uniq, - classNames, - useCancellablePromise, - useLocalStorage, - useDebounce, - hex, - toValidationError, - locationReload, - setLocationSearch, - localConnection, - slugify, - timezoneTime, - mask, - getBreakpoint, - agamaWidthBreakpoints, -}; +/** + * 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; +}; + +export { compact, hex, locationReload, setLocationSearch, localConnection, timezoneTime, mask };