From 767b8d756de05c3154d6fd283354fc4bb4225a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 19 Dec 2024 15:19:08 +0000 Subject: [PATCH 01/31] refactor(web): migrate core/EmailInput to TypeScript --- ...mailInput.test.jsx => EmailInput.test.tsx} | 51 ++++++++++--------- .../core/{EmailInput.jsx => EmailInput.tsx} | 29 +++++------ 2 files changed, 42 insertions(+), 38 deletions(-) rename web/src/components/core/{EmailInput.test.jsx => EmailInput.test.tsx} (74%) rename web/src/components/core/{EmailInput.jsx => EmailInput.tsx} (67%) diff --git a/web/src/components/core/EmailInput.test.jsx b/web/src/components/core/EmailInput.test.tsx similarity index 74% rename from web/src/components/core/EmailInput.test.jsx rename to web/src/components/core/EmailInput.test.tsx index b4572709f3..1a050e9f15 100644 --- a/web/src/components/core/EmailInput.test.jsx +++ b/web/src/components/core/EmailInput.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -23,9 +23,35 @@ import React, { useState } from "react"; import { screen } from "@testing-library/react"; -import EmailInput from "./EmailInput"; +import EmailInput, { EmailInputProps } from "./EmailInput"; import { plainRender } from "~/test-utils"; +/** + * Controlled component for testing the EmailInputProps + * + * Instead of testing if given callbacks are called, below tests are going to + * check the rendered result to be more aligned with the React Testing Library + * principles, https://testing-library.com/docs/guiding-principles/ + * + */ +const EmailInputTest = (props: EmailInputProps) => { + const [email, setEmail] = useState(""); + const [isValid, setIsValid] = useState(true); + + return ( + <> + setEmail(v)} + onValidate={setIsValid} + /> + {email &&

Email value updated!

} + {isValid === false &&

Email is not valid!

} + + ); +}; + describe("EmailInput component", () => { it("renders an email input", () => { plainRender( @@ -36,27 +62,6 @@ describe("EmailInput component", () => { expect(inputField).toHaveAttribute("type", "email"); }); - // Using a controlled component for testing the rendered result instead of testing if - // the given onChange callback is called. The former is more aligned with the - // React Testing Library principles, https://testing-library.com/docs/guiding-principles/ - const EmailInputTest = (props) => { - const [email, setEmail] = useState(""); - const [isValid, setIsValid] = useState(true); - - return ( - <> - setEmail(v)} - onValidate={setIsValid} - /> - {email &&

Email value updated!

} - {isValid === false &&

Email is not valid!

} - - ); - }; - it("triggers onChange callback", async () => { const { user } = plainRender(); const emailInput = screen.getByRole("textbox", { name: "Test email" }); diff --git a/web/src/components/core/EmailInput.jsx b/web/src/components/core/EmailInput.tsx similarity index 67% rename from web/src/components/core/EmailInput.jsx rename to web/src/components/core/EmailInput.tsx index 6bda2447b5..5d3bf09335 100644 --- a/web/src/components/core/EmailInput.jsx +++ b/web/src/components/core/EmailInput.tsx @@ -21,28 +21,25 @@ */ import React, { useEffect, useState } from "react"; -import { InputGroup, TextInput } from "@patternfly/react-core"; -import { noop } from "~/utils"; +import { InputGroup, TextInput, TextInputProps } from "@patternfly/react-core"; +import { isEmpty, noop } from "~/utils"; /** * Email validation. * * Code inspired by https://github.com/manishsaraan/email-validator/blob/master/index.js - * - * @param {string} email - * @returns {boolean} */ -const validateEmail = (email) => { +const validateEmail = (email: string) => { const regexp = /^[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/; - const validateFormat = (email) => { + const validateFormat = (email: string) => { const parts = email.split("@"); return parts.length === 2 && regexp.test(email); }; - const validateSizes = (email) => { + const validateSizes = (email: string) => { const [account, address] = email.split("@"); if (account.length > 64) return false; @@ -58,27 +55,29 @@ const validateEmail = (email) => { return validateFormat(email) && validateSizes(email); }; +export type EmailInputProps = TextInputProps & { onValidate?: (isValid: boolean) => void }; + /** * Renders an email input field which validates its value. * @component * - * @param {(boolean) => void} onValidate - Callback to be called every time the input value is + * @param onValidate - Callback to be called every time the input value is * validated. - * @param {Object} props - Props matching the {@link https://www.patternfly.org/components/forms/text-input PF/TextInput}, + * @param props - Props matching the {@link https://www.patternfly.org/components/forms/text-input PF/TextInput}, * except `type` and `validated` which are managed by the component. */ -export default function EmailInput({ onValidate = noop, ...props }) { +export default function EmailInput({ onValidate = noop, value, ...props }: EmailInputProps) { const [isValid, setIsValid] = useState(true); useEffect(() => { - const isValid = props.value.length === 0 || validateEmail(props.value); + const isValid = typeof value === "string" && (isEmpty(value) || validateEmail(value)); setIsValid(isValid); - onValidate(isValid); - }, [onValidate, props.value, setIsValid]); + typeof onValidate === "function" && onValidate(isValid); + }, [onValidate, value, setIsValid]); return ( - + ); } From 7a412fd0fde69b125be18996a24e06765a9c9029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 23 Dec 2024 22:25:06 +0000 Subject: [PATCH 02/31] refactor(web): migrate src/client to TypeScript --- web/src/client/{index.js => index.ts} | 50 ++++++++++++++--------- web/src/client/{ws.js => ws.ts} | 58 +++++++++++++-------------- 2 files changed, 59 insertions(+), 49 deletions(-) rename web/src/client/{index.js => index.ts} (55%) rename web/src/client/{ws.js => ws.ts} (85%) diff --git a/web/src/client/index.js b/web/src/client/index.ts similarity index 55% rename from web/src/client/index.js rename to web/src/client/index.ts index 5e161ef5f3..97826e5cd5 100644 --- a/web/src/client/index.js +++ b/web/src/client/index.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2021-2023] SUSE LLC + * Copyright (c) [2021-2024] SUSE LLC * * All Rights Reserved. * @@ -20,28 +20,40 @@ * find current contact information at www.suse.com. */ -// @ts-check - import { WSClient } from "./ws"; -/** - * @typedef {object} InstallerClient - * @property {() => boolean} isConnected - determines whether the client is connected - * @property {() => boolean} isRecoverable - determines whether the client is recoverable after disconnected - * @property {(handler: () => void) => (() => void)} onConnect - registers a handler to run - * @property {(handler: () => void) => (() => void)} onDisconnect - registers a handler to run - * when the connection is lost. It returns a function to deregister the - * handler. - * @property {(handler: (any) => void) => (() => void)} onEvent - registers a handler to run on events - */ +type VoidFn = () => void; +type BooleanFn = () => boolean; +type EventHandlerFn = (event) => void; + +export type InstallerClient = { + /** Whether the client is connected. */ + isConnected: BooleanFn; + /** Whether the client is recoverable after disconnecting. */ + isRecoverable: BooleanFn; + /** + * Registers a handler to run when connection is set. It returns a function + * for deregistering the handler. + */ + onConnect: (handler: VoidFn) => VoidFn; + /** + * Registers a handler to run when connection is lost. It returns a function + * for deregistering the handler. + */ + onDisconnect: (handler: VoidFn) => VoidFn; + /** + * Registers a handler to run on events. It returns a function for + * deregistering the handler. + */ + onEvent: (handler: EventHandlerFn) => VoidFn; +}; /** * Creates the Agama client * - * @param {URL} url - URL of the HTTP API. - * @return {InstallerClient} + * @param url - URL of the HTTP API. */ -const createClient = (url) => { +const createClient = (url: URL): InstallerClient => { url.hash = ""; url.pathname = url.pathname.concat("api/ws"); url.protocol = url.protocol === "http:" ? "ws" : "wss"; @@ -53,9 +65,9 @@ const createClient = (url) => { return { isConnected, isRecoverable, - onConnect: (handler) => ws.onOpen(handler), - onDisconnect: (handler) => ws.onClose(handler), - onEvent: (handler) => ws.onEvent(handler), + onConnect: (handler: VoidFn) => ws.onOpen(handler), + onDisconnect: (handler: VoidFn) => ws.onClose(handler), + onEvent: (handler: EventHandlerFn) => ws.onEvent(handler), }; }; diff --git a/web/src/client/ws.js b/web/src/client/ws.ts similarity index 85% rename from web/src/client/ws.js rename to web/src/client/ws.ts index b63c8e2a51..608001f2cd 100644 --- a/web/src/client/ws.js +++ b/web/src/client/ws.ts @@ -20,19 +20,13 @@ * find current contact information at www.suse.com. */ -// @ts-check - -/** - * @callback RemoveFn - * @return {void} - */ +type RemoveFn = () => void; +type BaseHandlerFn = () => void; +type EventHandlerFn = (event) => void; /** * Enum for the WebSocket states. - * - * */ - const SocketStates = Object.freeze({ CONNECTED: 0, CONNECTING: 1, @@ -52,10 +46,25 @@ const ATTEMPT_INTERVAL = 1000; * HTTPClient API. */ class WSClient { + url: string; + + client: WebSocket; + + handlers: { + open: Array; + close: Array; + error: Array; + events: Array; + }; + + reconnectAttempts: number; + + timeout: ReturnType; + /** - * @param {URL} url - Websocket URL. + * @param url - Websocket URL. */ - constructor(url) { + constructor(url: URL) { this.url = url.toString(); this.handlers = { @@ -126,13 +135,10 @@ class WSClient { /** * Registers a handler for events. * - * The handler is executed for all the events. It is up to the callback to - * filter the relevant events. - * - * @param {(object) => void} func - Handler function to register. - * @return {RemoveFn} + * The handler is executed for all events. It is up to the callback to + * filter the relevant ones for it. */ - onEvent(func) { + onEvent(func: EventHandlerFn): RemoveFn { this.handlers.events.push(func); return () => { const position = this.handlers.events.indexOf(func); @@ -144,11 +150,8 @@ class WSClient { * Registers a handler for close socket. * * The handler is executed when the socket is close. - * - * @param {(object) => void} func - Handler function to register. - * @return {RemoveFn} */ - onClose(func) { + onClose(func: BaseHandlerFn): RemoveFn { this.handlers.close.push(func); return () => { @@ -161,10 +164,8 @@ class WSClient { * Registers a handler for open socket. * * The handler is executed when the socket is open. - * @param {(object) => void} func - Handler function to register. - * @return {RemoveFn} */ - onOpen(func) { + onOpen(func: BaseHandlerFn): RemoveFn { this.handlers.open.push(func); return () => { @@ -177,11 +178,8 @@ class WSClient { * Registers a handler for socket errors. * * The handler is executed when an error is reported by the socket. - * - * @param {(object) => void} func - Handler function to register. - * @return {RemoveFn} */ - onError(func) { + onError(func: BaseHandlerFn): RemoveFn { this.handlers.error.push(func); return () => { @@ -195,9 +193,9 @@ class WSClient { * * Dispatchs an event by running all the handlers. * - * @param {object} event - Event object, which is basically a websocket message. + * @param event - Event object, which is basically a websocket message. */ - dispatchEvent(event) { + dispatchEvent(event: MessageEvent) { const eventObject = JSON.parse(event.data); this.handlers.events.forEach((f) => f(eventObject)); } From 77cf091b12efdeb6d627a411fefb6b5ebbd12cad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 26 Dec 2024 15:42:19 +0000 Subject: [PATCH 03/31] refactor(web): migrate contexts to TypeScript --- web/src/context/{app.jsx => app.tsx} | 7 +-- web/src/context/{auth.jsx => auth.tsx} | 6 +-- ...{installer.test.jsx => installer.test.tsx} | 4 +- .../context/{installer.jsx => installer.tsx} | 43 ++++++++----------- web/src/context/{root.jsx => root.tsx} | 9 +--- 5 files changed, 25 insertions(+), 44 deletions(-) rename web/src/context/{app.jsx => app.tsx} (89%) rename web/src/context/{auth.jsx => auth.tsx} (95%) rename web/src/context/{installer.test.jsx => installer.test.tsx} (94%) rename web/src/context/{installer.jsx => installer.tsx} (75%) rename web/src/context/{root.jsx => root.tsx} (84%) diff --git a/web/src/context/app.jsx b/web/src/context/app.tsx similarity index 89% rename from web/src/context/app.jsx rename to web/src/context/app.tsx index 9ba49bf1f0..70686fde79 100644 --- a/web/src/context/app.jsx +++ b/web/src/context/app.tsx @@ -20,8 +20,6 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React from "react"; import { InstallerClientProvider } from "./installer"; import { InstallerL10nProvider } from "./installerL10n"; @@ -31,11 +29,8 @@ const queryClient = new QueryClient(); /** * Combines all application providers. - * - * @param {object} props - * @param {React.ReactNode} [props.children] - content to display within the provider. */ -function AppProviders({ children }) { +function AppProviders({ children }: React.PropsWithChildren) { return ( diff --git a/web/src/context/auth.jsx b/web/src/context/auth.tsx similarity index 95% rename from web/src/context/auth.jsx rename to web/src/context/auth.tsx index 536e8c054b..65154a29a7 100644 --- a/web/src/context/auth.jsx +++ b/web/src/context/auth.tsx @@ -20,8 +20,6 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React, { useCallback, useEffect, useState } from "react"; const AuthContext = React.createContext(null); @@ -48,11 +46,11 @@ const AuthErrors = Object.freeze({ * @param {object} props * @param {React.ReactNode} [props.children] - content to display within the provider */ -function AuthProvider({ children }) { +function AuthProvider({ children }: React.PropsWithChildren) { const [isLoggedIn, setIsLoggedIn] = useState(undefined); const [error, setError] = useState(null); - const login = useCallback(async (password) => { + const login = useCallback(async (password: string) => { const response = await fetch("/api/auth", { method: "POST", body: JSON.stringify({ password }), diff --git a/web/src/context/installer.test.jsx b/web/src/context/installer.test.tsx similarity index 94% rename from web/src/context/installer.test.jsx rename to web/src/context/installer.test.tsx index cd55969ffd..5c4125da1e 100644 --- a/web/src/context/installer.test.jsx +++ b/web/src/context/installer.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -41,7 +41,7 @@ const ClientStatus = () => { describe("installer context", () => { beforeEach(() => { - createDefaultClient.mockImplementation(() => { + (createDefaultClient as jest.Mock).mockImplementation(() => { return { onConnect: jest.fn(), onDisconnect: jest.fn(), diff --git a/web/src/context/installer.jsx b/web/src/context/installer.tsx similarity index 75% rename from web/src/context/installer.jsx rename to web/src/context/installer.tsx index b2a96aefc2..6b48662e7d 100644 --- a/web/src/context/installer.jsx +++ b/web/src/context/installer.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2021-2023] SUSE LLC + * Copyright (c) [2021-2024] SUSE LLC * * All Rights Reserved. * @@ -20,10 +20,21 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React, { useState, useEffect } from "react"; -import { createDefaultClient } from "~/client"; +import { createDefaultClient, InstallerClient } from "~/client"; + +type ClientStatus = { + /** Whether the client is connected or not. */ + connected: boolean; + /** Whether the client present an error and cannot reconnect. */ + error: boolean; +}; + +type InstallerClientProviderProps = React.PropsWithChildren<{ + /** Client to connect to Agama service; if it is undefined, it instantiates a + * new one using the address registered in /run/agama/bus.address. */ + client?: InstallerClient; +}>; const InstallerClientContext = React.createContext(null); // TODO: we use a separate context to avoid changing all the codes to @@ -35,10 +46,8 @@ const InstallerClientStatusContext = React.createContext({ /** * Returns the D-Bus installer client - * - * @return {import("~/client").InstallerClient} */ -function useInstallerClient() { +function useInstallerClient(): InstallerClient { const context = React.useContext(InstallerClientContext); if (context === undefined) { throw new Error("useInstallerClient must be used within a InstallerClientProvider"); @@ -49,15 +58,8 @@ function useInstallerClient() { /** * Returns the client status. - * - * @typedef {object} ClientStatus - * @property {boolean} connected - whether the client is connected - * @property {boolean} error - whether the client present an error and cannot - * reconnect - * - * @return {ClientStatus} installer client status */ -function useInstallerClientStatus() { +function useInstallerClientStatus(): ClientStatus { const context = React.useContext(InstallerClientStatusContext); if (!context) { throw new Error("useInstallerClientStatus must be used within a InstallerClientProvider"); @@ -66,16 +68,7 @@ function useInstallerClientStatus() { return context; } -/** - * @param {object} props - * @param {import("~/client").InstallerClient|undefined} [props.client] client to connect to - * Agama service; if it is undefined, it instantiates a new one using the address - * registered in /run/agama/bus.address. - * @param {number} [props.interval=2000] - Interval in milliseconds between connection attempt - * (2000 by default). - * @param {React.ReactNode} [props.children] - content to display within the provider - */ -function InstallerClientProvider({ children, client = null }) { +function InstallerClientProvider({ children, client = null }: InstallerClientProviderProps) { const [value, setValue] = useState(client); const [connected, setConnected] = useState(false); const [error, setError] = useState(false); diff --git a/web/src/context/root.jsx b/web/src/context/root.tsx similarity index 84% rename from web/src/context/root.jsx rename to web/src/context/root.tsx index e47d708b9f..cb1dfbd5cc 100644 --- a/web/src/context/root.jsx +++ b/web/src/context/root.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -20,19 +20,14 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React, { Suspense } from "react"; import { AuthProvider } from "./auth"; import { Loading } from "~/components/layout"; /** * Combines all application providers. - * - * @param {object} props - * @param {React.ReactNode} [props.children] - content to display within the provider. */ -function RootProviders({ children }) { +function RootProviders({ children }: React.PropsWithChildren) { return ( }> {children} From ea242a3a3ce6ee5132cd433d647de18d24e36f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 26 Dec 2024 16:32:34 +0000 Subject: [PATCH 04/31] refactor(web): migrate src/Protected to TypeScript --- web/src/{Protected.jsx => Protected.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename web/src/{Protected.jsx => Protected.tsx} (100%) diff --git a/web/src/Protected.jsx b/web/src/Protected.tsx similarity index 100% rename from web/src/Protected.jsx rename to web/src/Protected.tsx From 760a780fcb45ce1292ae8db62845a1d1b40108b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 26 Dec 2024 16:59:29 +0000 Subject: [PATCH 05/31] refactor(web): migrate i18n to TypeScript --- web/src/{i18n.test.js => i18n.test.ts} | 2 +- web/src/{i18n.js => i18n.ts} | 44 ++++++++++++-------------- 2 files changed, 22 insertions(+), 24 deletions(-) rename web/src/{i18n.test.js => i18n.test.ts} (98%) rename web/src/{i18n.js => i18n.ts} (74%) diff --git a/web/src/i18n.test.js b/web/src/i18n.test.ts similarity index 98% rename from web/src/i18n.test.js rename to web/src/i18n.test.ts index 483e8ae0fc..6c620a30b1 100644 --- a/web/src/i18n.test.js +++ b/web/src/i18n.test.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * diff --git a/web/src/i18n.js b/web/src/i18n.ts similarity index 74% rename from web/src/i18n.js rename to web/src/i18n.ts index 93a0bed507..0ab39d6df1 100644 --- a/web/src/i18n.js +++ b/web/src/i18n.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -30,20 +30,18 @@ import agama from "~/agama"; /** * Tests whether a special testing language is used. - * - * @returns {boolean} true if the testing language is set */ -const isTestingLanguage = () => agama.language === "xx"; +const isTestingLanguage = (): boolean => agama.language === "xx"; /** * "Translate" the string to special "xx" testing language. * It just replaces all alpha characters with "x". * It keeps the percent placeholders like "%s" or "%d" unmodified. * - * @param {string} str input string - * @returns {string} "translated" string + * @param str input string + * @returns "translated" string */ -const xTranslate = (str) => { +const xTranslate = (str: string): string => { let result = ""; let wasPercent = false; @@ -70,23 +68,23 @@ const xTranslate = (str) => { * Returns a translated text in the current locale or the original text if the * translation is not found. * - * @param {string} str the input string to translate - * @return {string} translated or original text + * @param str the input string to translate + * @return translated or original text */ -const _ = (str) => (isTestingLanguage() ? xTranslate(str) : agama.gettext(str)); +const _ = (str: string): string => (isTestingLanguage() ? xTranslate(str) : agama.gettext(str)); /** * Similar to the _() function. This variant returns singular or plural form * depending on an additional "num" argument. * * @see {@link _} for further information - * @param {string} str1 the input string in the singular form - * @param {string} strN the input string in the plural form - * @param {number} n the actual number which decides whether to use the + * @param str1 the input string in the singular form + * @param strN the input string in the plural form + * @param n the actual number which decides whether to use the * singular or plural form - * @return {string} translated or original text + * @return translated or original text */ -const n_ = (str1, strN, n) => { +const n_ = (str1: string, strN: string, n: number): string => { return isTestingLanguage() ? xTranslate(n === 1 ? str1 : strN) : agama.ngettext(str1, strN, n); }; @@ -120,22 +118,22 @@ const n_ = (str1, strN, n) => { * // here the string will be translated using the current locale * return
Result: {_(result)}
; * - * @param {string} str the input string - * @return {string} the input string + * @param str the input string + * @return the input string */ -const N_ = (str) => str; +const N_ = (str: string): string => str; /** * Similar to the N_() function, but for the singular and plural form. * * @see {@link N_} for further information - * @param {string} str1 the input string in the singular form - * @param {string} strN the input string in the plural form - * @param {number} n the actual number which decides whether to use the + * @param str1 the input string in the singular form + * @param strN the input string in the plural form + * @param n the actual number which decides whether to use the * singular or plural form - * @return {string} the original text, either "string1" or "stringN" depending + * @return the original text, either "string1" or "stringN" depending * on the value "num" */ -const Nn_ = (str1, strN, n) => (n === 1 ? str1 : strN); +const Nn_ = (str1: string, strN: string, n: number): string => (n === 1 ? str1 : strN); export { _, n_, N_, Nn_ }; From 1e777ec8fe72c750a54f354f5bd9a44b58887c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 26 Dec 2024 17:19:08 +0000 Subject: [PATCH 06/31] refactor(web): migrate src/agama to TypeScript --- web/src/agama.js | 99 -------------------------------------------- web/src/agama.ts | 99 ++++++++++++++++++++++++++++++++++++++++++++ web/src/i18n.test.ts | 16 +++---- 3 files changed, 108 insertions(+), 106 deletions(-) delete mode 100644 web/src/agama.js create mode 100644 web/src/agama.ts diff --git a/web/src/agama.js b/web/src/agama.js deleted file mode 100644 index e89e0ded3f..0000000000 --- a/web/src/agama.js +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright (c) [2024] 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. - */ - -/** - * This module provides a global "agama" object which can be use from other - * scripts like "po.js". - */ - -const agama = { - // the current language - language: "en", -}; - -// mapping with the current translations -let translations = {}; -// function used for computing the plural form index -let plural_fn; - -// set the current translations, called from po..js -agama.locale = function locale(po) { - if (po) { - Object.assign(translations, po); - - const header = po[""]; - if (header) { - if (header["plural-forms"]) plural_fn = header["plural-forms"]; - if (header.language) agama.language = header.language; - } - } else if (po === null) { - translations = {}; - plural_fn = undefined; - agama.language = "en"; - } -}; - -/** - * get a translation for a singular text - * @param {string} str input text - * @return translated text or the original text if the translation is not found - */ -agama.gettext = function gettext(str) { - if (translations) { - const translated = translations[str]; - if (translated?.[0]) return translated[0]; - } - - // fallback, return the original text - return str; -}; - -/** - * get a translation for a plural text - * @param {string} str1 input singular text - * @param {string} strN input plural text - * @param {number} n the actual number which decides whether to use the - * singular or plural form (of which plural form if there are several of them) - * @return translated text or the original text if the translation is not found - */ -agama.ngettext = function ngettext(str1, strN, n) { - if (translations && plural_fn) { - // plural form translations are indexed by the singular variant - const translation = translations[str1]; - - if (translation) { - const plural_index = plural_fn(n); - - // the plural function either returns direct index (integer) in the plural - // translations or a boolean indicating simple plural form which - // needs to be converted to index 0 (singular) or 1 (plural) - const index = plural_index === true ? 1 : plural_index || 0; - - if (translation[index]) return translation[index]; - } - } - - // fallback, return the original text - return n === 1 ? str1 : strN; -}; - -export default agama; diff --git a/web/src/agama.ts b/web/src/agama.ts new file mode 100644 index 0000000000..9ecc8a57cf --- /dev/null +++ b/web/src/agama.ts @@ -0,0 +1,99 @@ +/* + * Copyright (c) [2024] 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. + */ + +/** + * This module provides a global "agama" object which can be use from other + * scripts like "po.js". + */ + +// mapping with the current translations +let translations = {}; +// function used for computing the plural form index +let plural_fn: (n: number) => boolean; + +const agama = { + // the current language + language: "en", + + // set the current translations, called from po..js + locale: (po) => { + if (po) { + Object.assign(translations, po); + + const header = po[""]; + if (header) { + if (header["plural-forms"]) plural_fn = header["plural-forms"]; + if (header.language) agama.language = header.language; + } + } else if (po === null) { + translations = {}; + plural_fn = undefined; + agama.language = "en"; + } + }, + + /** + * Get a translation for a singular text + * @param str input text + * @return translated text or the original text if the translation is not found + */ + gettext: (str: string): string => { + if (translations) { + const translated = translations[str]; + if (translated?.[0]) return translated[0]; + } + + // fallback, return the original text + return str; + }, + + /** + * get a translation for a plural text + * @param str1 input singular text + * @param strN input plural text + * @param n the actual number which decides whether to use the + * singular or plural form (of which plural form if there are several of them) + * @return translated text or the original text if the translation is not found + */ + ngettext: (str1: string, strN: string, n: number) => { + if (translations && plural_fn) { + // plural form translations are indexed by the singular variant + const translation = translations[str1]; + + if (translation) { + const plural_index = plural_fn(n); + + // the plural function either returns direct index (integer) in the plural + // translations or a boolean indicating simple plural form which + // needs to be converted to index 0 (singular) or 1 (plural) + const index = plural_index === true ? 1 : plural_index || 0; + + if (translation[index]) return translation[index]; + } + } + + // fallback, return the original text + return n === 1 ? str1 : strN; + }, +}; + +export default agama; diff --git a/web/src/i18n.test.ts b/web/src/i18n.test.ts index 6c620a30b1..c3caef75e2 100644 --- a/web/src/i18n.test.ts +++ b/web/src/i18n.test.ts @@ -20,15 +20,17 @@ * find current contact information at www.suse.com. */ +/* eslint-disable agama-i18n/string-literals */ + import { _, n_, N_, Nn_ } from "~/i18n"; import agama from "~/agama"; // mock the cockpit gettext functions -jest.mock("~/agama"); -const gettextFn = jest.fn(); -agama.gettext.mockImplementation(gettextFn); -const ngettextFn = jest.fn(); -agama.ngettext.mockImplementation(ngettextFn); +jest.mock("~/agama", () => ({ + ...jest.requireActual("~/agama"), + gettext: jest.fn(), + ngettext: jest.fn(), +})); // some testing texts const text = "text to translate"; @@ -40,7 +42,7 @@ describe("i18n", () => { it("calls the agama.gettext() implementation", () => { _(text); - expect(gettextFn).toHaveBeenCalledWith(text); + expect(agama.gettext).toHaveBeenCalledWith(text); }); }); @@ -48,7 +50,7 @@ describe("i18n", () => { it("calls the agama.ngettext() implementation", () => { n_(singularText, pluralText, 1); - expect(ngettextFn).toHaveBeenCalledWith(singularText, pluralText, 1); + expect(agama.ngettext).toHaveBeenCalledWith(singularText, pluralText, 1); }); }); From fd989ffa23e50cdc00308e3722f7ede87f5df085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 27 Dec 2024 09:00:29 +0000 Subject: [PATCH 07/31] refactor(web): migrate src/utils to TypeScript --- web/src/components/l10n/TimezoneSelection.tsx | 2 +- web/src/{utils.test.js => utils.test.ts} | 25 +--- web/src/{utils.js => utils.ts} | 137 ++++++++---------- 3 files changed, 62 insertions(+), 102 deletions(-) rename web/src/{utils.test.js => utils.test.ts} (88%) rename web/src/{utils.js => utils.ts} (75%) diff --git a/web/src/components/l10n/TimezoneSelection.tsx b/web/src/components/l10n/TimezoneSelection.tsx index 0ea385a2ba..614ba8dc5a 100644 --- a/web/src/components/l10n/TimezoneSelection.tsx +++ b/web/src/components/l10n/TimezoneSelection.tsx @@ -98,7 +98,7 @@ export default function TimezoneSelection() { } description={ - {timezoneTime(id, { date }) || ""} + {timezoneTime(id, date) || ""}
{details}
diff --git a/web/src/utils.test.js b/web/src/utils.test.ts similarity index 88% rename from web/src/utils.test.js rename to web/src/utils.test.ts index 80003d8356..99ced7f4e0 100644 --- a/web/src/utils.test.js +++ b/web/src/utils.test.ts @@ -28,7 +28,6 @@ import { noop, toValidationError, localConnection, - remoteConnection, isObject, slugify, } from "./utils"; @@ -43,7 +42,7 @@ 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); + const [odd, even] = partition(numbers, (number) => number % 2 !== 0); expect(odd).toEqual([1, 3, 5]); expect(even).toEqual([2, 4, 6]); @@ -126,26 +125,6 @@ describe("localConnection", () => { }); }); -describe("remoteConnection", () => { - describe("when the page URL is " + localURL, () => { - it("returns true", () => { - expect(remoteConnection(localURL)).toEqual(false); - }); - }); - - describe("when the page URL is " + localURL2, () => { - it("returns true", () => { - expect(remoteConnection(localURL2)).toEqual(false); - }); - }); - - describe("when the page URL is " + remoteURL, () => { - it("returns false", () => { - expect(remoteConnection(remoteURL)).toEqual(true); - }); - }); -}); - describe("isObject", () => { it("returns true when called with an object", () => { expect(isObject({ dummy: "object" })).toBe(true); @@ -156,7 +135,7 @@ describe("isObject", () => { }); it("returns false when called with undefined", () => { - expect(isObject()).toBe(false); + expect(isObject(undefined)).toBe(false); }); it("returns false when called with a string", () => { diff --git a/web/src/utils.js b/web/src/utils.ts similarity index 75% rename from web/src/utils.js rename to web/src/utils.ts index 50feed2b79..457ef5b99a 100644 --- a/web/src/utils.js +++ b/web/src/utils.ts @@ -28,8 +28,8 @@ import { useEffect, useRef, useCallback, useState } from "react"; * * Borrowed from https://dev.to/alesm0101/how-to-check-if-a-value-is-an-object-in-javascript-3pin * - * @param {any} value - the value to be checked - * @return {boolean} true when given value is an object; false otherwise + * @param value - the value to be checked + * @return true when given value is an object; false otherwise */ const isObject = (value) => typeof value === "object" && @@ -43,18 +43,18 @@ const isObject = (value) => /** * Whether given object is empty or not * - * @param {object} value - the value to be checked - * @return {boolean} true when given value is an empty object; false otherwise + * @param value - the value to be checked + * @return true when given value is an empty object; false otherwise */ -const isObjectEmpty = (value) => { +const isObjectEmpty = (value: object) => { return Object.keys(value).length === 0; }; /** * Whether given value is empty or not * - * @param {object} value - the value to be checked - * @return {boolean} false if value is a function, a not empty object, or a 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) => { @@ -84,12 +84,12 @@ const isEmpty = (value) => { /** * Returns an empty function useful to be used as a default callback. * - * @return {function} empty function + * @return empty function */ const noop = () => undefined; /** - * @return {function} identity function + * @return identity function */ const identity = (i) => i; @@ -97,11 +97,14 @@ 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 {Array} collection - the collection to be filtered - * @param {function} filter - the function to be used as filter - * @return {Array[]} a pair of arrays, [passing, failing] + * @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, filter) => { +const partition = ( + collection: Array, + filter: (element: T) => boolean, +): [Array, Array] => { const pass = []; const fail = []; @@ -114,23 +117,17 @@ const partition = (collection, filter) => { /** * Generates a new array without null and undefined values. - * - * @param {Array} collection - * @returns {Array} */ -function compact(collection) { +const compact = (collection: Array) => { return collection.filter((e) => e !== null && e !== undefined); -} +}; /** * Generates a new array without duplicates. - * - * @param {Array} collection - * @returns {Array} */ -function uniq(collection) { +const uniq = (collection: Array) => { return [...new Set(collection)]; -} +}; /** * Simple utility function to help building className conditionally @@ -141,12 +138,12 @@ function uniq(collection) { * * @todo Use https://github.com/JedWatson/classnames instead? * - * @param {...*} classes - CSS classes to join - * @returns {String} - CSS classes joined together after ignoring falsy values + * @param classes - CSS classes to join + * @returns CSS classes joined together after ignoring falsy values */ -function classNames(...classes) { +const classNames = (...classes) => { return classes.filter((item) => !!item).join(" "); -} +}; /** * Convert any string into a slug @@ -157,10 +154,10 @@ function classNames(...classes) { * slugify("Agama! / Network 1"); * // returns "agama-network-1" * - * @param {string} input - the string to slugify - * @returns {string} - the slug + * @param input - the string to slugify + * @returns the slug */ -function slugify(input) { +const slugify = (input: string) => { if (!input) return ""; return ( @@ -177,26 +174,24 @@ function slugify(input) { // replace multiple spaces or hyphens with a single hyphen .replace(/[\s-]+/g, "-") ); -} +}; -/** - * @typedef {Object} cancellableWrapper - * @property {Promise} promise - Cancellable promise - * @property {function} cancel - Function for canceling the promise - */ +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 - * - * @param {Promise} promise - * @returns {cancellableWrapper} */ -function makeCancellable(promise) { +const makeCancellable = (promise: Promise): CancellableWrapper => { let isCanceled = false; - const cancellablePromise = new Promise((resolve, reject) => { + const cancellablePromise: Promise = new Promise((resolve, reject) => { promise .then((value) => !isCanceled && resolve(value)) .catch((error) => !isCanceled && reject(error)); @@ -208,7 +203,7 @@ function makeCancellable(promise) { isCanceled = true; }, }; -} +}; /** * Allows using promises in a safer way. @@ -217,7 +212,7 @@ function makeCancellable(promise) { * 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} + * @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 @@ -238,8 +233,8 @@ function makeCancellable(promise) { * cancellablePromise(promise).then(setState); * }, [setState, cancellablePromise]); */ -function useCancellablePromise() { - const promises = useRef(); +const useCancellablePromise = () => { + const promises = useRef>>(); useEffect(() => { promises.current = []; @@ -251,22 +246,22 @@ function useCancellablePromise() { }, []); const cancellablePromise = useCallback((promise) => { - const cancellableWrapper = makeCancellable(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/} * - * @param {String} storageKey - * @param {*} fallbackState + * @param storageKey + * @param fallbackState */ -const useLocalStorage = (storageKey, fallbackState) => { +const useLocalStorage = (storageKey: string, fallbackState) => { const [value, setValue] = useState(JSON.parse(localStorage.getItem(storageKey)) ?? fallbackState); useEffect(() => { @@ -281,9 +276,8 @@ const useLocalStorage = (storageKey, fallbackState) => { * * Source {@link https://designtechworld.medium.com/create-a-custom-debounce-hook-in-react-114f3f245260} * - * @param {Function} callback - Function to be called after some delay. - * @param {number} delay - Delay in milliseconds. - * @returns {Function} + * @param callback - Function to be called after some delay. + * @param delay - Delay in milliseconds. * * @example * @@ -291,7 +285,7 @@ const useLocalStorage = (storageKey, fallbackState) => { * log("test ", 1) // The message will be logged after at least 1 second. * log("test ", 2) // Subsequent calls cancels pending calls. */ -const useDebounce = (callback, delay) => { +const useDebounce = (callback: Function, delay: number) => { const timeoutRef = useRef(null); useEffect(() => { @@ -317,10 +311,9 @@ const useDebounce = (callback, delay) => { }; /** - * @param {string} - * @returns {number} + * Convert given string to a hexadecimal number */ -const hex = (value) => { +const hex = (value: string) => { const sanitizedValue = value.replaceAll(".", ""); return parseInt(sanitizedValue, 16); }; @@ -357,14 +350,14 @@ const locationReload = () => { * - https://github.com/jsdom/jsdom/blob/master/Changelog.md#2100 * - https://github.com/jsdom/jsdom/issues/3492 * - * @param {string} query + * @param query */ -const setLocationSearch = (query) => { +const setLocationSearch = (query: string) => { window.location.search = query; }; /** - * Is the Agama server running locally? + * WetherAgama 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. @@ -373,9 +366,9 @@ const setLocationSearch = (query) => { * environment variable to `1`. This can be useful for debugging or for * development. * - * @returns {boolean} `true` if the connection is local, `false` otherwise + * @returns `true` if the connection is local, `false` otherwise */ -const localConnection = (location = window.location) => { +const localConnection = (location: Location | URL = window.location) => { // forced local behavior if (process.env.LOCAL_CONNECTION === "1") return true; @@ -385,26 +378,15 @@ const localConnection = (location = window.location) => { return hostname === "localhost" || hostname.startsWith("127."); }; -/** - * Is the Agama server running remotely? - * - * @see localConnection - * - * @returns {boolean} `true` if the connection is remote, `false` otherwise - */ -const remoteConnection = (...args) => !localConnection(...args); - /** * Time for the given timezone. * - * @param {string} timezone - E.g., "Atlantic/Canary". - * @param {object} [options] - * @param {Date} options.date - Date to take the time from. + * @param timezone - E.g., "Atlantic/Canary". + * @param date - Date to take the time from. * - * @returns {string|undefined} - Time in 24 hours format (e.g., "23:56"). Undefined for an unknown - * timezone. + * @returns Time in 24 hours format (e.g., "23:56"). Undefined for an unknown timezone. */ -const timezoneTime = (timezone, { date = new Date() }) => { +const timezoneTime = (timezone: string, date: Date = new Date()): string | undefined => { try { const formatter = new Intl.DateTimeFormat("en-US", { timeZone: timezone, @@ -438,7 +420,6 @@ export { locationReload, setLocationSearch, localConnection, - remoteConnection, slugify, timezoneTime, }; From 5c38fc895696c00b898c459682784bdf6ff75269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 27 Dec 2024 11:28:37 +0000 Subject: [PATCH 08/31] refactor(web): migrate test-utils to TypeScript --- .../{test-utils.test.js => test-utils.test.tsx} | 5 ----- web/src/{test-utils.js => test-utils.tsx} | 14 +++++++------- 2 files changed, 7 insertions(+), 12 deletions(-) rename web/src/{test-utils.test.js => test-utils.test.tsx} (89%) rename web/src/{test-utils.js => test-utils.tsx} (92%) diff --git a/web/src/test-utils.test.js b/web/src/test-utils.test.tsx similarity index 89% rename from web/src/test-utils.test.js rename to web/src/test-utils.test.tsx index 5c89a5181d..f8baaa092e 100644 --- a/web/src/test-utils.test.js +++ b/web/src/test-utils.test.tsx @@ -40,11 +40,6 @@ describe("resetLocalStorage", () => { expect(window.localStorage.setItem).not.toHaveBeenCalled(); }); - it("does not set an initial state if given value is not an object", () => { - resetLocalStorage(["wrong", "initial state"]); - expect(window.localStorage.setItem).not.toHaveBeenCalled(); - }); - it("sets an initial state if given value is an object", () => { resetLocalStorage({ storage: "something", diff --git a/web/src/test-utils.js b/web/src/test-utils.tsx similarity index 92% rename from web/src/test-utils.js rename to web/src/test-utils.tsx index 7580c0be32..01a69857d3 100644 --- a/web/src/test-utils.js +++ b/web/src/test-utils.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -112,7 +112,7 @@ const Providers = ({ children, withL10n }) => { * * @see #plainRender for rendering without installer providers */ -const installerRender = (ui, options = {}) => { +const installerRender = (ui: React.ReactNode, options: { withL10n?: boolean } = {}) => { const queryClient = new QueryClient({}); const Wrapper = ({ children }) => ( @@ -159,11 +159,11 @@ const plainRender = (ui, options = {}) => { * It can be useful to mock functions that might receive a callback that you can * execute on-demand during the test. * - * @return {[() => () => void, Array<(any) => void>]} a tuple with the mocked function and the list of callbacks. + * @return a tuple with the mocked function and the list of callbacks. */ -const createCallbackMock = () => { +const createCallbackMock = (): [(callback: Function) => () => void, Array<(arg0: any) => void>] => { const callbacks = []; - const on = (callback) => { + const on = (callback: Function) => { callbacks.push(callback); return () => { const position = callbacks.indexOf(callback); @@ -176,10 +176,10 @@ const createCallbackMock = () => { /** * Helper for clearing window.localStorage and setting an initial state if needed. * - * @param {Object.} [initialState] - a collection of keys/values as + * @param [initialState] - a collection of keys/values as * expected by {@link https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem Web Storage API setItem method} */ -const resetLocalStorage = (initialState) => { +const resetLocalStorage = (initialState?: { [key: string]: string }) => { window.localStorage.clear(); if (!isObject(initialState)) return; From 7ecdd9ec96af70aa29009bac4aed16b6aa5f8cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 27 Dec 2024 12:23:04 +0000 Subject: [PATCH 09/31] refactor utils --- web/src/components/core/Popup.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/web/src/components/core/Popup.tsx b/web/src/components/core/Popup.tsx index 9135bfcb6c..ec2ba7f6a8 100644 --- a/web/src/components/core/Popup.tsx +++ b/web/src/components/core/Popup.tsx @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import React from "react"; +import React, { isValidElement } from "react"; import { Button, ButtonProps, Modal, ModalProps } from "@patternfly/react-core"; import { Loading } from "~/components/layout"; import { _ } from "~/i18n"; @@ -202,9 +202,8 @@ const Popup = ({ children, ...props }: PopupProps) => { - const [actions, content] = partition( - React.Children.toArray(children), - (child) => child.type === Actions, + const [actions, content] = partition(React.Children.toArray(children), (child) => + isValidElement(child) ? child.type === Actions : false, ); return ( From 4f8dcdb4ad5f49cf822c56ebdcac678d8580d82a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 27 Dec 2024 14:16:17 +0000 Subject: [PATCH 10/31] fix(web): adjust some types in ZFCP components --- web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx | 4 ++-- web/src/components/storage/zfcp/ZFCPDiskForm.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx b/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx index 4026d5aeea..7ffa368ffb 100644 --- a/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx +++ b/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx @@ -41,9 +41,9 @@ export default function ZFCPDiskActivationPage() { const onSubmit = async (formData: LUNInfo & { id: string }) => { setIsAcceptDisabled(true); - const result = await cancellablePromise( + const result = (await cancellablePromise( activateZFCPDisk(formData.id, formData.wwpn, formData.lun), - ); + )) as Awaited>; if (result.status === 200) navigate(PATHS.zfcp.root); setIsAcceptDisabled(false); diff --git a/web/src/components/storage/zfcp/ZFCPDiskForm.tsx b/web/src/components/storage/zfcp/ZFCPDiskForm.tsx index 2ac341996e..19f5ab3c4f 100644 --- a/web/src/components/storage/zfcp/ZFCPDiskForm.tsx +++ b/web/src/components/storage/zfcp/ZFCPDiskForm.tsx @@ -24,7 +24,7 @@ import React, { FormEvent, useEffect, useState } from "react"; import { Alert, Form, FormGroup, FormSelect, FormSelectOption } from "@patternfly/react-core"; -import { AxiosResponseHeaders } from "axios"; +import { AxiosResponse } from "axios"; import { Page } from "~/components/core"; import { useZFCPControllers, useZFCPDisks } from "~/queries/storage/zfcp"; import { inactiveLuns } from "~/utils/zfcp"; @@ -46,7 +46,7 @@ export default function ZFCPDiskForm({ onLoading, }: { id: string; - onSubmit: (formData: FormData) => Promise; + onSubmit: (formData: FormData) => Promise; onLoading: (isLoading: boolean) => void; }) { const controllers = useZFCPControllers(); From 8c63e13c7fb4cc7431a549d0ecbecbb000e7aa53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 27 Dec 2024 14:22:33 +0000 Subject: [PATCH 11/31] fix(web): type adjustments --- web/src/components/network/NetworkPage.tsx | 2 +- web/src/utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/network/NetworkPage.tsx b/web/src/components/network/NetworkPage.tsx index f28015222a..83c3a16166 100644 --- a/web/src/components/network/NetworkPage.tsx +++ b/web/src/components/network/NetworkPage.tsx @@ -98,7 +98,7 @@ const NoWifiAvailable = () => ( export default function NetworkPage() { useNetworkConfigChanges(); const { connections, devices, settings } = useNetwork(); - const [wifiConnections, wiredConnections] = partition(connections, (c) => c.wireless); + const [wifiConnections, wiredConnections] = partition(connections, (c) => !!c.wireless); return ( diff --git a/web/src/utils.ts b/web/src/utils.ts index 457ef5b99a..3290d65117 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -245,7 +245,7 @@ const useCancellablePromise = () => { }; }, []); - const cancellablePromise = useCallback((promise) => { + const cancellablePromise = useCallback((promise: Promise): Promise => { const cancellableWrapper: CancellableWrapper = makeCancellable(promise); promises.current.push(cancellableWrapper); return cancellableWrapper.promise; From b6e89843be13dc455ac2d5ea4cd38119685f6cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 27 Dec 2024 14:42:56 +0000 Subject: [PATCH 12/31] fix(web): please ESLint --- web/src/test-utils.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index 01a69857d3..39493e288e 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -20,6 +20,8 @@ * find current contact information at www.suse.com. */ +/* eslint-disable i18next/no-literal-string */ + /** * A module for providing utility functions for testing * @@ -161,7 +163,7 @@ const plainRender = (ui, options = {}) => { * * @return a tuple with the mocked function and the list of callbacks. */ -const createCallbackMock = (): [(callback: Function) => () => void, Array<(arg0: any) => void>] => { +const createCallbackMock = (): [(callback: Function) => () => void, Array<(arg0) => void>] => { const callbacks = []; const on = (callback: Function) => { callbacks.push(callback); From 02b4a4bbb8b33e7ec1a8e525d3513e72ae56a428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 27 Dec 2024 14:55:50 +0000 Subject: [PATCH 13/31] refactor(web): migrate router to TypeScript --- web/src/{router.js => router.tsx} | 1 - 1 file changed, 1 deletion(-) rename web/src/{router.js => router.tsx} (99%) diff --git a/web/src/router.js b/web/src/router.tsx similarity index 99% rename from web/src/router.js rename to web/src/router.tsx index f949a76652..8a94477885 100644 --- a/web/src/router.js +++ b/web/src/router.tsx @@ -99,7 +99,6 @@ const protectedRoutes = () => [ const router = () => createHashRouter([ { - exact: true, path: PATHS.login, element: ( From 96f3db659f50bd366d7ef4be3325606314d48ca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 27 Dec 2024 15:03:49 +0000 Subject: [PATCH 14/31] refactor(web): migrate src/index to TypeScript --- web/src/{index.js => index.tsx} | 0 web/webpack.config.js | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename web/src/{index.js => index.tsx} (100%) diff --git a/web/src/index.js b/web/src/index.tsx similarity index 100% rename from web/src/index.js rename to web/src/index.tsx diff --git a/web/webpack.config.js b/web/webpack.config.js index 1f7db8f1a6..d92d5291f9 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -101,7 +101,7 @@ module.exports = { ignored: /node_modules/, }, entry: { - index: ["./src/index.js"], + index: ["./src/index.tsx"], }, devServer: { hot: true, From 6ccb663c2b2a1ee7350e38a23b8270be6bb83f61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 27 Dec 2024 15:13:41 +0000 Subject: [PATCH 15/31] refactor(web): migrate setupTests to TypeScript --- web/jest.config.js | 2 +- web/src/{setupTests.js => setupTests.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename web/src/{setupTests.js => setupTests.ts} (100%) diff --git a/web/jest.config.js b/web/jest.config.js index 670ff97b57..728e681612 100644 --- a/web/jest.config.js +++ b/web/jest.config.js @@ -135,7 +135,7 @@ module.exports = { // A list of paths to modules that run some code to configure or set up the testing framework before each test // setupFilesAfterEnv: [], - setupFilesAfterEnv: ["/src/setupTests.js"], + setupFilesAfterEnv: ["/src/setupTests.ts"], // The number of seconds after which a test is considered as slow and reported as such in the results. // slowTestThreshold: 5, diff --git a/web/src/setupTests.js b/web/src/setupTests.ts similarity index 100% rename from web/src/setupTests.js rename to web/src/setupTests.ts From 815415dab66734f542fddf8218cf773e4bb9e16f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 30 Dec 2024 14:15:22 +0000 Subject: [PATCH 16/31] refactor(web): migrate hooks to TypeScript --- web/src/hooks/useNodeSiblings.test.js | 63 ---------------- web/src/hooks/useNodeSiblings.test.tsx | 73 +++++++++++++++++++ ...{useNodeSiblings.js => useNodeSiblings.ts} | 26 ++----- 3 files changed, 78 insertions(+), 84 deletions(-) delete mode 100644 web/src/hooks/useNodeSiblings.test.js create mode 100644 web/src/hooks/useNodeSiblings.test.tsx rename web/src/hooks/{useNodeSiblings.js => useNodeSiblings.ts} (54%) diff --git a/web/src/hooks/useNodeSiblings.test.js b/web/src/hooks/useNodeSiblings.test.js deleted file mode 100644 index b2a569f845..0000000000 --- a/web/src/hooks/useNodeSiblings.test.js +++ /dev/null @@ -1,63 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import useNodeSiblings from "./useNodeSiblings"; - -// Mocked HTMLElement for testing -const mockNode = { - parentNode: { - children: [ - { setAttribute: jest.fn(), removeAttribute: jest.fn() }, // sibling 1 - { setAttribute: jest.fn(), removeAttribute: jest.fn() }, // sibling 2 - { setAttribute: jest.fn(), removeAttribute: jest.fn() }, // sibling 3 - ], - }, -}; - -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", () => { - const { result } = renderHook(() => useNodeSiblings(mockNode)); - const [addAttribute] = result.current; - const attributeName = "attribute"; - const attributeValue = "value"; - - addAttribute(attributeName, attributeValue); - - expect(mockNode.parentNode.children[0].setAttribute).toHaveBeenCalledWith( - attributeName, - attributeValue, - ); - expect(mockNode.parentNode.children[1].setAttribute).toHaveBeenCalledWith( - attributeName, - attributeValue, - ); - expect(mockNode.parentNode.children[2].setAttribute).toHaveBeenCalledWith( - attributeName, - attributeValue, - ); - }); - - it("should remove attribute from all siblings when removeAttribute is called", () => { - const { result } = renderHook(() => useNodeSiblings(mockNode)); - const [, removeAttribute] = result.current; - const attributeName = "attribute"; - - removeAttribute(attributeName); - - expect(mockNode.parentNode.children[0].removeAttribute).toHaveBeenCalledWith(attributeName); - expect(mockNode.parentNode.children[1].removeAttribute).toHaveBeenCalledWith(attributeName); - expect(mockNode.parentNode.children[2].removeAttribute).toHaveBeenCalledWith(attributeName); - }); -}); diff --git a/web/src/hooks/useNodeSiblings.test.tsx b/web/src/hooks/useNodeSiblings.test.tsx new file mode 100644 index 0000000000..5052fd651b --- /dev/null +++ b/web/src/hooks/useNodeSiblings.test.tsx @@ -0,0 +1,73 @@ +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.js b/web/src/hooks/useNodeSiblings.ts similarity index 54% rename from web/src/hooks/useNodeSiblings.js rename to web/src/hooks/useNodeSiblings.ts index cf98a42d5e..1b21cfc34e 100644 --- a/web/src/hooks/useNodeSiblings.js +++ b/web/src/hooks/useNodeSiblings.ts @@ -1,42 +1,26 @@ import { noop } from "~/utils"; -/** - * Function for adding an attribute to a sibling - * - * @typedef {function} addAttributeFn - * @param {string} attribute - attribute name - * @param {*} value - value to set - */ - -/** - * Function for removing an attribute from a sibling - * - * @typedef {function} removeAttributeFn - * @param {string} attribute - attribute name - */ - /** * 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 - * - * @param {HTMLElement} node - * @returns {[addAttributeFn, removeAttributeFn]} */ -const useNodeSiblings = (node) => { +const useNodeSiblings = ( + node: HTMLElement, +): [(attribute: string, value) => void, (attribute: string) => void] => { if (!node) return [noop, noop]; const siblings = [...node.parentNode.children].filter((n) => n !== node); - const addAttribute = (attribute, value) => { + const addAttribute = (attribute: string, value) => { siblings.forEach((sibling) => { sibling.setAttribute(attribute, value); }); }; - const removeAttribute = (attribute) => { + const removeAttribute = (attribute: string) => { siblings.forEach((sibling) => { sibling.removeAttribute(attribute); }); From c1684a8115ebe3ddfc51f9a48cff0935f3509c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 30 Dec 2024 14:33:31 +0000 Subject: [PATCH 17/31] refactor(web): migrate user utils to TypeScript --- .../users/{utils.test.js => utils.test.ts} | 0 web/src/components/users/{utils.js => utils.ts} | 13 +++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) rename web/src/components/users/{utils.test.js => utils.test.ts} (100%) rename web/src/components/users/{utils.js => utils.ts} (86%) diff --git a/web/src/components/users/utils.test.js b/web/src/components/users/utils.test.ts similarity index 100% rename from web/src/components/users/utils.test.js rename to web/src/components/users/utils.test.ts diff --git a/web/src/components/users/utils.js b/web/src/components/users/utils.ts similarity index 86% rename from web/src/components/users/utils.js rename to web/src/components/users/utils.ts index c1a9ec62aa..233ff584b5 100644 --- a/web/src/components/users/utils.js +++ b/web/src/components/users/utils.ts @@ -22,13 +22,14 @@ /** * Method which generates username suggestions based on given full name. - * The method cleans the input name by removing non-alphanumeric characters (except spaces), + * + * The method cleans given name by removing non-alphanumeric characters (except spaces), * splits the name into parts, and then generates suggestions based on these parts. * - * @param {string} fullName The full name used to generate username suggestions. - * @returns {string[]} An array of username suggestions. + * @param fullName The full name used to generate username suggestions. + * @returns An array of username suggestions. */ -const suggestUsernames = (fullName) => { +const suggestUsernames = (fullName: string) => { if (!fullName) return []; // Cleaning the name. @@ -41,7 +42,8 @@ const suggestUsernames = (fullName) => { // Split the cleaned name into parts. const parts = cleanedName.split(/\s+/); - const suggestions = new Set(); + // Uses Set for avoiding duplicates + const suggestions = new Set(); const firstLetters = parts.map((p) => p[0]).join(""); const lastPosition = parts.length - 1; @@ -66,7 +68,6 @@ const suggestUsernames = (fullName) => { if (s.length < 3) suggestions.delete(s); }); - // using Set object to remove duplicates, then converting back to array return [...suggestions]; }; From e97c575d79e970bdcec3193c5f11cd9376a38b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 2 Jan 2025 09:20:59 +0000 Subject: [PATCH 18/31] refactor(web): move index files to TypeScript --- web/src/components/core/{index.js => index.ts} | 0 web/src/components/l10n/{index.js => index.ts} | 0 web/src/components/layout/{index.js => index.ts} | 0 web/src/components/network/{index.js => index.ts} | 0 web/src/components/overview/{index.js => index.ts} | 0 web/src/components/software/{index.js => index.ts} | 0 web/src/components/storage/dasd/{index.js => index.ts} | 0 web/src/components/storage/{index.js => index.ts} | 0 web/src/components/storage/iscsi/{index.js => index.ts} | 0 web/src/components/storage/zfcp/{index.js => index.ts} | 0 web/src/components/users/{index.js => index.ts} | 0 11 files changed, 0 insertions(+), 0 deletions(-) rename web/src/components/core/{index.js => index.ts} (100%) rename web/src/components/l10n/{index.js => index.ts} (100%) rename web/src/components/layout/{index.js => index.ts} (100%) rename web/src/components/network/{index.js => index.ts} (100%) rename web/src/components/overview/{index.js => index.ts} (100%) rename web/src/components/software/{index.js => index.ts} (100%) rename web/src/components/storage/dasd/{index.js => index.ts} (100%) rename web/src/components/storage/{index.js => index.ts} (100%) rename web/src/components/storage/iscsi/{index.js => index.ts} (100%) rename web/src/components/storage/zfcp/{index.js => index.ts} (100%) rename web/src/components/users/{index.js => index.ts} (100%) diff --git a/web/src/components/core/index.js b/web/src/components/core/index.ts similarity index 100% rename from web/src/components/core/index.js rename to web/src/components/core/index.ts diff --git a/web/src/components/l10n/index.js b/web/src/components/l10n/index.ts similarity index 100% rename from web/src/components/l10n/index.js rename to web/src/components/l10n/index.ts diff --git a/web/src/components/layout/index.js b/web/src/components/layout/index.ts similarity index 100% rename from web/src/components/layout/index.js rename to web/src/components/layout/index.ts diff --git a/web/src/components/network/index.js b/web/src/components/network/index.ts similarity index 100% rename from web/src/components/network/index.js rename to web/src/components/network/index.ts diff --git a/web/src/components/overview/index.js b/web/src/components/overview/index.ts similarity index 100% rename from web/src/components/overview/index.js rename to web/src/components/overview/index.ts diff --git a/web/src/components/software/index.js b/web/src/components/software/index.ts similarity index 100% rename from web/src/components/software/index.js rename to web/src/components/software/index.ts diff --git a/web/src/components/storage/dasd/index.js b/web/src/components/storage/dasd/index.ts similarity index 100% rename from web/src/components/storage/dasd/index.js rename to web/src/components/storage/dasd/index.ts diff --git a/web/src/components/storage/index.js b/web/src/components/storage/index.ts similarity index 100% rename from web/src/components/storage/index.js rename to web/src/components/storage/index.ts diff --git a/web/src/components/storage/iscsi/index.js b/web/src/components/storage/iscsi/index.ts similarity index 100% rename from web/src/components/storage/iscsi/index.js rename to web/src/components/storage/iscsi/index.ts diff --git a/web/src/components/storage/zfcp/index.js b/web/src/components/storage/zfcp/index.ts similarity index 100% rename from web/src/components/storage/zfcp/index.js rename to web/src/components/storage/zfcp/index.ts diff --git a/web/src/components/users/index.js b/web/src/components/users/index.ts similarity index 100% rename from web/src/components/users/index.js rename to web/src/components/users/index.ts From 62e1b04493dbbbb5256a855f5b7bc3d586f00f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 2 Jan 2025 11:50:51 +0000 Subject: [PATCH 19/31] refactor(web): migrate core/RowActions to TypeScript --- web/src/components/core/RowActions.test.tsx | 71 +++++++++++++++++++ .../core/{RowActions.jsx => RowActions.tsx} | 32 +++++---- 2 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 web/src/components/core/RowActions.test.tsx rename web/src/components/core/{RowActions.jsx => RowActions.tsx} (77%) diff --git a/web/src/components/core/RowActions.test.tsx b/web/src/components/core/RowActions.test.tsx new file mode 100644 index 0000000000..25c461d5db --- /dev/null +++ b/web/src/components/core/RowActions.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright (c) [2024] 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 React from "react"; +import { IAction } from "@patternfly/react-table"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { RowActions } from "~/components/core"; +import { Icon } from "../layout"; +import { _ } from "~/i18n"; + +const mockEditFn = jest.fn(); +const mockDeleteFn = jest.fn(); + +const actions: IAction[] = [ + { + title: _("Edit"), + role: "link", + "aria-label": _("Dummy edit action"), + onClick: mockEditFn, + }, + { + title: _("Delete"), + "aria-label": _("Dummy delete action"), + icon: , + onClick: mockDeleteFn, + isDanger: true, + }, +]; + +describe("RowActions", () => { + it("allows interacting with given actions from a dropdown menu", async () => { + const { user } = plainRender( + , + ); + + const button = screen.getByRole("button", { name: "Actions for testing" }); + await user.click(button); + screen.getByRole("menu"); + const editAction = screen.getByRole("menuitem", { name: "Dummy edit action" }); + await user.click(editAction); + expect(mockEditFn).toHaveBeenCalled(); + await user.click(button); + const deleteAction = screen.getByRole("menuitem", { name: "Dummy delete action" }); + await user.click(deleteAction); + expect(mockDeleteFn).toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/core/RowActions.jsx b/web/src/components/core/RowActions.tsx similarity index 77% rename from web/src/components/core/RowActions.jsx rename to web/src/components/core/RowActions.tsx index 8a6321bdc4..27f2be1cd7 100644 --- a/web/src/components/core/RowActions.jsx +++ b/web/src/components/core/RowActions.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -22,13 +22,21 @@ import React from "react"; import { MenuToggle } from "@patternfly/react-core"; -import { ActionsColumn } from "@patternfly/react-table"; - +import { + ActionsColumn, + ActionsColumnProps, + CustomActionsToggleProps, +} from "@patternfly/react-table"; import { Icon } from "~/components/layout"; import { _ } from "~/i18n"; +type RowActionsProps = { + id: string; + actions: ActionsColumnProps["items"]; +} & Omit; + /** - * Renders icon for selecting the options of a row in a table + * Renders available options for a row in a table * @component * * @example @@ -46,16 +54,14 @@ import { _ } from "~/i18n"; * } * ]} * /> - * - * @param {object} props - * @param {string} props.id - * @param {Action[]} props.actions - * @param {object} [props.rest] - * - * @typedef {import("@patternfly/react-table").IAction} Action */ -export default function RowActions({ id, actions, "aria-label": toggleAriaLabel, ...rest }) { - const actionsToggle = (props) => ( +export default function RowActions({ + id, + actions, + "aria-label": toggleAriaLabel, + ...rest +}: RowActionsProps) { + const actionsToggle = (props: CustomActionsToggleProps) => ( Date: Thu, 2 Jan 2025 12:01:45 +0000 Subject: [PATCH 20/31] refactor(web): migrate core/NumericTextInput to TypeScript --- ...put.test.jsx => NumericTextInput.test.tsx} | 4 +-- ...ericTextInput.jsx => NumericTextInput.tsx} | 30 ++++++++----------- 2 files changed, 14 insertions(+), 20 deletions(-) rename web/src/components/core/{NumericTextInput.test.jsx => NumericTextInput.test.tsx} (95%) rename web/src/components/core/{NumericTextInput.jsx => NumericTextInput.tsx} (66%) diff --git a/web/src/components/core/NumericTextInput.test.jsx b/web/src/components/core/NumericTextInput.test.tsx similarity index 95% rename from web/src/components/core/NumericTextInput.test.jsx rename to web/src/components/core/NumericTextInput.test.tsx index 2078a24e5c..760aac843e 100644 --- a/web/src/components/core/NumericTextInput.test.jsx +++ b/web/src/components/core/NumericTextInput.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -29,7 +29,7 @@ import { NumericTextInput } from "~/components/core"; // the given onChange callback is called. The former is more aligned with the // React Testing Library principles, https://testing-library.com/docs/guiding-principles const Input = ({ value: initialValue = "" }) => { - const [value, setValue] = useState(initialValue); + const [value, setValue] = useState(initialValue); return ; }; diff --git a/web/src/components/core/NumericTextInput.jsx b/web/src/components/core/NumericTextInput.tsx similarity index 66% rename from web/src/components/core/NumericTextInput.jsx rename to web/src/components/core/NumericTextInput.tsx index 1ecd2aa984..50302af7b2 100644 --- a/web/src/components/core/NumericTextInput.jsx +++ b/web/src/components/core/NumericTextInput.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -20,19 +20,14 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React from "react"; -import { TextInput } from "@patternfly/react-core"; +import { TextInput, TextInputProps } from "@patternfly/react-core"; import { noop } from "~/utils"; -/** - * Callback function for notifying a valid input change - * - * @callback onChangeFn - * @param {string|number} the input value - * @return {void} - */ +type NumericTextInputProps = { + value: string | number; + onChange: (value: string | number) => void; +} & Omit; /** * Helper component for having an input text limited to not signed numbers @@ -41,17 +36,16 @@ import { noop } from "~/utils"; * Based on {@link https://www.patternfly.org/components/forms/text-input PF/TextInput} * * @note It allows empty value too. - * - * @param {object} props - * @param {string|number} props.value - the input value - * @param {onChangeFn} props.onChange - the callback to be called when the entered value match the input pattern - * @param {import("@patternfly/react-core").TextInputProps} props.textInputProps */ -export default function NumericTextInput({ value = "", onChange = noop, ...textInputProps }) { +export default function NumericTextInput({ + value = "", + onChange = noop, + ...textInputProps +}: NumericTextInputProps) { // NOTE: Using \d* instead of \d+ at the beginning to allow empty const pattern = /^\d*\.?\d*$/; - const handleOnChange = (_, value) => { + const handleOnChange: TextInputProps["onChange"] = (_, value) => { if (pattern.test(value)) { onChange(value); } From b01c27ea43603fbfd3c47efe30b715ae627a26dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 2 Jan 2025 12:09:26 +0000 Subject: [PATCH 21/31] fix(web): please ESLint after migrating NumericTextInput --- web/src/components/storage/VolumeFields.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/web/src/components/storage/VolumeFields.tsx b/web/src/components/storage/VolumeFields.tsx index 3d5beb6a0a..aafe93e25b 100644 --- a/web/src/components/storage/VolumeFields.tsx +++ b/web/src/components/storage/VolumeFields.tsx @@ -344,7 +344,6 @@ const SizeManual = ({ onChange({ minSize })} - validated={errors.minSize && "error"} + validated={errors.minSize ? "error" : "default"} isDisabled={isDisabled} /> @@ -419,14 +418,13 @@ and maximum. If no maximum is given then the file system will be as big as possi onChange({ minSize })} - validated={errors.minSize && "error"} + validated={errors.minSize ? "error" : "default"} isDisabled={isDisabled} /> @@ -453,10 +451,9 @@ and maximum. If no maximum is given then the file system will be as big as possi Date: Thu, 2 Jan 2025 13:15:06 +0000 Subject: [PATCH 22/31] refactor(web): migrate core/ListSearch to TypeScript --- ...istSearch.test.jsx => ListSearch.test.tsx} | 2 +- .../core/{ListSearch.jsx => ListSearch.tsx} | 31 ++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) rename web/src/components/core/{ListSearch.test.jsx => ListSearch.test.tsx} (98%) rename web/src/components/core/{ListSearch.jsx => ListSearch.tsx} (76%) diff --git a/web/src/components/core/ListSearch.test.jsx b/web/src/components/core/ListSearch.test.tsx similarity index 98% rename from web/src/components/core/ListSearch.test.jsx rename to web/src/components/core/ListSearch.test.tsx index 1baf97cfea..d29e368dfb 100644 --- a/web/src/components/core/ListSearch.test.jsx +++ b/web/src/components/core/ListSearch.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * diff --git a/web/src/components/core/ListSearch.jsx b/web/src/components/core/ListSearch.tsx similarity index 76% rename from web/src/components/core/ListSearch.jsx rename to web/src/components/core/ListSearch.tsx index 1b3467f1f0..780feb574b 100644 --- a/web/src/components/core/ListSearch.jsx +++ b/web/src/components/core/ListSearch.tsx @@ -25,44 +25,47 @@ import { SearchInput } from "@patternfly/react-core"; import { _ } from "~/i18n"; import { noop, useDebounce } from "~/utils"; -const search = (elements, term) => { +type ListSearchProps = { + /** Text to display as placeholder for the search input. */ + placeholder?: string; + /** List of elements in which to search. */ + elements: T[]; + /** Callback to be called with the filtered list of elements. */ + onChange: (elements: T[]) => void; +}; + +function search(elements: T[], term: string): T[] { const value = term.toLowerCase(); - const match = (element) => { + const match = (element: T) => { return Object.values(element).join("").toLowerCase().includes(value); }; return elements.filter(match); -}; +} /** - * TODO: Rename and/or refactor? * Input field for searching in a given list of elements. * @component - * - * @param {object} props - * @param {string} [props.placeholder] - * @param {object[]} [props.elements] - List of elements in which to search. - * @param {(elements: object[]) => void} [props.onChange] - Callback to be called with the filtered list of elements. */ -export default function ListSearch({ +export default function ListSearch({ placeholder = _("Search"), elements = [], onChange: onChangeProp = noop, -}) { +}: ListSearchProps) { const [value, setValue] = useState(""); const [resultSize, setResultSize] = useState(elements.length); - const updateResult = (result) => { + const updateResult = (result: T[]) => { setResultSize(result.length); onChangeProp(result); }; - const searchHandler = useDebounce((term) => { + const searchHandler = useDebounce((term: string) => { updateResult(search(elements, term)); }, 500); - const onChange = (value) => { + const onChange = (value: string) => { setValue(value); searchHandler(value); }; From 7ec3d978fc7d01c3a756368f29ec5c610c4d7aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 2 Jan 2025 13:17:45 +0000 Subject: [PATCH 23/31] refactor(web): drop core/SectionSkeleton Because it's no longer use. --- web/src/components/core/SectionSkeleton.jsx | 42 --------------------- web/src/components/core/index.ts | 1 - 2 files changed, 43 deletions(-) delete mode 100644 web/src/components/core/SectionSkeleton.jsx diff --git a/web/src/components/core/SectionSkeleton.jsx b/web/src/components/core/SectionSkeleton.jsx deleted file mode 100644 index 59337e133b..0000000000 --- a/web/src/components/core/SectionSkeleton.jsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) [2022-2023] 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 React from "react"; -import { Skeleton } from "@patternfly/react-core"; -import { _ } from "~/i18n"; - -const WaitingSkeleton = ({ width }) => { - return ; -}; - -const SectionSkeleton = ({ numRows = 2 }) => { - return ( - <> - {Array.from({ length: numRows }, (_, i) => { - const width = i % 2 === 0 ? "50%" : "25%"; - return ; - })} - - ); -}; - -export default SectionSkeleton; diff --git a/web/src/components/core/index.ts b/web/src/components/core/index.ts index 0345080fd4..dd18a31925 100644 --- a/web/src/components/core/index.ts +++ b/web/src/components/core/index.ts @@ -32,7 +32,6 @@ export { default as InstallationFinished } from "./InstallationFinished"; export { default as InstallationProgress } from "./InstallationProgress"; export { default as InstallButton } from "./InstallButton"; export { default as IssuesHint } from "./IssuesHint"; -export { default as SectionSkeleton } from "./SectionSkeleton"; export { default as ListSearch } from "./ListSearch"; export { default as LoginPage } from "./LoginPage"; export { default as RowActions } from "./RowActions"; From dfa51aae7ed60a7e6b804ca8ae0ca9fef1bf8982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 2 Jan 2025 13:20:17 +0000 Subject: [PATCH 24/31] refactor(web): migratre layout/Center to TypeScript --- web/src/components/layout/{Center.jsx => Center.tsx} | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) rename web/src/components/layout/{Center.jsx => Center.tsx} (91%) diff --git a/web/src/components/layout/Center.jsx b/web/src/components/layout/Center.tsx similarity index 91% rename from web/src/components/layout/Center.jsx rename to web/src/components/layout/Center.tsx index 2f3046ec3a..48c4a53512 100644 --- a/web/src/components/layout/Center.jsx +++ b/web/src/components/layout/Center.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -20,8 +20,6 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React from "react"; /** @@ -44,11 +42,8 @@ import React from "react"; * To know more, read * - https://www.w3.org/TR/selectors-4/#relational * - https://ishadeed.com/article/css-has-parent-selector/ - * - * @param {object} props - * @param {React.ReactNode} props.children */ -const Center = ({ children }) => ( +const Center = ({ children }: React.PropsWithChildren) => (
{children}
From 8876cf528a9a8d7961897d3ee044b258bc4fe168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 2 Jan 2025 13:23:03 +0000 Subject: [PATCH 25/31] refactor(web): migrate to core/IssuesHint to TypeScript --- .../core/{IssuesHint.test.jsx => IssuesHint.test.tsx} | 2 +- web/src/components/core/{IssuesHint.jsx => IssuesHint.tsx} | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) rename web/src/components/core/{IssuesHint.test.jsx => IssuesHint.test.tsx} (97%) rename web/src/components/core/{IssuesHint.jsx => IssuesHint.tsx} (91%) diff --git a/web/src/components/core/IssuesHint.test.jsx b/web/src/components/core/IssuesHint.test.tsx similarity index 97% rename from web/src/components/core/IssuesHint.test.jsx rename to web/src/components/core/IssuesHint.test.tsx index a4a0d5a19e..3b654a88f4 100644 --- a/web/src/components/core/IssuesHint.test.jsx +++ b/web/src/components/core/IssuesHint.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2025] SUSE LLC * * All Rights Reserved. * diff --git a/web/src/components/core/IssuesHint.jsx b/web/src/components/core/IssuesHint.tsx similarity index 91% rename from web/src/components/core/IssuesHint.jsx rename to web/src/components/core/IssuesHint.tsx index 9a9c92a6d9..86ee00cad7 100644 --- a/web/src/components/core/IssuesHint.jsx +++ b/web/src/components/core/IssuesHint.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2025] SUSE LLC * * All Rights Reserved. * @@ -23,6 +23,7 @@ import React from "react"; import { Hint, HintBody, List, ListItem, Stack } from "@patternfly/react-core"; import { _ } from "~/i18n"; +import { Issue } from "~/types/issues"; export default function IssuesHint({ issues }) { if (issues === undefined || issues.length === 0) return; @@ -35,7 +36,7 @@ export default function IssuesHint({ issues }) { {_("Before starting the installation, you need to address the following problems:")}

- {issues.map((i, idx) => ( + {issues.map((i: Issue, idx: number) => ( {i.description} ))} From 39bdcb68f5688b895d4395af58682f52df33d0a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 2 Jan 2025 13:26:12 +0000 Subject: [PATCH 26/31] refactor(web): migrate L10n page and sections to TypeScript --- .../components/l10n/{L10nPage.test.jsx => L10nPage.test.tsx} | 2 +- web/src/components/l10n/{L10nPage.jsx => L10nPage.tsx} | 2 +- .../overview/{L10nSection.test.jsx => L10nSection.test.tsx} | 2 +- .../components/overview/{L10nSection.jsx => L10nSection.tsx} | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename web/src/components/l10n/{L10nPage.test.jsx => L10nPage.test.tsx} (98%) rename web/src/components/l10n/{L10nPage.jsx => L10nPage.tsx} (98%) rename web/src/components/overview/{L10nSection.test.jsx => L10nSection.test.tsx} (97%) rename web/src/components/overview/{L10nSection.jsx => L10nSection.tsx} (97%) diff --git a/web/src/components/l10n/L10nPage.test.jsx b/web/src/components/l10n/L10nPage.test.tsx similarity index 98% rename from web/src/components/l10n/L10nPage.test.jsx rename to web/src/components/l10n/L10nPage.test.tsx index 7229c51903..48f26d6255 100644 --- a/web/src/components/l10n/L10nPage.test.jsx +++ b/web/src/components/l10n/L10nPage.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2024] SUSE LLC + * Copyright (c) [2022-2025] SUSE LLC * * All Rights Reserved. * diff --git a/web/src/components/l10n/L10nPage.jsx b/web/src/components/l10n/L10nPage.tsx similarity index 98% rename from web/src/components/l10n/L10nPage.jsx rename to web/src/components/l10n/L10nPage.tsx index 5ead5d01f6..4e0121f788 100644 --- a/web/src/components/l10n/L10nPage.jsx +++ b/web/src/components/l10n/L10nPage.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2025] SUSE LLC * * All Rights Reserved. * diff --git a/web/src/components/overview/L10nSection.test.jsx b/web/src/components/overview/L10nSection.test.tsx similarity index 97% rename from web/src/components/overview/L10nSection.test.jsx rename to web/src/components/overview/L10nSection.test.tsx index c89155fd5a..efb9a41ff0 100644 --- a/web/src/components/overview/L10nSection.test.jsx +++ b/web/src/components/overview/L10nSection.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2025] SUSE LLC * * All Rights Reserved. * diff --git a/web/src/components/overview/L10nSection.jsx b/web/src/components/overview/L10nSection.tsx similarity index 97% rename from web/src/components/overview/L10nSection.jsx rename to web/src/components/overview/L10nSection.tsx index a82d72cb8d..37308e410d 100644 --- a/web/src/components/overview/L10nSection.jsx +++ b/web/src/components/overview/L10nSection.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2025] SUSE LLC * * All Rights Reserved. * From 0bb51fd248051ad8495db7f73f525d02c191368c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 2 Jan 2025 13:28:57 +0000 Subject: [PATCH 27/31] refactor(web): migrate core/ProgressText to TypeScript --- ...essText.test.jsx => ProgressText.test.tsx} | 2 +- .../{ProgressText.jsx => ProgressText.tsx} | 22 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) rename web/src/components/core/{ProgressText.test.jsx => ProgressText.test.tsx} (97%) rename web/src/components/core/{ProgressText.jsx => ProgressText.tsx} (82%) diff --git a/web/src/components/core/ProgressText.test.jsx b/web/src/components/core/ProgressText.test.tsx similarity index 97% rename from web/src/components/core/ProgressText.test.jsx rename to web/src/components/core/ProgressText.test.tsx index 0c7e49034b..8fc5f21206 100644 --- a/web/src/components/core/ProgressText.test.jsx +++ b/web/src/components/core/ProgressText.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2025] SUSE LLC * * All Rights Reserved. * diff --git a/web/src/components/core/ProgressText.jsx b/web/src/components/core/ProgressText.tsx similarity index 82% rename from web/src/components/core/ProgressText.jsx rename to web/src/components/core/ProgressText.tsx index 5ac7dcac57..8a31346e2a 100644 --- a/web/src/components/core/ProgressText.jsx +++ b/web/src/components/core/ProgressText.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2025] SUSE LLC * * All Rights Reserved. * @@ -20,22 +20,22 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React from "react"; import { Split, Text } from "@patternfly/react-core"; +type ProgressTextProps = { + /** Progress message. */ + message: string; + /** Current step. */ + current: number; + /** Total steps. */ + total: number; +}; + /** * Progress description - * - * @component - * - * @param {object} props - * @param {string} [props.message] Progress message - * @param {number} [props.current] Current step - * @param {number} [props.total] Number of steps */ -export default function ProgressText({ message, current, total }) { +export default function ProgressText({ message, current, total }: ProgressTextProps) { const text = current === 0 ? message : `${message} (${current}/${total})`; return ( From d6815ceb8a49dffd8ee19decb35ff3a2e0a36c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 2 Jan 2025 14:06:23 +0000 Subject: [PATCH 28/31] refactor(web): migrate ProductSelectionProgress to TypeScript --- .../product/ProductSelectionProgress.test.tsx | 63 +++++++++++++++++++ ...gress.jsx => ProductSelectionProgress.tsx} | 4 +- 2 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 web/src/components/product/ProductSelectionProgress.test.tsx rename web/src/components/product/{ProductSelectionProgress.jsx => ProductSelectionProgress.tsx} (96%) diff --git a/web/src/components/product/ProductSelectionProgress.test.tsx b/web/src/components/product/ProductSelectionProgress.test.tsx new file mode 100644 index 0000000000..082ac388d2 --- /dev/null +++ b/web/src/components/product/ProductSelectionProgress.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright (c) [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 React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import ProductSelectionProgress from "./ProductSelectionProgress"; +import { ROOT } from "~/routes/paths"; +import { Product } from "~/types/software"; + +jest.mock("~/components/core/ProgressReport", () => () =>
ProgressReport Mock
); + +let isBusy = false; +const tumbleweed: Product = { id: "openSUSE", name: "openSUSE Tumbleweed" }; + +jest.mock("~/queries/status", () => ({ + ...jest.requireActual("~/queries/status"), + useInstallerStatus: () => ({ isBusy }), +})); + +jest.mock("~/queries/software", () => ({ + ...jest.requireActual("~/queries/software"), + useProduct: () => ({ selectedProduct: tumbleweed }), +})); + +describe("ProductSelectionProgress", () => { + describe("when installer is not busy", () => { + it("redirects to the root path", async () => { + installerRender(); + await screen.findByText(`Navigating to ${ROOT.root}`); + }); + }); + + describe("when installer in busy", () => { + beforeEach(() => { + isBusy = true; + }); + + it("renders progress report", () => { + installerRender(); + screen.getByText("ProgressReport Mock"); + }); + }); +}); diff --git a/web/src/components/product/ProductSelectionProgress.jsx b/web/src/components/product/ProductSelectionProgress.tsx similarity index 96% rename from web/src/components/product/ProductSelectionProgress.jsx rename to web/src/components/product/ProductSelectionProgress.tsx index cfc80bed82..09df5286d2 100644 --- a/web/src/components/product/ProductSelectionProgress.jsx +++ b/web/src/components/product/ProductSelectionProgress.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024] SUSE LLC + * Copyright (c) [2024-2025] SUSE LLC * * All Rights Reserved. * @@ -29,8 +29,6 @@ import { ROOT as PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; /** - * @component - * * Shows progress steps when a product is selected. */ function ProductSelectionProgress() { From b1f89d38d71d5bf3972268c934037ec188adfe5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 3 Jan 2025 11:48:33 +0000 Subject: [PATCH 29/31] fix(web): improve useNodeSiblings type readbility --- web/src/hooks/useNodeSiblings.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web/src/hooks/useNodeSiblings.ts b/web/src/hooks/useNodeSiblings.ts index 1b21cfc34e..d6418c0979 100644 --- a/web/src/hooks/useNodeSiblings.ts +++ b/web/src/hooks/useNodeSiblings.ts @@ -1,5 +1,8 @@ import { noop } from "~/utils"; +type AddAttributeFn = HTMLElement["setAttribute"]; +type RemoveAttributeFn = HTMLElement["removeAttribute"]; + /** * A hook for working with siblings of the node passed as parameter * @@ -7,20 +10,18 @@ import { noop } from "~/utils"; * - First for adding given attribute to siblings * - Second for removing given attributes from siblings */ -const useNodeSiblings = ( - node: HTMLElement, -): [(attribute: string, value) => void, (attribute: string) => void] => { +const useNodeSiblings = (node: HTMLElement): [AddAttributeFn, RemoveAttributeFn] => { if (!node) return [noop, noop]; const siblings = [...node.parentNode.children].filter((n) => n !== node); - const addAttribute = (attribute: string, value) => { + const addAttribute: AddAttributeFn = (attribute, value) => { siblings.forEach((sibling) => { sibling.setAttribute(attribute, value); }); }; - const removeAttribute = (attribute: string) => { + const removeAttribute: RemoveAttributeFn = (attribute: string) => { siblings.forEach((sibling) => { sibling.removeAttribute(attribute); }); From 6d803cca3f37aec654b7b929ffe6d26f7b5453c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 3 Jan 2025 11:49:27 +0000 Subject: [PATCH 30/31] fix(web): stop using translations in test files --- web/src/components/core/Page.test.tsx | 31 +++++++++---------- .../components/core/PasswordInput.test.tsx | 9 +++--- web/src/components/core/RowActions.test.tsx | 11 +++---- .../storage/DevicesTechMenu.test.tsx | 13 ++++---- 4 files changed, 30 insertions(+), 34 deletions(-) diff --git a/web/src/components/core/Page.test.tsx b/web/src/components/core/Page.test.tsx index be2515d2a3..20f3393fde 100644 --- a/web/src/components/core/Page.test.tsx +++ b/web/src/components/core/Page.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023-2024] SUSE LLC + * Copyright (c) [2023-2025] SUSE LLC * * All Rights Reserved. * @@ -24,7 +24,6 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender, mockNavigateFn } from "~/test-utils"; import { Page } from "~/components/core"; -import { _ } from "~/i18n"; let consoleErrorSpy: jest.SpyInstance; @@ -41,7 +40,7 @@ describe("Page", () => { it("renders given children", () => { plainRender( -

{_("The Page Component")}

+

The Page Component

, ); screen.getByRole("heading", { name: "The Page Component" }); @@ -93,7 +92,7 @@ describe("Page", () => { describe("Page.Content", () => { it("renders a node that fills all the available space", () => { - plainRender({_("The Content")}); + plainRender(The Content); const content = screen.getByText("The Content"); expect(content.classList.contains("pf-m-fill")).toBe(true); }); @@ -149,7 +148,7 @@ describe("Page", () => { }); describe("Page.Header", () => { it("renders a node that sticks to top", () => { - plainRender({_("The Header")}); + plainRender(The Header); const content = screen.getByText("The Header"); const container = content.parentNode as HTMLElement; expect(container.classList.contains("pf-m-sticky-top")).toBe(true); @@ -158,19 +157,19 @@ describe("Page", () => { describe("Page.Section", () => { it("outputs to console.error if both are missing, title and aria-label", () => { - plainRender({_("Content")}); + plainRender(Content); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("must have either")); }); it("renders a section node", () => { - plainRender({_("The Content")}); + plainRender(The Content); const section = screen.getByRole("region"); within(section).getByText("The Content"); }); it("adds the aria-labelledby attribute when title is given but aria-label is not", () => { const { rerender } = plainRender( - {_("The Content")}, + The Content, ); const section = screen.getByRole("region"); expect(section).toHaveAttribute("aria-labelledby"); @@ -178,7 +177,7 @@ describe("Page", () => { // aria-label is given through Page.Section props rerender( - {_("The Content")} + The Content , ); expect(section).not.toHaveAttribute("aria-labelledby"); @@ -186,25 +185,25 @@ describe("Page", () => { // aria-label is given through pfCardProps rerender( - {_("The Content")} + The Content , ); expect(section).not.toHaveAttribute("aria-labelledby"); // None was given, title nor aria-label - rerender({_("The Content")}); + rerender(The Content); expect(section).not.toHaveAttribute("aria-labelledby"); }); it("renders given content props (title, value, description, actions, and children (content)", () => { plainRender( {_("Disable")}} + title="A section" + value="Enabled" + description="Testing section with title, value, description, content, and actions" + actions={Disable} > - {_("The Content")} + The Content , ); const section = screen.getByRole("region"); diff --git a/web/src/components/core/PasswordInput.test.tsx b/web/src/components/core/PasswordInput.test.tsx index 8437498107..ad4f5aca27 100644 --- a/web/src/components/core/PasswordInput.test.tsx +++ b/web/src/components/core/PasswordInput.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023-2024] SUSE LLC + * Copyright (c) [2023-2025] SUSE LLC * * All Rights Reserved. * @@ -25,18 +25,17 @@ import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import userEvent from "@testing-library/user-event"; import PasswordInput, { PasswordInputProps } from "./PasswordInput"; -import { _ } from "~/i18n"; describe("PasswordInput Component", () => { it("renders a password input", () => { - plainRender(); + plainRender(); const inputField = screen.getByLabelText("User password"); expect(inputField).toHaveAttribute("type", "password"); }); it("allows revealing the password", async () => { - plainRender(); + plainRender(); const passwordInput = screen.getByLabelText("User password"); const button = screen.getByRole("button"); @@ -48,7 +47,7 @@ describe("PasswordInput Component", () => { it("applies autoFocus behavior correctly", () => { plainRender( - , + , ); const inputField = screen.getByLabelText("User password"); diff --git a/web/src/components/core/RowActions.test.tsx b/web/src/components/core/RowActions.test.tsx index 25c461d5db..9f5fc525da 100644 --- a/web/src/components/core/RowActions.test.tsx +++ b/web/src/components/core/RowActions.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024] SUSE LLC + * Copyright (c) [2025] SUSE LLC * * All Rights Reserved. * @@ -26,21 +26,20 @@ import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { RowActions } from "~/components/core"; import { Icon } from "../layout"; -import { _ } from "~/i18n"; const mockEditFn = jest.fn(); const mockDeleteFn = jest.fn(); const actions: IAction[] = [ { - title: _("Edit"), + title: "Edit", role: "link", - "aria-label": _("Dummy edit action"), + "aria-label": "Dummy edit action", onClick: mockEditFn, }, { - title: _("Delete"), - "aria-label": _("Dummy delete action"), + title: "Delete", + "aria-label": "Dummy delete action", icon: , onClick: mockDeleteFn, isDanger: true, diff --git a/web/src/components/storage/DevicesTechMenu.test.tsx b/web/src/components/storage/DevicesTechMenu.test.tsx index a527322d8a..5b8bf07358 100644 --- a/web/src/components/storage/DevicesTechMenu.test.tsx +++ b/web/src/components/storage/DevicesTechMenu.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2025] SUSE LLC * * All Rights Reserved. * @@ -24,7 +24,6 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import DevicesTechMenu from "./DevicesTechMenu"; -import { _ } from "~/i18n"; import { supportedDASD } from "~/api/storage/dasd"; import { supportedZFCP } from "~/api/storage/zfcp"; @@ -37,7 +36,7 @@ beforeEach(() => { }); it("contains an entry for configuring iSCSI", async () => { - const { user } = installerRender(); + const { user } = installerRender(); const toggler = screen.getByRole("button"); await user.click(toggler); const link = screen.getByRole("option", { name: /iSCSI/ }); @@ -45,7 +44,7 @@ it("contains an entry for configuring iSCSI", async () => { }); it("does not contain an entry for configuring DASD when is NOT supported", async () => { - const { user } = installerRender(); + const { user } = installerRender(); const toggler = screen.getByRole("button"); await user.click(toggler); expect(screen.queryByRole("option", { name: /DASD/ })).toBeNull(); @@ -53,7 +52,7 @@ it("does not contain an entry for configuring DASD when is NOT supported", async it("contains an entry for configuring DASD when is supported", async () => { (supportedDASD as jest.Mock).mockResolvedValue(true); - const { user } = installerRender(); + const { user } = installerRender(); const toggler = screen.getByRole("button"); await user.click(toggler); const link = screen.getByRole("option", { name: /DASD/ }); @@ -61,7 +60,7 @@ it("contains an entry for configuring DASD when is supported", async () => { }); it("does not contain an entry for configuring zFCP when is NOT supported", async () => { - const { user } = installerRender(); + const { user } = installerRender(); const toggler = screen.getByRole("button"); await user.click(toggler); expect(screen.queryByRole("option", { name: /DASD/ })).toBeNull(); @@ -69,7 +68,7 @@ it("does not contain an entry for configuring zFCP when is NOT supported", async it("contains an entry for configuring zFCP when is supported", async () => { (supportedZFCP as jest.Mock).mockResolvedValue(true); - const { user } = installerRender(); + const { user } = installerRender(); const toggler = screen.getByRole("button"); await user.click(toggler); const link = screen.getByRole("option", { name: /zFCP/ }); From 6ecc3161fca9fa0c25800f33ab27d72ca6675e7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 3 Jan 2025 11:51:17 +0000 Subject: [PATCH 31/31] fix(web): revert copyright miss-updated --- web/src/components/core/IssuesHint.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/core/IssuesHint.test.tsx b/web/src/components/core/IssuesHint.test.tsx index 3b654a88f4..6bead73a40 100644 --- a/web/src/components/core/IssuesHint.test.tsx +++ b/web/src/components/core/IssuesHint.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2025] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. *