diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index be65e47243..33de6e471e 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -2,14 +2,6 @@ // In the future we might add different section layouts by using data-variant attribute // or similar strategy -// raw file content with formatting similar to
-.filecontent {
-  font-family: var(--ff-code);
-  font-size: 90%;
-  word-break: break-all;
-  white-space: pre-wrap;
-}
-
 // Make progress more compact
 .dasd-format-progress {
   .pf-v5-c-progress {
@@ -17,29 +9,6 @@
   }
 }
 
-[data-type="agama/page-menu"] {
-  > button {
-    --pf-v5-c-button--PaddingRight: 0;
-  }
-
-  a {
-    font-weight: var(--fw-bold);
-    text-decoration: none;
-
-    svg {
-      color: inherit;
-    }
-
-    &:hover {
-      color: var(--color-link-hover);
-
-      svg {
-        color: var(--color-link);
-      }
-    }
-  }
-}
-
 .issue {
   --icon-size: 1rem;
 
@@ -55,174 +24,6 @@
   }
 }
 
-ul[data-type="agama/list"] {
-  list-style: none;
-  margin-inline: 0;
-
-  li {
-    border: 2px solid var(--color-gray-dark);
-    padding: var(--spacer-small);
-    text-align: start;
-    background: var(--color-gray-light);
-    margin-block-end: 0;
-
-    &:nth-child(n + 2) {
-      border-top: 0;
-    }
-
-    &:not(:last-child) {
-      border-bottom-width: 1px;
-    }
-
-    > div {
-      margin-block-end: var(--spacer-smaller);
-    }
-
-    // Done in two rules instead of div:not(:last-child) to avoid specificity
-    // problems later; see the storage-devices selector
-    > div:last-child {
-      margin-block-end: 0;
-    }
-  }
-
-  // FIXME: see if it's semantically correct to mark an li as aria-selected when
-  // not belongs to a listbox or grid list ul.
-  li[aria-selected] {
-    background: var(--color-gray-dark);
-
-    &:not(:last-child) {
-      border-bottom-color: white;
-    }
-  }
-}
-
-// These attributes together means that UI is rendering a selector
-ul[data-type="agama/list"][role="grid"] {
-  li[role="row"] {
-    cursor: pointer;
-
-    &:first-child {
-      border-radius: 5px 5px 0 0;
-    }
-
-    &:last-child {
-      border-radius: 0 0 5px 5px;
-    }
-
-    &:only-child {
-      border-radius: 5px;
-    }
-
-    &:hover {
-      &:not([aria-selected]) {
-        background: var(--color-gray-dark);
-      }
-
-      &:not(:last-child) {
-        border-bottom-color: white;
-      }
-    }
-
-    div[role="gridcell"] {
-      display: flex;
-      align-items: center;
-      gap: var(--spacer-small);
-
-      input {
-        --size: var(--fs-h2);
-        cursor: pointer;
-        block-size: var(--size);
-        inline-size: var(--size);
-
-        &[data-auto-selected] {
-          accent-color: white;
-          box-shadow: 0 0 1px;
-        }
-      }
-
-      & > div:first-child {
-        display: flex;
-        flex-direction: column;
-        align-items: center;
-        gap: var(--spacer-small);
-
-        span {
-          font-size: var(--fs-small);
-          font-weight: bold;
-        }
-      }
-
-      & > div:last-child {
-        flex: 1;
-      }
-    }
-  }
-}
-
-[data-items-type="agama/space-policies"] {
-  // It works with the default styling
-}
-
-[data-items-type="agama/locales"] {
-  display: grid;
-  grid-template-columns: 1fr 2fr;
-
-  > :last-child {
-    grid-column: 1 / -1;
-    font-size: var(--fs-small);
-  }
-}
-
-[data-items-type="agama/keymaps"] {
-  > :last-child {
-    font-size: var(--fs-small);
-  }
-}
-
-[data-items-type="agama/timezones"] {
-  display: grid;
-  grid-template-columns: 2fr 1fr 1fr;
-
-  > :last-child {
-    grid-column: 1 / -1;
-    font-size: 80%;
-  }
-
-  > :nth-child(3) {
-    color: var(--color-gray-dimmed);
-    text-align: end;
-  }
-}
-
-ul[data-items-type="agama/patterns"] {
-  div[role="gridcell"] {
-    & > div:first-child {
-      min-width: 65px;
-    }
-
-    & > div:last-child * {
-      margin-block-end: var(--spacer-small);
-    }
-  }
-}
-
-[role="dialog"] {
-  .sticky-top-0 {
-    position: sticky;
-    top: calc(-1 * var(--pf-v5-c-modal-box__body--PaddingTop));
-    margin-block-start: calc(-1 * var(--pf-v5-c-modal-box__body--PaddingTop));
-    padding-block-start: var(--pf-v5-c-modal-box__body--PaddingTop);
-    background-color: var(--pf-v5-c-modal-box--BackgroundColor);
-
-    [role="search"] {
-      width: 100%;
-      padding: var(--spacer-small);
-      border: 1px solid var(--color-primary);
-      border-radius: 5px;
-    }
-  }
-}
-
 table[data-type="agama/tree-table"] {
   th:first-child {
     padding-inline-end: var(--spacer-normal);
@@ -342,11 +143,6 @@ table.proposal-result {
   }
 }
 
-.highlighted-live-region {
-  padding: 10px;
-  background: var(--color-gray);
-}
-
 .size-input-group {
   max-inline-size: 20ch;
 
diff --git a/web/src/components/core/CardField.jsx b/web/src/components/core/CardField.jsx
deleted file mode 100644
index c11862e78e..0000000000
--- a/web/src/components/core/CardField.jsx
+++ /dev/null
@@ -1,86 +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 version 2 of the GNU General Public License as published
- * by the Free Software Foundation.
- *
- * 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.
- */
-
-// @ts-check
-
-import React from "react";
-import {
-  Card,
-  CardHeader,
-  CardTitle,
-  CardBody,
-  CardFooter,
-  Flex,
-  FlexItem,
-} from "@patternfly/react-core";
-import textStyles from "@patternfly/react-styles/css/utilities/Text/text";
-
-// FIXME: improve name and documentation
-// TODO: allows having a drawer, see storage/ProposalResultActions
-
-/**
- * Field wrapper built on top of PF/Card
- * @component
- *
- * @todo write documentation
- */
-const CardField = ({
-  label = undefined,
-  value = undefined,
-  description = undefined,
-  actions = undefined,
-  children,
-  cardProps = {},
-  cardHeaderProps = {},
-  cardDescriptionProps = {},
-}) => {
-  // TODO: replace aria-label with the proper aria-labelledby
-  return (
-    
-      
-        
-          
-            {label && (
-              
-                

{label}

-
- )} - {value && ( - - {value} - - )} -
-
-
- {description && ( - -
{description}
-
- )} - {children} - {actions && {actions}} -
- ); -}; - -CardField.Content = CardBody; -export default CardField; diff --git a/web/src/components/core/ListSearch.jsx b/web/src/components/core/ListSearch.jsx index 5f187f0d37..b3d2008314 100644 --- a/web/src/components/core/ListSearch.jsx +++ b/web/src/components/core/ListSearch.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -42,7 +42,7 @@ const search = (elements, term) => { * @param {object} props * @param {string} [props.placeholder] * @param {object[]} [props.elements] - List of elements in which to search. - * @param {(elements: object[]) => void} - Callback to be called with the filtered list of elements. + * @param {(elements: object[]) => void} [props.onChange] - Callback to be called with the filtered list of elements. */ export default function ListSearch({ placeholder = _("Search"), diff --git a/web/src/components/core/LoginPage.test.jsx b/web/src/components/core/LoginPage.test.tsx similarity index 93% rename from web/src/components/core/LoginPage.test.jsx rename to web/src/components/core/LoginPage.test.tsx index d95ed34d51..899a29abcd 100644 --- a/web/src/components/core/LoginPage.test.jsx +++ b/web/src/components/core/LoginPage.test.tsx @@ -25,9 +25,10 @@ import { plainRender } from "~/test-utils"; import { LoginPage } from "~/components/core"; import { AuthErrors } from "~/context/auth"; -let mockIsAuthenticated; -const mockLoginFn = jest.fn(); +let consoleErrorSpy: jest.SpyInstance; +let mockIsAuthenticated: boolean; let mockLoginError; +const mockLoginFn = jest.fn(); jest.mock("~/context/auth", () => ({ ...jest.requireActual("~/context/auth"), @@ -40,16 +41,16 @@ jest.mock("~/context/auth", () => ({ }, })); -describe.skip("LoginPage", () => { +describe("LoginPage", () => { beforeAll(() => { mockIsAuthenticated = false; mockLoginError = null; mockLoginFn.mockResolvedValue({ status: 200 }); - jest.spyOn(console, "error").mockImplementation(); + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); }); afterAll(() => { - console.error.mockRestore(); + consoleErrorSpy.mockRestore(); }); describe("when user is not authenticated", () => { @@ -114,7 +115,7 @@ describe.skip("LoginPage", () => { it("renders a button to know more about the project", async () => { const { user } = plainRender(); - const button = screen.getByRole("button", { name: "What is this?" }); + const button = screen.getByRole("button", { name: "More about this" }); await user.click(button); diff --git a/web/src/components/core/LoginPage.jsx b/web/src/components/core/LoginPage.tsx similarity index 97% rename from web/src/components/core/LoginPage.jsx rename to web/src/components/core/LoginPage.tsx index 4a008904f9..ed37b28dbb 100644 --- a/web/src/components/core/LoginPage.jsx +++ b/web/src/components/core/LoginPage.tsx @@ -19,8 +19,6 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React, { useState } from "react"; import { Navigate } from "react-router-dom"; import { @@ -33,7 +31,6 @@ import { FormGroup, Grid, GridItem, - Stack, } from "@patternfly/react-core"; import { About, EmptyState, FormValidationError, Page, PasswordInput } from "~/components/core"; import { Center } from "~/components/layout"; @@ -41,8 +38,6 @@ import { AuthErrors, useAuth } from "~/context/auth"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; -// @ts-check - /** * Renders the UI that lets the user log into the system. * @component @@ -52,7 +47,7 @@ export default function LoginPage() { const [error, setError] = useState(false); const { isLoggedIn, login: loginFn, error: loginError } = useAuth(); - const login = async (e) => { + const login = async (e: React.FormEvent) => { e.preventDefault(); const result = await loginFn(password); @@ -82,7 +77,7 @@ user privileges.", ).split(/[[\]]/); return ( - +
@@ -122,6 +117,6 @@ user privileges.",
-
+ ); } diff --git a/web/src/components/core/Page.test.jsx b/web/src/components/core/Page.test.jsx deleted file mode 100644 index 4733dbf555..0000000000 --- a/web/src/components/core/Page.test.jsx +++ /dev/null @@ -1,290 +0,0 @@ -/* - * Copyright (c) [2023-2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * 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, within } from "@testing-library/react"; -import { installerRender, plainRender, mockNavigateFn } from "~/test-utils"; -import { Page } from "~/components/core"; -import { createClient } from "~/client"; - -jest.mock("~/client"); - -const l10nClientMock = { - getUILocale: jest.fn().mockResolvedValue("en_US"), - getUIKeymap: jest.fn().mockResolvedValue("en"), - keymaps: jest.fn().mockResolvedValue([]), - getKeymap: jest.fn().mockResolvedValue(undefined), - timezones: jest.fn().mockResolvedValue([]), - getTimezone: jest.fn().mockResolvedValue(undefined), - onLocalesChange: jest.fn(), - onKeymapChange: jest.fn(), - onTimezoneChange: jest.fn(), -}; - -describe.skip("Page", () => { - beforeAll(() => { - jest.spyOn(console, "error").mockImplementation(); - }); - - beforeEach(() => { - // if defined outside, the mock is cleared automatically - createClient.mockImplementation(() => { - return { - l10n: l10nClientMock, - }; - }); - }); - - afterAll(() => { - console.error.mockRestore(); - }); - - it("renders given title", () => { - installerRender(, { withL10n: true }); - screen.getByRole("heading", { name: "The Title" }); - }); - - it("renders 'Agama' as title if no title is given", () => { - installerRender(, { withL10n: true }); - screen.getByRole("heading", { name: "Agama" }); - }); - - it("renders an icon if valid icon name is given", () => { - installerRender(, { withL10n: true }); - const heading = screen.getByRole("heading", { level: 1 }); - const icon = heading.querySelector("svg"); - expect(icon).toHaveAttribute("data-icon-name", "settings"); - }); - - it("does not render an icon if icon name not given", () => { - installerRender(, { withL10n: true }); - const heading = screen.getByRole("heading", { level: 1 }); - const icon = heading.querySelector("svg"); - expect(icon).toBeNull(); - // Check that component was not mounted with 'undefined' - expect(console.error).not.toHaveBeenCalled(); - }); - - it("does not render an icon if not valid icon name is given", () => { - installerRender(, { withL10n: true }); - const heading = screen.getByRole("heading", { level: 1 }); - const icon = heading.querySelector("svg"); - expect(icon).toBeNull(); - }); - - it("renders given content", () => { - installerRender( - -
Page content
-
, - { withL10n: true }, - ); - - screen.getByText("Page content"); - }); - - it("renders found page menu in the header", async () => { - const { user } = installerRender( - -
A page with menu
- - - - - - -
, - { withL10n: true }, - ); - - // Sidebar is rendering it's own header, let's ignore it - const [header] = screen.getAllByRole("banner"); - const menuButton = within(header).getByRole("button", { name: "Testing menu" }); - await user.click(menuButton); - screen.getByRole("menuitem", { name: "Switch to advanced mode" }); - }); - - it("renders found page actions in the footer", () => { - installerRender( - - - Save - Discard - - , - { withL10n: true }, - ); - - // Sidebar is rendering it's own footer, let's ignore it - const [footer] = screen.getAllByRole("contentinfo"); - within(footer).getByRole("button", { name: "Save" }); - within(footer).getByRole("button", { name: "Discard" }); - }); - - it("renders the default 'Back' action if no actions are given", () => { - installerRender(, { withL10n: true }); - screen.getByRole("button", { name: "Back" }); - }); - - it("renders the Agama sidebar by default", async () => { - const { user } = installerRender(, { withL10n: true }); - const openSidebarButton = screen.getByRole("button", { name: "Show global options" }); - - await user.click(openSidebarButton); - - screen.getByRole("complementary", { name: /options/i }); - }); - - it("does not render the Agama sidebar when mountSidebar=false", () => { - installerRender(, { withL10n: true }); - const openSidebarButton = screen.queryByRole("button", { name: "Show global options" }); - const sidebar = screen.queryByRole("complementary", { name: /options/i, hidden: true }); - expect(openSidebarButton).toBeNull(); - expect(sidebar).toBeNull(); - }); -}); - -describe.skip("Page.Actions", () => { - it("renders its children", () => { - plainRender( - - - , - ); - - screen.getByRole("button", { name: "Plain action" }); - }); -}); - -describe.skip("Page.Menu", () => { - // NOTE: just testing that the Page.Menu alias works. - // Full PageMenu testing is done in its own test file at core/PageMenu.test.jsx - it("renders a menu", () => { - plainRender( - - - - <>The menu entry - - - , - ); - - screen.getByRole("button", { name: "Show page menu" }); - }); -}); - -describe.skip("Page.Action", () => { - it("renders a button with given content", () => { - plainRender(Save); - screen.getByRole("button", { name: "Save" }); - }); - - it("renders an 'lg' button when size prop is not given", () => { - plainRender(Cancel); - const button = screen.getByRole("button", { name: "Cancel" }); - expect(button.classList.contains("pf-m-display-lg")).toBe(true); - }); - - describe("when user clicks on it", () => { - it("triggers given onClick handler, if valid", async () => { - const onClick = jest.fn(); - const { user } = plainRender(Cancel); - const button = screen.getByRole("button", { name: "Cancel" }); - await user.click(button); - expect(onClick).toHaveBeenCalled(); - }); - - it("navigates to the path given through 'navigateTo' prop", async () => { - const { user } = plainRender(Cancel); - const button = screen.getByRole("button", { name: "Cancel" }); - await user.click(button); - expect(mockNavigateFn).toHaveBeenCalledWith("/somewhere"); - }); - - it("triggers form submission if it's a submit action and has an associated form", async () => { - // NOTE: using preventDefault here to avoid a jsdom error - // Error: Not implemented: HTMLFormElement.prototype.requestSubmit - const onSubmit = jest.fn((e) => { - e.preventDefault(); - }); - - const { user } = plainRender( - <> -
- - Send - - , - ); - const button = screen.getByRole("button", { name: "Send" }); - await user.click(button); - expect(onSubmit).toHaveBeenCalled(); - }); - - it("triggers form submission even when onClick and navigateTo are given", async () => { - const onClick = jest.fn(); - // NOTE: using preventDefault here to avoid a jsdom error - // Error: Not implemented: HTMLFormElement.prototype.requestSubmit - const onSubmit = jest.fn((e) => { - e.preventDefault(); - }); - - const { user } = plainRender( - <> - - - Send - - , - ); - const button = screen.getByRole("button", { name: "Send" }); - await user.click(button); - expect(onSubmit).toHaveBeenCalled(); - expect(onClick).toHaveBeenCalled(); - expect(mockNavigateFn).toHaveBeenCalledWith("/somewhere"); - }); - }); -}); - -describe.skip("Page.BackAction", () => { - beforeAll(() => { - jest.spyOn(history, "back").mockImplementation(); - }); - - afterAll(() => { - history.back.mockRestore(); - }); - - it("renders a 'Back' button with large size and secondary style", () => { - plainRender(); - const button = screen.getByRole("button", { name: "Back" }); - expect(button.classList.contains("pf-m-display-lg")).toBe(true); - expect(button.classList.contains("pf-m-secondary")).toBe(true); - }); - - it("triggers history.back() when user clicks on it", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: "Back" }); - await user.click(button); - expect(history.back).toHaveBeenCalled(); - }); -}); diff --git a/web/src/components/core/Page.test.tsx b/web/src/components/core/Page.test.tsx new file mode 100644 index 0000000000..8b4af016c7 --- /dev/null +++ b/web/src/components/core/Page.test.tsx @@ -0,0 +1,219 @@ +/* + * Copyright (c) [2023-2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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, within } from "@testing-library/react"; +import { plainRender, mockNavigateFn } from "~/test-utils"; +import { Page } from "~/components/core"; +import { _ } from "~/i18n"; + +let consoleErrorSpy: jest.SpyInstance; + +describe("Page", () => { + beforeAll(() => { + consoleErrorSpy = jest.spyOn(console, "error"); + consoleErrorSpy.mockImplementation(); + }); + + afterAll(() => { + consoleErrorSpy.mockRestore(); + }); + + it("renders given children", () => { + plainRender( + +

{_("The Page Component")}

+
, + ); + screen.getByRole("heading", { name: "The Page Component" }); + }); + + describe("Page.Actions", () => { + it("renders a footer sticky to bottom", () => { + plainRender( + + Save + Discard + , + ); + + const footer = screen.getByRole("contentinfo"); + expect(footer.classList.contains("pf-m-sticky-bottom")).toBe(true); + }); + }); + + describe("Page.Action", () => { + it("renders a button with given content", () => { + plainRender(Save); + screen.getByRole("button", { name: "Save" }); + }); + + it("renders an 'lg' button when size prop is not given", () => { + plainRender(Cancel); + const button = screen.getByRole("button", { name: "Cancel" }); + expect(button.classList.contains("pf-m-display-lg")).toBe(true); + }); + + describe("when user clicks on it", () => { + it("triggers given onClick handler, if valid", async () => { + const onClick = jest.fn(); + const { user } = plainRender(Cancel); + const button = screen.getByRole("button", { name: "Cancel" }); + await user.click(button); + expect(onClick).toHaveBeenCalled(); + }); + + it("navigates to the path given through 'navigateTo' prop", async () => { + const { user } = plainRender(Cancel); + const button = screen.getByRole("button", { name: "Cancel" }); + await user.click(button); + expect(mockNavigateFn).toHaveBeenCalledWith("/somewhere"); + }); + }); + }); + + describe("Page.Content", () => { + it("renders a node that fills all the available space", () => { + plainRender({_("The Content")}); + const content = screen.getByText("The Content"); + expect(content.classList.contains("pf-m-fill")).toBe(true); + }); + }); + + describe("Page.Cancel", () => { + it("renders a 'Cancel' button that navigates to the top level route by default", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "Cancel" }); + await user.click(button); + expect(mockNavigateFn).toHaveBeenCalledWith(".."); + }); + }); + + describe("Page.Back", () => { + it("renders a button for navigating back when user clicks on it", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "Back" }); + await user.click(button); + expect(mockNavigateFn).toHaveBeenCalledWith(-1); + }); + + it("uses `lg` size and `link` variant by default", () => { + plainRender(); + const button = screen.getByRole("button", { name: "Back" }); + expect(button.classList.contains("pf-m-link")).toBe(true); + expect(button.classList.contains("pf-m-display-lg")).toBe(true); + }); + }); + + describe("Page.Submit", () => { + it("triggers both, form submission of its associated form and onClick handler if given", async () => { + const onClick = jest.fn(); + // NOTE: using preventDefault here to avoid a jsdom error + // Error: Not implemented: HTMLFormElement.prototype.requestSubmit + const onSubmit = jest.fn((e) => { + e.preventDefault(); + }); + + const { user } = plainRender( + <> + + + Send + + , + ); + const button = screen.getByRole("button", { name: "Send" }); + await user.click(button); + expect(onSubmit).toHaveBeenCalled(); + expect(onClick).toHaveBeenCalled(); + }); + }); + describe("Page.Header", () => { + it("renders a node that sticks to top", () => { + 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); + }); + }); + + describe("Page.Section", () => { + it("outputs to console.error if both are missing, title and aria-label", () => { + plainRender({_("Content")}); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("must have either")); + }); + + it("renders a section node", () => { + 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")}, + ); + const section = screen.getByRole("region"); + expect(section).toHaveAttribute("aria-labelledby"); + + // aria-label is given through Page.Section props + rerender( + + {_("The Content")} + , + ); + expect(section).not.toHaveAttribute("aria-labelledby"); + + // aria-label is given through pfCardProps + rerender( + + {_("The Content")} + , + ); + expect(section).not.toHaveAttribute("aria-labelledby"); + + // None was given, title nor aria-label + rerender({_("The Content")}); + expect(section).not.toHaveAttribute("aria-labelledby"); + }); + + it("renders given content props (title, value, description, actions, and children (content)", () => { + plainRender( + {_("Disable")}} + > + {_("The Content")} + , + ); + const section = screen.getByRole("region"); + within(section).getByText("A section"); + within(section).getByText("Enabled"); + within(section).getByText( + "Testing section with title, value, description, content, and actions", + ); + within(section).getByText("The Content"); + within(section).getByRole("button", { name: "Disable" }); + }); + }); +}); diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index 03d165ecd7..4e736c911b 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -19,33 +19,195 @@ * find current contact information at www.suse.com. */ -import React from "react"; -import { NavLink, useNavigate } from "react-router-dom"; +import React, { useId } from "react"; import { Button, ButtonProps, Card, CardBody, + CardBodyProps, + CardFooter, CardHeader, + CardHeaderProps, CardProps, - Flex, PageGroup, + PageGroupProps, PageSection, + PageSectionProps, + Split, Stack, + TitleProps, } from "@patternfly/react-core"; +import { Flex } from "~/components/layout"; import { _ } from "~/i18n"; -import tabsStyles from "@patternfly/react-styles/css/components/Tabs/tabs"; +import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import flexStyles from "@patternfly/react-styles/css/utilities/Flex/flex"; +import { To, useNavigate } from "react-router-dom"; +import { isEmpty, isObject } from "~/utils"; -type PageActionProps = { navigateTo?: string } & ButtonProps; -type PageCancelActionProps = { text?: string } & PageActionProps; +/** + * Props accepted by Page.Section + */ +type SectionProps = { + /** The section title */ + title?: string; + /** The value used for accessible label */ + "aria-label"?: string; + /** Part of the header that complements the title as a representation of the + * section state. E.g. "Encryption enabled", where "Encryption" is the title + * and "enabled" the value */ + value?: React.ReactNode; + /** Elements to be rendered in the section footer */ + actions?: React.ReactNode; + /** As short as possible yet as much as needed text for describing what the section is about, if needed */ + description?: string; + /** The heading level used for the section title */ + headerLevel?: TitleProps["headingLevel"]; + /** Props to influence PF/Card component wrapping the section */ + pfCardProps?: CardProps; + /** Props to influence PF/CardHeader component wrapping the section title */ + pfCardHeaderProps?: CardHeaderProps; + /** Props to influence PF/CardBody component wrapping the section content */ + pfCardBodyProps?: CardBodyProps; +}; + +type ActionProps = { + /** Path to navigate to */ + navigateTo?: To; +} & ButtonProps; + +type SubmitActionProps = { + /** The id of a the submit button is associated with */ + form: string; +} & ButtonProps; + +const defaultCardProps: CardProps = { + isRounded: true, + isCompact: true, + isFullHeight: true, + component: "section", +}; + +const STICK_TO_TOP = Object.freeze({ default: "top" }); +const STICK_TO_BOTTOM = Object.freeze({ default: "bottom" }); + +// TODO: check if it should have the banner role +const Header = ({ hasGutter = true, children, ...props }) => { + return ( + + {children} + + ); +}; + +/** + * Creates a page region on top of PF/Card component + * + * @example Simple usage + * + * + * + * @example Complex usage + * : } + * > + * + * )} + * + */ +const Section = ({ + title, + "aria-label": ariaLabel, + value, + description, + actions, + headerLevel: Title = "h3", + pfCardProps, + pfCardHeaderProps, + pfCardBodyProps, + children, +}: React.PropsWithChildren) => { + const titleId = useId(); + const hasTitle = !isEmpty(title); + const hasValue = !isEmpty(value); + const hasDescription = !isEmpty(description); + const hasHeader = hasTitle || hasValue; + const hasAriaLabel = + !isEmpty(ariaLabel) || (isObject(pfCardProps) && "aria-label" in pfCardProps); + const props = { ...defaultCardProps, "aria-label": ariaLabel }; + + if (!hasTitle && !hasAriaLabel) { + console.error("Page.Section must have either, a title or aria-label"); + } + + if (hasTitle && !hasAriaLabel) props["aria-labelledby"] = titleId; + + return ( + + {hasHeader && ( + + + + {hasTitle && {title}} + {hasValue && ( + + {value} + + )} + + {hasDescription &&
{description}
} +
+
+ )} + {children} + {actions && ( + + {actions} + + )} +
+ ); +}; + +/** + * Wraps given children in an PF/PageGroup sticky at the bottom + * + * TODO: check if it contentinfo role really should have the banner role + * + * @see [PatternFly Page/PageGroup](https://www.patternfly.org/components/page#pagegroup) + * + * @example + * + * Let's go + * + * + */ +const Actions = ({ children }: React.PropsWithChildren) => { + return ( + + + {children} + + + ); +}; /** - * A convenient component for rendering a page action + * Handy component built on top of PF/Button for rendering a page action * - * Built on top of {@link https://www.patternfly.org/components/button | PF/Button} + * @see [PatternFly Button](https://www.patternfly.org/components/button). */ -const Action = ({ navigateTo, children, ...props }: PageActionProps) => { +const Action = ({ navigateTo, children, ...props }: ActionProps) => { const navigate = useNavigate(); const onClickFn = props.onClick; @@ -55,116 +217,116 @@ const Action = ({ navigateTo, children, ...props }: PageActionProps) => { if (navigateTo) navigate(navigateTo); }; - const buttonProps = { size: "lg" as const, ...props }; - return ; + return ( + + ); }; /** - * Convenient component for a Cancel / Back action + * Handy component for rendering a "Cancel" action + * + * NOTE: by default it navigates to the top path, which can be changed + * `navigateTo` prop BUT not for navigating back into the history. Use Page.Back + * for the latest, which behaves differently. */ -const CancelAction = ({ - text = _("Cancel"), - navigateTo = "..", - ...props -}: PageCancelActionProps) => { +const Cancel = ({ navigateTo = "..", children, ...props }: ActionProps) => { return ( - {text} + {children || _("Cancel")} ); }; /** - * Wrapper component built on top of PF/PageSection for holding the Page actions + * Handy component for rendering a "Back" action + * + * NOTE: It does not behave like Page.Cancel, since + * * does not support changing the path to navigate to, and + * * always goes one path back in the history (-1) * - * Required for placing content to be used as Page actions, usually a - * Page.Action or a PF/Button + * NOTE: Not using Page.Cancel for practical reasons about useNavigate + * overloading, which kind of forces to write an ugly code for supporting both + * types, "To" and "number", without a TypeScript complain. To know more, see + * https://github.com/remix-run/react-router/issues/10505#issuecomment-2237126223 */ -const Actions = ({ children }: React.PropsWithChildren) => ( - - {children} - -); - -const MainContent = ({ children, ...props }) => ( - - {children} - -); - -const Navigation = ({ routes }) => { - if (!Array.isArray(routes) || routes.length === 0) return; +const Back = ({ children, ...props }: Omit) => { + const navigate = useNavigate(); - // FIXME: routes should have a "subnavigation" flag to decide if should be - // rendered here. For example, Storage/iSCSI, Storage/DASD and so on might be - // not part of this navigation but part of an expandable menu. - // - // FIXME: extract to a component since using PF/Tab is not possible to achieve - // it because the tabs needs a content. As a reference, see https://github.com/patternfly/patternfly-org/blob/b2dbe716096e05cc68d3c85ada692e6140b4e992/packages/documentation-framework/templates/mdx.js#L304-L323 return ( - - - + ); }; -const Header = ({ hasGutter = true, children, ...props }) => { +/** + * Handy component to submit a form matching the id given in the `form` prop + */ +const Submit = ({ children, ...props }: SubmitActionProps) => { return ( - - {children} - + + {children || _("Accept")} + ); }; -const CardSection = ({ title, children, ...props }: CardProps & { title?: string }) => { - return ( - - {title && {title} } - {children && {children}} - - ); -}; +/** + * Wrapper for the section content built on top of PF/Page/PageSection + * + * @see [Patternfly Page/PageSection](https://www.patternfly.org/components/page#pagesection) + */ +const Content = ({ children, ...pageSectionProps }: React.PropsWithChildren) => ( + + {children} + +); /** - * Wraps children in a PF/PageGroup + * Component for structuing an Agama page, built on top of PF/Page/PageGroup. * - * @example Simple usage + * @see [Patternfly Page/PageGroup](https://www.patternfly.org/components/page#pagegroup) + * + * @example * - * + * + *

{_("Software")}

+ *
+ * + * + * + * + * + * + * {patterns.length === 0 ? : } + * + * + * + * + * + * + * + * + * + * *
*/ -const Page = ({ children }) => { - return {children}; +const Page = ({ + children, + ...pageGroupProps +}: React.PropsWithChildren): React.ReactNode => { + return {children}; }; -Page.CardSection = CardSection; -Page.NextActions = Actions; -Page.Action = Action; -Page.MainContent = MainContent; -Page.CancelAction = CancelAction; +Page.displayName = "agama/core/Page"; Page.Header = Header; +Page.Content = Content; +Page.Actions = Actions; +Page.Back = Back; +Page.Cancel = Cancel; +Page.Submit = Submit; +Page.Action = Action; +Page.Section = Section; export default Page; diff --git a/web/src/components/core/PasswordAndConfirmationInput.test.jsx b/web/src/components/core/PasswordAndConfirmationInput.test.tsx similarity index 94% rename from web/src/components/core/PasswordAndConfirmationInput.test.jsx rename to web/src/components/core/PasswordAndConfirmationInput.test.tsx index f0fae37b3f..6f43ab3700 100644 --- a/web/src/components/core/PasswordAndConfirmationInput.test.jsx +++ b/web/src/components/core/PasswordAndConfirmationInput.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -38,8 +38,8 @@ describe("when the passwords do not match", () => { it("uses the given password value for confirmation too", async () => { plainRender(); - const passwordInput = screen.getByLabelText("Password"); - const confirmationInput = screen.getByLabelText("Password confirmation"); + const passwordInput = screen.getByLabelText("Password") as HTMLInputElement; + const confirmationInput = screen.getByLabelText("Password confirmation") as HTMLInputElement; expect(passwordInput.value).toEqual("12345"); expect(passwordInput.value).toEqual(confirmationInput.value); diff --git a/web/src/components/core/PasswordAndConfirmationInput.jsx b/web/src/components/core/PasswordAndConfirmationInput.tsx similarity index 75% rename from web/src/components/core/PasswordAndConfirmationInput.jsx rename to web/src/components/core/PasswordAndConfirmationInput.tsx index f23680ba0c..afcb448659 100644 --- a/web/src/components/core/PasswordAndConfirmationInput.jsx +++ b/web/src/components/core/PasswordAndConfirmationInput.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -19,16 +19,25 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React, { useEffect, useState } from "react"; import { FormGroup } from "@patternfly/react-core"; import { FormValidationError, PasswordInput } from "~/components/core"; import { _ } from "~/i18n"; -// TODO: improve the component to allow working only in uncontrlled mode if -// needed. -// TODO: improve the showErrors thingy +// TODO: +// * add documentation, +// * allow working only in uncontrolled mode if needed, and +// * improve the showErrors thingy + +type PasswordAndConfirmationInputProps = { + inputRef?: React.RefObject; + value?: string; + showErrors?: boolean; + isDisabled?: boolean; + onChange?: (e: React.SyntheticEvent, v: string) => void; + onValidation?: (r: boolean) => void; +}; + const PasswordAndConfirmationInput = ({ inputRef, showErrors = true, @@ -36,17 +45,17 @@ const PasswordAndConfirmationInput = ({ onChange, onValidation, isDisabled = false, -}) => { +}: PasswordAndConfirmationInputProps) => { const passwordInput = inputRef?.current; - const [password, setPassword] = useState(value || ""); - const [confirmation, setConfirmation] = useState(value || ""); - const [error, setError] = useState(""); + const [password, setPassword] = useState(value || ""); + const [confirmation, setConfirmation] = useState(value || ""); + const [error, setError] = useState(""); useEffect(() => { if (isDisabled) setError(""); }, [isDisabled]); - const validate = (password, passwordConfirmation) => { + const validate = (password: string, passwordConfirmation: string) => { let newError = ""; showErrors && setError(newError); passwordInput?.setCustomValidity(newError); @@ -63,13 +72,13 @@ const PasswordAndConfirmationInput = ({ } }; - const onValueChange = (event, value) => { + const onValueChange = (event: React.SyntheticEvent, value: string) => { setPassword(value); validate(value, confirmation); if (typeof onChange === "function") onChange(event, value); }; - const onConfirmationChange = (_, confirmationValue) => { + const onConfirmationChange = (_: React.SyntheticEvent, confirmationValue: string) => { setConfirmation(confirmationValue); validate(password, confirmationValue); }; diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index d755b19ce4..e602a4b8f6 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -50,7 +50,6 @@ export { default as PasswordInput } from "./PasswordInput"; export { default as ServerError } from "./ServerError"; export { default as ExpandableSelector } from "./ExpandableSelector"; export { default as TreeTable } from "./TreeTable"; -export { default as CardField } from "./CardField"; export { default as Link } from "./Link"; export { default as EmptyState } from "./EmptyState"; export { default as InstallerOptions } from "./InstallerOptions"; diff --git a/web/src/components/l10n/KeyboardSelection.test.jsx b/web/src/components/l10n/KeyboardSelection.test.tsx similarity index 100% rename from web/src/components/l10n/KeyboardSelection.test.jsx rename to web/src/components/l10n/KeyboardSelection.test.tsx diff --git a/web/src/components/l10n/KeyboardSelection.jsx b/web/src/components/l10n/KeyboardSelection.tsx similarity index 85% rename from web/src/components/l10n/KeyboardSelection.jsx rename to web/src/components/l10n/KeyboardSelection.tsx index 9fd97137af..a69ac5ae26 100644 --- a/web/src/components/l10n/KeyboardSelection.jsx +++ b/web/src/components/l10n/KeyboardSelection.tsx @@ -27,7 +27,7 @@ import { _ } from "~/i18n"; import { useConfigMutation, useL10n } from "~/queries/l10n"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; -// TODO: Add documentation and typechecking +// TODO: Add documentation // TODO: Evaluate if worth it extracting the selector export default function KeyboardSelection() { const navigate = useNavigate(); @@ -40,7 +40,7 @@ export default function KeyboardSelection() { const searchHelp = _("Filter by description or keymap code"); - const onSubmit = async (e) => { + const onSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); setConfig.mutate({ keymap: selected }); navigate(-1); @@ -68,7 +68,7 @@ export default function KeyboardSelection() { }); if (keymapsList.length === 0) { - keymapsList = {_("None of the keymaps match the filter.")}; + keymapsList = [{_("None of the keymaps match the filter.")}]; } return ( @@ -77,19 +77,19 @@ export default function KeyboardSelection() {

{_("Keyboard selection")}

- - + + + {keymapsList} - - - - - - {_("Select")} - - +
+ + + + + {_("Select")} +
); } diff --git a/web/src/components/l10n/L10nPage.jsx b/web/src/components/l10n/L10nPage.jsx index 8e01bffbfc..e11b261a8a 100644 --- a/web/src/components/l10n/L10nPage.jsx +++ b/web/src/components/l10n/L10nPage.jsx @@ -21,20 +21,13 @@ import React from "react"; import { Gallery, GalleryItem } from "@patternfly/react-core"; -import { Link, CardField, Page } from "~/components/core"; +import { Link, Page } from "~/components/core"; import { PATHS } from "~/routes/l10n"; import { _ } from "~/i18n"; import { useL10n } from "~/queries/l10n"; -const Section = ({ label, value, children }) => { - return ( - - {children} - - ); -}; - // FIXME: re-evaluate the need of "Thing not selected yet" + /** * Page for configuring localization. * @component @@ -48,39 +41,42 @@ export default function L10nPage() {

{_("Localization")}

- + -
{locale ? _("Change") : _("Select")} -
+
-
+ {keymap ? _("Change") : _("Select")} -
+
-
{timezone ? _("Change") : _("Select")} -
+
-
+
); } diff --git a/web/src/components/l10n/LocaleSelection.test.jsx b/web/src/components/l10n/LocaleSelection.test.tsx similarity index 100% rename from web/src/components/l10n/LocaleSelection.test.jsx rename to web/src/components/l10n/LocaleSelection.test.tsx diff --git a/web/src/components/l10n/LocaleSelection.jsx b/web/src/components/l10n/LocaleSelection.tsx similarity index 86% rename from web/src/components/l10n/LocaleSelection.jsx rename to web/src/components/l10n/LocaleSelection.tsx index 75ed98f6d2..09403f59ec 100644 --- a/web/src/components/l10n/LocaleSelection.jsx +++ b/web/src/components/l10n/LocaleSelection.tsx @@ -27,7 +27,7 @@ import { _ } from "~/i18n"; import { useConfigMutation, useL10n } from "~/queries/l10n"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; -// TODO: Add documentation and typechecking +// TODO: Add documentation // TODO: Evaluate if worth it extracting the selector export default function LocaleSelection() { const navigate = useNavigate(); @@ -38,7 +38,7 @@ export default function LocaleSelection() { const searchHelp = _("Filter by language, territory or locale code"); - const onSubmit = async (e) => { + const onSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); setConfig.mutate({ locales: [selected] }); navigate(-1); @@ -69,7 +69,7 @@ export default function LocaleSelection() { }); if (localesList.length === 0) { - localesList = {_("None of the locales match the filter.")}; + localesList = [{_("None of the locales match the filter.")}]; } return ( @@ -79,19 +79,18 @@ export default function LocaleSelection() { - - + +
{localesList}
-
-
- - - - {_("Select")} - - + + + + + + {_("Select")} +
); } diff --git a/web/src/components/l10n/TimezoneSelection.test.jsx b/web/src/components/l10n/TimezoneSelection.test.tsx similarity index 100% rename from web/src/components/l10n/TimezoneSelection.test.jsx rename to web/src/components/l10n/TimezoneSelection.test.tsx diff --git a/web/src/components/l10n/TimezoneSelection.jsx b/web/src/components/l10n/TimezoneSelection.tsx similarity index 63% rename from web/src/components/l10n/TimezoneSelection.jsx rename to web/src/components/l10n/TimezoneSelection.tsx index 56bc6a525d..f676491587 100644 --- a/web/src/components/l10n/TimezoneSelection.jsx +++ b/web/src/components/l10n/TimezoneSelection.tsx @@ -23,14 +23,17 @@ import React, { useState } from "react"; import { Divider, Flex, Form, FormGroup, Radio, Text } from "@patternfly/react-core"; import { useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; -import { _ } from "~/i18n"; import { timezoneTime } from "~/utils"; import { useConfigMutation, useL10n } from "~/queries/l10n"; +import { Timezone } from "~/types/l10n"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; +import { _ } from "~/i18n"; -let date; +type TimezoneWithDetails = Timezone & { details: string }; -const timezoneWithDetails = (timezone) => { +let date: Date; + +const timezoneWithDetails = (timezone: Timezone): TimezoneWithDetails => { const offset = timezone.utcOffset; if (offset === undefined) return { ...timezone, details: timezone.id }; @@ -42,14 +45,14 @@ const timezoneWithDetails = (timezone) => { return { ...timezone, details: `${timezone.id} ${utc}` }; }; -const sortedTimezones = (timezones) => { +const sortedTimezones = (timezones: Timezone[]) => { return timezones.sort((timezone1, timezone2) => { - const timezoneText = (t) => t.parts.join("").toLowerCase(); + const timezoneText = (t: Timezone) => t.parts.join("").toLowerCase(); return timezoneText(timezone1) > timezoneText(timezone2) ? 1 : -1; }); }; -// TODO: Add documentation and typechecking +// TODO: Add documentation // TODO: Evaluate if worth it extracting the selector // TODO: Refactor timezones/extendedTimezones thingy export default function TimezoneSelection() { @@ -63,42 +66,44 @@ export default function TimezoneSelection() { const searchHelp = _("Filter by territory, time zone code or UTC offset"); - const onSubmit = async (e) => { + const onSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); setConfig.mutate({ timezone: selected }); navigate(-1); }; - let timezonesList = filteredTimezones.map(({ id, country, details, parts }) => { - return ( - setSelected(id)} - label={ - <> - - {parts.join("-")} - {" "} - {country} - - } - description={ - - {timezoneTime(id, { date }) || ""} - -
{details}
-
- } - value={id} - isChecked={id === selected} - /> - ); - }); + let timezonesList = filteredTimezones.map( + ({ id, country, details, parts }: TimezoneWithDetails) => { + return ( + setSelected(id)} + label={ + <> + + {parts.join("-")} + {" "} + {country} + + } + description={ + + {timezoneTime(id, { date }) || ""} + +
{details}
+
+ } + value={id} + isChecked={id === selected} + /> + ); + }, + ); if (timezonesList.length === 0) { - timezonesList = {_("None of the time zones match the filter.")}; + timezonesList = [{_("None of the time zones match the filter.")}]; } return ( @@ -112,19 +117,18 @@ export default function TimezoneSelection() { /> - - + +
{timezonesList}
-
-
- - - - {_("Select")} - - + + + + + + {_("Select")} +
); } diff --git a/web/src/components/layout/Flex.tsx b/web/src/components/layout/Flex.tsx new file mode 100644 index 0000000000..e8fb988e31 --- /dev/null +++ b/web/src/components/layout/Flex.tsx @@ -0,0 +1,153 @@ +/* + * 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 version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 { Flex as PFFlex, FlexProps, FlexItem, FlexItemProps } from "@patternfly/react-core"; + +/** + * NOTE: below code for dealing with PF/Flex types and extract the "responsive props" is a bit + * complex but useful for building a wrapper around such a component without the risk of getting it + * silently broken if PF/Flex changes these props by deleting them or adding new ones. + * + * For sure, would be better to add these responsive props shortcuts direclty in PF/Flex to allow + * the consumer to just set the `default` value when not needed to change it depending on + * the breakpoint. But at this moment we're a bit short of time for creating and testing such + * an elaborated PR against upstream. + * + * BTW, the lines for extracting an object from the type were borrowed from + * https://dev.to/scooperdev/generate-array-of-all-an-interfaces-keys-with-typescript-4hbf + */ + +// NOTE: PF/Flex#order prop is missing "sm" breakpoint +// NOTE: The omitted props match the extends constraint because they are typed +// as "any", see https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react/index.d.ts#L2923-L3001 +// (FlexProps interface extends React.HTMLProps) +type ResponsiveFlexProps = { + [Key in keyof Omit as FlexProps[Key] extends { + default?: unknown; + } + ? Key + : // @ts-ignore + never]: FlexProps[Key]["default"] | FlexProps[Key]; +}; +type ResponsiveProps = Record; + +// Creates an object based on the type for being able to have the keys at runtime +// @see #mappedProps to check its usage. +const responsiveProps: ResponsiveProps = { + gap: undefined, + grow: undefined, + spacer: undefined, + spaceItems: undefined, + rowGap: undefined, + columnGap: undefined, + flex: undefined, + direction: undefined, + alignItems: undefined, + alignContent: undefined, + alignSelf: undefined, + align: undefined, + justifyContent: undefined, + display: undefined, + fullWidth: undefined, + flexWrap: undefined, + order: undefined, + shrink: undefined, +}; + +const RESPONSIVE_FLEX_PROPS = Object.keys(responsiveProps); + +type ResponsiveFlexItemProps = { + [Key in keyof Omit as FlexItemProps[Key] extends { + default?: unknown; + } + ? Key + : // @ts-ignore + never]: FlexItemProps[Key]["default"] | FlexItemProps[Key]; +}; +type ResponsiveItemProps = Record; + +// Creates an object based on the type for being able to have the keys at runtime +// @see #mappedProps to check its usage. +const responsiveItemProps: ResponsiveItemProps = { + spacer: undefined, + grow: undefined, + shrink: undefined, + flex: undefined, + alignSelf: undefined, + align: undefined, + fullWidth: undefined, + order: undefined, +}; + +const RESPONSIVE_FLEX_ITEM_PROPS = Object.keys(responsiveItemProps); + +type AgamaFlexProps = FlexProps | ResponsiveFlexProps; +type AgamaFlexItemProps = FlexItemProps | ResponsiveFlexItemProps; + +/** + * Helper function for mapping found responsive props from `value` to `{ default: value }` + * + * @param props - collection of prop to be mapped + * @param responsivePropsKeys - keys of props that must be considered as responsive prop + */ +const mappedProps = ( + props: AgamaFlexProps | AgamaFlexItemProps, + responsiveProps: string[], +): FlexProps | FlexItemProps => + Object.keys(props).reduce((result, k) => { + const value = props[k]; + const needsMapping = responsiveProps.includes(k) && typeof value === "string"; + result[k] = needsMapping ? { default: value } : value; + return result; + }, {}); + +/** + * Wrapper around PatternFly/FlexItem that allows giving plain value to responsive props instead + * of an object when only interested in the value for the `default` key. I.e., it allows typing + * `grow="grow"` instead of `grow={{ default: "grow" }}` + * + * To know more see {@link https://www.patternfly.org/layouts/flex#flexitem | PF/FlexItem} + */ +const Item = (props: AgamaFlexItemProps) => ( + +); + +/** + * Wrapper around PatternFly/Flex that allows giving plain value to responsive props instead of an + * object when only interested in the value for the `default` key. I.e., it allows typing + * `columnGap="columnGapLg"` instead of `columnGap={{ default: "columnGapLg" }}` + * + * Additionally, it sets `alignItems={{ default: "alignItemsCenter" }}` by default. + * + * To know more see {@link https://www.patternfly.org/layouts/flex | PF/Flex} + */ +const Flex = (props: AgamaFlexProps): React.ReactNode => { + return ( + + ); +}; + +Flex.Item = Item; +export default Flex; diff --git a/web/src/components/layout/index.js b/web/src/components/layout/index.js index 3834bcc3fb..0fc69060fa 100644 --- a/web/src/components/layout/index.js +++ b/web/src/components/layout/index.js @@ -25,3 +25,4 @@ export { default as Loading } from "./Loading"; export { default as Sidebar } from "./Sidebar"; export { default as Header } from "./Header"; export { default as Main } from "./Main"; +export { default as Flex } from "./Flex"; diff --git a/web/src/components/network/IpSettingsForm.tsx b/web/src/components/network/IpSettingsForm.tsx index deb1afacfa..01f153796a 100644 --- a/web/src/components/network/IpSettingsForm.tsx +++ b/web/src/components/network/IpSettingsForm.tsx @@ -22,20 +22,19 @@ import React, { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { - HelperText, - HelperTextItem, Form, FormGroup, + FormHelperText, FormSelect, - FormSelectProps, FormSelectOption, + FormSelectProps, Grid, GridItem, - TextInput, + HelperText, + HelperTextItem, Stack, - FormHelperText, + TextInput, } from "@patternfly/react-core"; - import { Page } from "~/components/core"; import AddressesDataList from "~/components/network/AddressesDataList"; import DnsDataList from "~/components/network/DnsDataList"; @@ -46,6 +45,8 @@ import { IPAddress, Connection, ConnectionMethod } from "~/types/network"; const usingDHCP = (method: ConnectionMethod) => method === ConnectionMethod.AUTO; +// FIXME: rename to connedtioneditpage or so? +// FIXME: improve the layout a bit. export default function IpSettingsForm() { const { id } = useParams(); const navigate = useNavigate(); @@ -144,12 +145,13 @@ export default function IpSettingsForm() {

{sprintf(_("Edit connection %s"), connection.id)}

- + + {renderError("object")}
- + - + - + - + - + - +
-
- - - - - {_("Accept")} - - + + + + + +
); } diff --git a/web/src/components/network/NetworkPage.tsx b/web/src/components/network/NetworkPage.tsx index f1b7b884a8..808177b39a 100644 --- a/web/src/components/network/NetworkPage.tsx +++ b/web/src/components/network/NetworkPage.tsx @@ -20,8 +20,8 @@ */ import React from "react"; -import { CardBody, Grid, GridItem } from "@patternfly/react-core"; -import { Link, CardField, EmptyState, Page } from "~/components/core"; +import { Grid, GridItem } from "@patternfly/react-core"; +import { Link, EmptyState, Page } from "~/components/core"; import ConnectionsTable from "~/components/network/ConnectionsTable"; import { _ } from "~/i18n"; import { connectionAddresses } from "~/utils/network"; @@ -29,65 +29,66 @@ import { sprintf } from "sprintf-js"; import { useNetwork, useNetworkConfigChanges } from "~/queries/network"; import { PATHS } from "~/routes/network"; import { partition } from "~/utils"; +import { Connection, Device } from "~/types/network"; const WiredConnections = ({ connections, devices }) => { - const total = connections.length; + const wiredConnections = connections.length; + + const sectionProps = wiredConnections > 0 ? { title: _("Wired") } : {}; return ( - 0 && _("Wired")}> - - {total === 0 ? ( - - ) : ( - - )} - - + + {wiredConnections > 0 ? ( + + ) : ( + + )} + ); }; const WifiConnections = ({ connections, devices }) => { - const activeWifiDevice = devices.find((d) => d.type === "wireless" && d.state === "activated"); - const activeConnection = connections.find((c) => c.id === activeWifiDevice?.connection); + const activeWifiDevice = devices.find( + (d: Device) => d.type === "wireless" && d.state === "activated", + ); + const activeConnection = connections.find( + (c: Connection) => c.id === activeWifiDevice?.connection, + ); return ( - {activeConnection ? _("Change") : _("Connect")} } > - - {activeConnection ? ( - - {connectionAddresses(activeConnection, devices)} - - ) : ( - - {_("The system has not been configured for connecting to a Wi-Fi network yet.")} - - )} - - + {activeConnection ? ( + + {connectionAddresses(activeConnection, devices)} + + ) : ( + + {_("The system has not been configured for connecting to a Wi-Fi network yet.")} + + )} + ); }; const NoWifiAvailable = () => ( - - - - {_( - "The system does not support Wi-Fi connections, probably because of missing or disabled hardware.", - )} - - - + + + {_( + "The system does not support Wi-Fi connections, probably because of missing or disabled hardware.", + )} + + ); /** @@ -104,7 +105,7 @@ export default function NetworkPage() {

{_("Network")}

- + @@ -117,7 +118,7 @@ export default function NetworkPage() { )} - +
); } diff --git a/web/src/components/network/WifiSelectorPage.tsx b/web/src/components/network/WifiSelectorPage.tsx index e6bbbdfb39..1aacb76ee4 100644 --- a/web/src/components/network/WifiSelectorPage.tsx +++ b/web/src/components/network/WifiSelectorPage.tsx @@ -34,17 +34,18 @@ function WifiSelectorPage() {

{_("Connect to a Wi-Fi network")}

- + + - + - - - + + +
); } diff --git a/web/src/components/overview/OverviewPage.test.jsx b/web/src/components/overview/OverviewPage.test.tsx similarity index 80% rename from web/src/components/overview/OverviewPage.test.jsx rename to web/src/components/overview/OverviewPage.test.tsx index c4463bd3df..f0c6692f75 100644 --- a/web/src/components/overview/OverviewPage.test.jsx +++ b/web/src/components/overview/OverviewPage.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -22,18 +22,22 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { createClient } from "~/client"; import { OverviewPage } from "~/components/overview"; import { IssuesList } from "~/types/issues"; +import { Product } from "~/types/software"; + +const tumbleweed: Product = { + id: "Tumbleweed", + name: "openSUSE Tumbleweed", + icon: "tumbleweed.svg", + description: "Tumbleweed description...", +}; -const startInstallationFn = jest.fn(); -let mockSelectedProduct = { id: "Tumbleweed" }; const mockIssuesList = new IssuesList([], [], [], []); -jest.mock("~/client"); jest.mock("~/queries/software", () => ({ ...jest.requireActual("~/queries/software"), - useProduct: () => ({ selectedProduct: mockSelectedProduct }), + useProduct: () => ({ selectedProduct: tumbleweed }), useProductChanges: () => jest.fn(), })); @@ -47,21 +51,7 @@ jest.mock("~/components/overview/StorageSection", () => () =>
Storage Secti jest.mock("~/components/overview/SoftwareSection", () => () =>
Software Section
); jest.mock("~/components/core/InstallButton", () => () =>
Install Button
); -beforeEach(() => { - createClient.mockImplementation(() => { - return { - manager: { - startInstallation: startInstallationFn, - }, - }; - }); -}); - describe("when a product is selected", () => { - beforeEach(() => { - mockSelectedProduct = { name: "Tumbleweed" }; - }); - it("renders the overview page content and the Install button", async () => { installerRender(); screen.findByText("Localization Section"); diff --git a/web/src/components/overview/OverviewPage.jsx b/web/src/components/overview/OverviewPage.tsx similarity index 73% rename from web/src/components/overview/OverviewPage.jsx rename to web/src/components/overview/OverviewPage.tsx index 7363eb58fd..206dfa689e 100644 --- a/web/src/components/overview/OverviewPage.jsx +++ b/web/src/components/overview/OverviewPage.tsx @@ -19,9 +19,8 @@ * find current contact information at www.suse.com. */ -import React, { useEffect, useState } from "react"; +import React from "react"; import { - CardBody, Grid, GridItem, Hint, @@ -34,16 +33,15 @@ import { NotificationDrawerListItemHeader, Stack, } from "@patternfly/react-core"; -import { useInstallerClient } from "~/context/installer"; import { Link } from "react-router-dom"; import { Center } from "~/components/layout"; -import { CardField, EmptyState, Page, InstallButton } from "~/components/core"; +import { EmptyState, InstallButton, Page } from "~/components/core"; import L10nSection from "./L10nSection"; import StorageSection from "./StorageSection"; import SoftwareSection from "./SoftwareSection"; import { _ } from "~/i18n"; import { useAllIssues } from "~/queries/issues"; -import { IssueSeverity } from "~/types/issues"; +import { IssuesList as IssuesListType, IssueSeverity } from "~/types/issues"; const SCOPE_HEADERS = { users: _("Users"), @@ -59,9 +57,8 @@ const ReadyForInstallation = () => ( ); -// FIXME: improve -const IssuesList = ({ issues }) => { - const { isEmpty, issues: issuesByScope } = issues; +const IssuesList = ({ issues }: { issues: IssuesListType }) => { + const { issues: issuesByScope } = issues; const list = []; Object.entries(issuesByScope).forEach(([scope, issues], idx) => { issues.forEach((issue, subIdx) => { @@ -92,20 +89,42 @@ const IssuesList = ({ issues }) => { ); }; -export default function OverviewPage() { - const client = useInstallerClient(); +const ResultSection = () => { const issues = useAllIssues(); const resultSectionProps = issues.isEmpty ? {} : { - label: _("Installation"), + title: _("Installation"), description: _("Before installing, please check the following problems."), }; + return ( + + {issues.isEmpty ? : } + + ); +}; + +const OverviewSection = () => ( + + + + + + + +); + +export default function OverviewPage() { return ( - + @@ -117,30 +136,13 @@ export default function OverviewPage() { - - - - - - - - - + - - - {issues.isEmpty ? : } - - + - + ); } diff --git a/web/src/components/product/ProductSelectionPage.test.tsx b/web/src/components/product/ProductSelectionPage.test.tsx index 704f2b064f..260df90c0e 100644 --- a/web/src/components/product/ProductSelectionPage.test.tsx +++ b/web/src/components/product/ProductSelectionPage.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender, mockNavigateFn } from "~/test-utils"; import { ProductSelectionPage } from "~/components/product"; -import { Product, } from "~/types/software"; +import { Product } from "~/types/software"; import { useProduct } from "~/queries/software"; const mockConfigMutation = jest.fn(); @@ -50,7 +50,7 @@ jest.mock("~/queries/software", () => ({ }; }, useProductChanges: () => jest.fn(), - useConfigMutation: () => ({ mutate: mockConfigMutation }) + useConfigMutation: () => ({ mutate: mockConfigMutation }), })); describe("when the user chooses a product and hits the confirmation button", () => { @@ -72,6 +72,6 @@ describe("when the user chooses a product but hits the cancel button", () => { await user.click(productOption); await user.click(cancelButton); expect(mockConfigMutation).not.toHaveBeenCalled(); - expect(mockNavigateFn).toHaveBeenCalledWith("-1"); + expect(mockNavigateFn).toHaveBeenCalledWith(-1); }); }); diff --git a/web/src/components/product/ProductSelectionPage.tsx b/web/src/components/product/ProductSelectionPage.tsx index 09000b10dc..02ccfffc2a 100644 --- a/web/src/components/product/ProductSelectionPage.tsx +++ b/web/src/components/product/ProductSelectionPage.tsx @@ -20,7 +20,20 @@ */ import React, { useState } from "react"; -import { Card, CardBody, Flex, Form, Grid, GridItem, Radio, List, ListItem, Split, Stack, FormGroup } from "@patternfly/react-core"; +import { + Card, + CardBody, + Flex, + Form, + Grid, + GridItem, + Radio, + List, + ListItem, + Split, + Stack, + FormGroup, +} from "@patternfly/react-core"; import { Page } from "~/components/core"; import { Center } from "~/components/layout"; import { useConfigMutation, useProduct } from "~/queries/software"; @@ -86,7 +99,7 @@ function ProductSelectionPage() { const isSelectionDisabled = !nextProduct || nextProduct === selectedProduct; return ( - +
@@ -106,21 +119,20 @@ function ProductSelectionPage() { - {selectedProduct && !isLoading && } - {_("Cancel")}} + {_("Select")} - +
-
+ ); } diff --git a/web/src/components/software/SoftwarePage.tsx b/web/src/components/software/SoftwarePage.tsx index 7ce34acf08..a7101d6ba8 100644 --- a/web/src/components/software/SoftwarePage.tsx +++ b/web/src/components/software/SoftwarePage.tsx @@ -21,7 +21,6 @@ import React from "react"; import { - CardBody, DescriptionList, DescriptionListDescription, DescriptionListGroup, @@ -30,10 +29,15 @@ import { GridItem, Stack, } from "@patternfly/react-core"; -import { Link, CardField, IssuesHint, Page } from "~/components/core"; +import { Link, IssuesHint, Page } from "~/components/core"; import UsedSize from "./UsedSize"; import { useIssues } from "~/queries/issues"; -import { usePatterns, useProposal, useProposalChanges } from "~/queries/software"; +import { + selectedProductQuery, + usePatterns, + useProposal, + useProposalChanges, +} from "~/queries/software"; import { Pattern, SelectedBy } from "~/types/software"; import { _ } from "~/i18n"; import { PATHS } from "~/routes/software"; @@ -64,30 +68,26 @@ const SelectedPatternsList = ({ patterns }: { patterns: Pattern[] }): React.Reac }; const SelectedPatterns = ({ patterns }): React.ReactNode => ( - {_("Change selection")} } > - - - - + + ); const NoPatterns = (): React.ReactNode => ( - - -

- {_( - "This product does not allow to select software patterns during installation. However, you can add additional software once the installation is finished.", - )} -

-
-
+ +

+ {_( + "This product does not allow to select software patterns during installation. However, you can add additional software once the installation is finished.", + )} +

+
); /** @@ -100,29 +100,33 @@ function SoftwarePage(): React.ReactNode { useProposalChanges(); + // Selected patterns section should fill the full width in big screen too when + // tehere is no information for rendering the Proposal Size section. + const selectedPatternsXlSize = proposal.size ? 6 : 12; + return (

{_("Software")}

- + - + {patterns.length === 0 ? : } - - - + {proposal.size && ( + + - - - + + + )} - +
); } diff --git a/web/src/components/software/SoftwarePatternsSelection.tsx b/web/src/components/software/SoftwarePatternsSelection.tsx index 2f5326d407..2d8be37a29 100644 --- a/web/src/components/software/SoftwarePatternsSelection.tsx +++ b/web/src/components/software/SoftwarePatternsSelection.tsx @@ -21,8 +21,6 @@ import React, { useState } from "react"; import { - Card, - CardBody, Label, DataList, DataListCell, @@ -185,29 +183,27 @@ function SoftwarePatternsSelection(): React.ReactNode { return ( - -

{_("Software selection")}

- setSearchValue(value)} - onClear={() => setSearchValue("")} - resultsCount={visiblePatterns.length} - /> -
+

{_("Software selection")}

+ setSearchValue(value)} + onClear={() => setSearchValue("")} + resultsCount={visiblePatterns.length} + />
- - - {selector.length > 0 ? selector : } - - + + + {selector.length > 0 ? {selector} : } + + - - {_("Close")} - + + {_("Close")} +
); } diff --git a/web/src/components/storage/BootSelection.tsx b/web/src/components/storage/BootSelection.tsx index 8c7806f580..760ec91166 100644 --- a/web/src/components/storage/BootSelection.tsx +++ b/web/src/components/storage/BootSelection.tsx @@ -19,19 +19,17 @@ * find current contact information at www.suse.com. */ -// @ts-check - -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Card, CardBody, Form, FormGroup, Radio, Stack } from "@patternfly/react-core"; -import { _ } from "~/i18n"; import { DevicesFormSelect } from "~/components/storage"; import { Page } from "~/components/core"; import { deviceLabel } from "~/components/storage/utils"; -import { sprintf } from "sprintf-js"; -import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import { StorageDevice } from "~/types/storage"; import { useAvailableDevices, useProposalMutation, useProposalResult } from "~/queries/storage"; +import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; +import { sprintf } from "sprintf-js"; +import { _ } from "~/i18n"; // FIXME: improve classNames // FIXME: improve and rename to BootSelectionDialog @@ -137,97 +135,92 @@ partitions in the appropriate disk.",

{_("Select booting partition")}

{description}

- + +
- - - - - {_("Automatic")} - - } - body={automaticText()} - /> - - {_("Select a disk")} - - } - body={ - -
- {_("Partitions to boot will be allocated at the following device.")} -
- -
- } - /> - - {_("Do not configure")} - - } - body={ -
- {_( - "No partitions will be automatically configured for booting. Use with caution.", - )} -
- } - /> -
-
-
+ + + + {_("Automatic")} + + } + body={automaticText()} + /> + + {_("Select a disk")} + + } + body={ + +
{_("Partitions to boot will be allocated at the following device.")}
+ +
+ } + /> + + {_("Do not configure")} + + } + body={ +
+ {_( + "No partitions will be automatically configured for booting. Use with caution.", + )} +
+ } + /> +
+
-
- - - - - {_("Accept")} - - + + + + + + ); } diff --git a/web/src/components/storage/DASDPage.tsx b/web/src/components/storage/DASDPage.tsx index 28a77e13aa..12c5195962 100644 --- a/web/src/components/storage/DASDPage.tsx +++ b/web/src/components/storage/DASDPage.tsx @@ -36,10 +36,10 @@ export default function DASDPage() {

{_("DASD")}

- + - + ); } diff --git a/web/src/components/storage/DASDTable.tsx b/web/src/components/storage/DASDTable.tsx index 54fd22fec8..308f96cd35 100644 --- a/web/src/components/storage/DASDTable.tsx +++ b/web/src/components/storage/DASDTable.tsx @@ -22,7 +22,6 @@ import React, { useState } from "react"; import { Button, - CardBody, Divider, Dropdown, DropdownItem, @@ -37,8 +36,8 @@ import { ToolbarItem, } from "@patternfly/react-core"; import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; +import { Page } from "~/components/core"; import { Icon } from "~/components/layout"; -import { CardField } from "~/components/core"; import { _ } from "~/i18n"; import { hex } from "~/utils"; import { sort } from "fast-sort"; @@ -268,67 +267,67 @@ export default function DASDTable() { }; return ( - - - - - - - - updateFilter({ minChannel })} - /> - {minChannel !== "" && ( - - - - )} - - - - - updateFilter({ maxChannel })} - /> - {maxChannel !== "" && ( - - - - )} - - + <> + + + + + + updateFilter({ minChannel })} + /> + {minChannel !== "" && ( + + + + )} + + + + + updateFilter({ maxChannel })} + /> + {maxChannel !== "" && ( + + + + )} + + - + - - - - - - + + + + + + + - - + + ); } diff --git a/web/src/components/storage/DeviceSelection.tsx b/web/src/components/storage/DeviceSelection.tsx index aff4e21ce2..4e3e952be7 100644 --- a/web/src/components/storage/DeviceSelection.tsx +++ b/web/src/components/storage/DeviceSelection.tsx @@ -21,26 +21,16 @@ import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { - Card, - CardBody, - Flex, - Form, - FormGroup, - PageSection, - Radio, - Stack, -} from "@patternfly/react-core"; -import a11y from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; - -import { _ } from "~/i18n"; -import { deviceChildren } from "~/components/storage/utils"; +import { Flex, Form, FormGroup, Radio, Stack } from "@patternfly/react-core"; import { Page } from "~/components/core"; import { DeviceSelectorTable } from "~/components/storage"; import DevicesTechMenu from "./DevicesTechMenu"; -import { compact } from "~/utils"; -import { useAvailableDevices, useProposalMutation, useProposalResult } from "~/queries/storage"; import { ProposalTarget, StorageDevice } from "~/types/storage"; +import { useAvailableDevices, useProposalMutation, useProposalResult } from "~/queries/storage"; +import { deviceChildren } from "~/components/storage/utils"; +import { compact } from "~/utils"; +import a11y from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; +import { _ } from "~/i18n"; const SELECT_DISK_ID = "select-disk"; const CREATE_LVM_ID = "create-lvm"; @@ -127,104 +117,96 @@ devices.", return ( - +

{_("Select installation device")}

-
- + + +
- - - - + + + + + + + + +
+ {msgStart1} + {msgBold1} + {msgEnd1} +
+ + - -
-
-
- - - - -
- {msgStart1} - {msgBold1} - {msgEnd1} -
- +
+ + +
+ {msgStart2} + {msgBold2} + {msgEnd2} +
+ +
- - - -
- {msgStart2} - {msgBold2} - {msgEnd2} -
- -
- -
-
- - - {_("Prepare more devices by configuring advanced")} - - - - - +
+
+ + + {_("Prepare more devices by configuring advanced")} + + +
+ -
- - - - - {_("Accept")} - - + + + + + +
); } diff --git a/web/src/components/storage/EncryptionField.tsx b/web/src/components/storage/EncryptionField.tsx index 7935006840..c0da0554bd 100644 --- a/web/src/components/storage/EncryptionField.tsx +++ b/web/src/components/storage/EncryptionField.tsx @@ -21,7 +21,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { Button, Skeleton } from "@patternfly/react-core"; -import { CardField } from "~/components/core"; +import { Page } from "~/components/core"; import EncryptionSettingsDialog, { EncryptionSetting, } from "~/components/storage/EncryptionSettingsDialog"; @@ -105,11 +105,11 @@ export default function EncryptionField({ }; return ( - } description={DESCRIPTION} - cardDescriptionProps={{ isFilled: true }} + pfCardBodyProps={{ isFilled: true }} actions={} > {isDialogOpen && ( @@ -123,6 +123,6 @@ export default function EncryptionField({ onAccept={onAccept} /> )} - + ); } diff --git a/web/src/components/storage/InstallationDeviceField.tsx b/web/src/components/storage/InstallationDeviceField.tsx index 90c6db4d28..f4b7f9ad79 100644 --- a/web/src/components/storage/InstallationDeviceField.tsx +++ b/web/src/components/storage/InstallationDeviceField.tsx @@ -19,16 +19,14 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React from "react"; import { Skeleton } from "@patternfly/react-core"; -import { Link, CardField } from "~/components/core"; -import { deviceLabel } from "~/components/storage/utils"; +import { Link, Page } from "~/components/core"; +import { ProposalTarget, StorageDevice } from "~/types/storage"; import { PATHS } from "~/routes/storage"; -import { _ } from "~/i18n"; +import { deviceLabel } from "~/components/storage/utils"; import { sprintf } from "sprintf-js"; -import { ProposalTarget, StorageDevice } from "~/types/storage"; +import { _ } from "~/i18n"; const LABEL = _("Installation device"); // TRANSLATORS: The storage "Installation device" field's description. @@ -96,8 +94,8 @@ export default function InstallationDeviceField({ else value = targetValue(target, targetDevice, targetPVDevices); return ( - - {value} - + {value} + ); } diff --git a/web/src/components/storage/PartitionsField.tsx b/web/src/components/storage/PartitionsField.tsx index 16c557f56e..dfaa507827 100644 --- a/web/src/components/storage/PartitionsField.tsx +++ b/web/src/components/storage/PartitionsField.tsx @@ -22,12 +22,11 @@ import React, { useState } from "react"; import { Button, - CardBody, CardExpandableContent, Divider, Dropdown, - DropdownList, DropdownItem, + DropdownList, Flex, List, ListItem, @@ -37,7 +36,7 @@ import { Stack, } from "@patternfly/react-core"; import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; -import { CardField, RowActions, Tip } from "~/components/core"; +import { Page, RowActions, Tip } from "~/components/core"; import { noop } from "~/utils"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; @@ -808,13 +807,13 @@ export default function PartitionsField({ const onExpand = () => setIsExpanded(!isExpanded); return ( - {!isExpanded && ( - - - + )} - - - + - + ); } diff --git a/web/src/components/storage/ProposalActionsSummary.tsx b/web/src/components/storage/ProposalActionsSummary.tsx index 54b32da429..0dd146eaab 100644 --- a/web/src/components/storage/ProposalActionsSummary.tsx +++ b/web/src/components/storage/ProposalActionsSummary.tsx @@ -21,7 +21,7 @@ import React from "react"; import { Button, Skeleton, Stack, List, ListItem } from "@patternfly/react-core"; -import { CardField, Link } from "~/components/core"; +import { Link, Page } from "~/components/core"; import DevicesManager from "~/components/storage/DevicesManager"; import { _, n_ } from "~/i18n"; import { sprintf } from "sprintf-js"; @@ -237,8 +237,8 @@ export default function ProposalActionsSummary({ const devicesManager = new DevicesManager(system, staging, actions); return ( - @@ -246,32 +246,30 @@ export default function ProposalActionsSummary({ {_("Change")} ) } - cardProps={{ isFullHeight: false }} + pfCardProps={{ isFullHeight: false }} > - - {isLoading ? ( - - ) : ( - - a.action === "force_delete")} - /> - a.action === "resize")} - /> - - - )} - - + {isLoading ? ( + + ) : ( + + a.action === "force_delete")} + /> + a.action === "resize")} + /> + + + )} + ); } diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index 778d21f158..6fe32396cd 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -19,7 +19,7 @@ * find current contact information at www.suse.com. */ -import React, { useReducer, useEffect, useRef } from "react"; +import React, { useEffect, useRef } from "react"; import { Grid, GridItem, Stack } from "@patternfly/react-core"; import { Page, Drawer } from "~/components/core/"; import ProposalTransactionalInfo from "./ProposalTransactionalInfo"; @@ -105,7 +105,8 @@ export default function ProposalPage() {

{_("Storage")}

- + + @@ -152,7 +153,7 @@ export default function ProposalPage() { - + ); } diff --git a/web/src/components/storage/ProposalResultSection.tsx b/web/src/components/storage/ProposalResultSection.tsx index afe5e03c01..e87fb4c167 100644 --- a/web/src/components/storage/ProposalResultSection.tsx +++ b/web/src/components/storage/ProposalResultSection.tsx @@ -21,7 +21,7 @@ import React from "react"; import { Skeleton, Stack } from "@patternfly/react-core"; -import { CardField, EmptyState } from "~/components/core"; +import { EmptyState, Page } from "~/components/core"; import DevicesManager from "~/components/storage/DevicesManager"; import ProposalResultTable from "~/components/storage/ProposalResultTable"; import { _ } from "~/i18n"; @@ -58,26 +58,24 @@ export default function ProposalResultSection({ isLoading = false, }: ProposalResultSectionProps) { return ( - - - {isLoading && } - {errors.length === 0 ? ( - - ) : ( - - {errors.map((e, i) => ( -
{e.message}
- ))} -
- )} -
-
+ {isLoading && } + {errors.length === 0 ? ( + + ) : ( + + {errors.map((e, i) => ( +
{e.message}
+ ))} +
+ )} + ); } diff --git a/web/src/components/storage/SpacePolicySelection.tsx b/web/src/components/storage/SpacePolicySelection.tsx index fbe021ce8c..5ad5aebe9d 100644 --- a/web/src/components/storage/SpacePolicySelection.tsx +++ b/web/src/components/storage/SpacePolicySelection.tsx @@ -19,8 +19,6 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React, { useEffect, useState } from "react"; import { Card, CardBody, Form, Grid, GridItem, Radio, Stack } from "@patternfly/react-core"; import { useNavigate } from "react-router-dom"; @@ -49,32 +47,30 @@ const SpacePolicyPicker = ({ onChange?: (policy: SpacePolicy) => void; }) => { return ( - - - - {/* eslint-disable agama-i18n/string-literals */} - {SPACE_POLICIES.map((policy) => { - const isChecked = currentPolicy?.id === policy.id; - let labelStyle = textStyles.fontSizeLg; - if (isChecked) labelStyle += ` ${textStyles.fontWeightBold}`; - - return ( - {_(policy.label)}} - body={{_(policy.description)}} - onChange={() => onChange(policy)} - defaultChecked={isChecked} - /> - ); - })} - {/* eslint-enable agama-i18n/string-literals */} - - - + + + {/* eslint-disable agama-i18n/string-literals */} + {SPACE_POLICIES.map((policy) => { + const isChecked = currentPolicy?.id === policy.id; + let labelStyle = textStyles.fontSizeLg; + if (isChecked) labelStyle += ` ${textStyles.fontWeightBold}`; + + return ( + {_(policy.label)}} + body={{_(policy.description)}} + onChange={() => onChange(policy)} + defaultChecked={isChecked} + /> + ); + })} + {/* eslint-enable agama-i18n/string-literals */} + + ); }; @@ -159,7 +155,8 @@ export default function SpacePolicySelection() {

{_("Space policy")}

- + +
@@ -181,13 +178,11 @@ export default function SpacePolicySelection() { )}
-
- - - - {_("Accept")} - - + + + + + ); } diff --git a/web/src/components/users/FirstUser.jsx b/web/src/components/users/FirstUser.tsx similarity index 71% rename from web/src/components/users/FirstUser.jsx rename to web/src/components/users/FirstUser.tsx index 0320d4b2d9..a0a96bcf68 100644 --- a/web/src/components/users/FirstUser.jsx +++ b/web/src/components/users/FirstUser.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -19,36 +19,33 @@ * find current contact information at www.suse.com. */ -import React, { useState, useEffect } from "react"; -import { Split, Stack } from "@patternfly/react-core"; +import React from "react"; +import { Stack } from "@patternfly/react-core"; import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; import { useNavigate } from "react-router-dom"; -import { RowActions, Link } from "~/components/core"; +import { Link, Page, RowActions } from "~/components/core"; import { _ } from "~/i18n"; import { useFirstUser, useFirstUserChanges, useRemoveFirstUserMutation } from "~/queries/users"; import { PATHS } from "~/routes/users"; -const UserNotDefined = ({ actionCb }) => { - return ( - <> - -
{_("No user defined yet.")}
-
- - {_( - "Please, be aware that a user must be defined before installing the system to be able to log into it.", - )} - -
- - - {_("Define a user now")} - - -
- - ); -}; +const DefineUserNow = () => ( + + {_("Define a user now")} + +); + +const UserNotDefined = () => ( + +
{_("No user defined yet.")}
+
+ + {_( + "Please, be aware that a user must be defined before installing the system to be able to log into it.", + )} + +
+
+); const UserData = ({ user, actions }) => { return ( @@ -93,9 +90,9 @@ export default function FirstUser() { }, ]; - if (isUserDefined) { - return ; - } else { - return ; - } + return ( + }> + {isUserDefined ? : } + + ); } diff --git a/web/src/components/users/FirstUserForm.jsx b/web/src/components/users/FirstUserForm.tsx similarity index 87% rename from web/src/components/users/FirstUserForm.jsx rename to web/src/components/users/FirstUserForm.tsx index 53ce49625f..6b08305e01 100644 --- a/web/src/components/users/FirstUserForm.jsx +++ b/web/src/components/users/FirstUserForm.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -39,10 +39,9 @@ import { useNavigate } from "react-router-dom"; import { Loading } from "~/components/layout"; import { PasswordAndConfirmationInput, Page } from "~/components/core"; import { _ } from "~/i18n"; -import { useCancellablePromise } from "~/utils"; -import { useInstallerClient } from "~/context/installer"; import { suggestUsernames } from "~/components/users/utils"; import { useFirstUser, useFirstUserMutation } from "~/queries/users"; +import { FirstUser } from "~/types/users"; const UsernameSuggestions = ({ isOpen = false, @@ -62,7 +61,7 @@ const UsernameSuggestions = ({ > - {entries.map((suggestion, index) => ( + {entries.map((suggestion: string, index: number) => ( ({}); const [errors, setErrors] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); const [insideDropDown, setInsideDropDown] = useState(false); const [focusedIndex, setFocusedIndex] = useState(-1); const [suggestions, setSuggestions] = useState([]); const [changePassword, setChangePassword] = useState(true); - const usernameInputRef = useRef(); + const usernameInputRef = useRef(); const navigate = useNavigate(); - const passwordRef = useRef(); + const passwordRef = useRef(); useEffect(() => { const editing = firstUser.userName !== ""; @@ -116,13 +119,13 @@ export default function FirstUserForm() { if (!state.load) return ; - const onSubmit = async (e) => { + const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); setErrors([]); const passwordInput = passwordRef.current; - const formData = new FormData(e.target); - const user = {}; + const formData = new FormData(e.currentTarget); + const user: Partial & { passwordConfirmation?: string } = {}; // FIXME: have a look to https://www.patternfly.org/components/forms/form#form-state formData.forEach((value, key) => { user[key] = value; @@ -151,7 +154,7 @@ export default function FirstUserForm() { .then(() => navigate("..")); }; - const onSuggestionSelected = (suggestion) => { + const onSuggestionSelected = (suggestion: string) => { if (!usernameInputRef.current) return; usernameInputRef.current.value = suggestion; usernameInputRef.current.focus(); @@ -159,12 +162,12 @@ export default function FirstUserForm() { setShowSuggestions(false); }; - const renderSuggestions = (e) => { + const renderSuggestions = (e: React.KeyboardEvent) => { if (suggestions.length === 0) return; - setShowSuggestions(e.target.value === ""); + setShowSuggestions(e.currentTarget.value === ""); }; - const handleKeyDown = (e) => { + const handleKeyDown = (e: React.KeyboardEvent) => { switch (e.key) { case "ArrowDown": e.preventDefault(); // Prevent page scrolling @@ -201,7 +204,7 @@ export default function FirstUserForm() {

{state.isEditing ? _("Edit user") : _("Create user")}

- +
{errors.length > 0 && ( @@ -212,7 +215,7 @@ export default function FirstUserForm() { )} - + - + - + {state.isEditing && ( - + - + - + -
+ - - - - {_("Accept")} - - + + + + ); } diff --git a/web/src/components/users/RootAuthMethods.test.jsx b/web/src/components/users/RootAuthMethods.test.tsx similarity index 99% rename from web/src/components/users/RootAuthMethods.test.jsx rename to web/src/components/users/RootAuthMethods.test.tsx index 41b8ae686c..9155eeb451 100644 --- a/web/src/components/users/RootAuthMethods.test.jsx +++ b/web/src/components/users/RootAuthMethods.test.tsx @@ -25,8 +25,8 @@ import { plainRender } from "~/test-utils"; import { RootAuthMethods } from "~/components/users"; const mockRootUserMutation = { mutate: jest.fn(), mutateAsync: jest.fn() }; -let mockPassword; -let mockSSHKey; +let mockPassword: boolean; +let mockSSHKey: string; jest.mock("~/queries/users", () => ({ ...jest.requireActual("~/queries/users"), diff --git a/web/src/components/users/RootAuthMethods.jsx b/web/src/components/users/RootAuthMethods.tsx similarity index 50% rename from web/src/components/users/RootAuthMethods.jsx rename to web/src/components/users/RootAuthMethods.tsx index b8b5143573..01873c870f 100644 --- a/web/src/components/users/RootAuthMethods.jsx +++ b/web/src/components/users/RootAuthMethods.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -19,41 +19,80 @@ * find current contact information at www.suse.com. */ -import React, { useState, useEffect } from "react"; -import { Button, Skeleton, Split, Stack, Truncate } from "@patternfly/react-core"; +import React, { useState } from "react"; +import { Button, Stack, Truncate } from "@patternfly/react-core"; import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; -import { Em, RowActions } from "~/components/core"; +import { Em, Page, RowActions } from "~/components/core"; import { RootPasswordPopup, RootSSHKeyPopup } from "~/components/users"; import { _ } from "~/i18n"; -import { useCancellablePromise } from "~/utils"; -import { useInstallerClient } from "~/context/installer"; import { useRootUser, useRootUserChanges, useRootUserMutation } from "~/queries/users"; -const MethodsNotDefined = ({ setPassword, setSSHKey }) => { +const NoMethodDefined = () => ( + +
{_("No root authentication method defined yet.")}
+
+ + {_( + "Please, define at least one authentication method for logging into the system as root.", + )} + +
+
+); + +const SSHKeyLabel = ({ sshKey }) => { + const trailingChars = Math.min(sshKey.length - sshKey.lastIndexOf(" "), 30); + return ( - -
{_("No root authentication method defined yet.")}
-
- - {_( - "Please, define at least one authentication method for logging into the system as root.", - )} - -
- - {/* TRANSLATORS: push button label */} - - {/* TRANSLATORS: push button label */} - - -
+ + + ); }; + +const Content = ({ + isPasswordDefined, + isSSHKeyDefined, + sshKey, + passwordActions, + sshKeyActions, +}) => { + if (!isPasswordDefined && !isSSHKeyDefined) return ; + + return ( + + + + {/* TRANSLATORS: table header, user authentication method */} + + {/* TRANSLATORS: table header */} + + + + + + + + + + + + + + + +
{_("Method")}{_("Status")} +
{_("Password")}{isPasswordDefined ? _("Already set") : _("Not set")} + +
{_("SSH Key")} + {isSSHKeyDefined ? : _("Not set")} + + +
+ ); +}; + export default function RootAuthMethods() { const setRootUser = useRootUserMutation(); const [isSSHKeyFormOpen, setIsSSHKeyFormOpen] = useState(false); @@ -93,65 +132,33 @@ export default function RootAuthMethods() { }, ].filter(Boolean); - const PasswordLabel = () => { - return isPasswordDefined ? _("Already set") : _("Not set"); - }; - - const SSHKeyLabel = () => { - if (!isSSHKeyDefined) return _("Not set"); - - const trailingChars = Math.min(sshKey.length - sshKey.lastIndexOf(" "), 30); - - return ( - - - - ); - }; - - const Content = () => { - if (!isPasswordDefined && !isSSHKeyDefined) { - return ; - } - - return ( - - - - {/* TRANSLATORS: table header, user authentication method */} - - {/* TRANSLATORS: table header */} - - - - - - - - - - - - - - - -
{_("Method")}{_("Status")} -
{_("Password")} - - - -
{_("SSH Key")} - - - -
- ); - }; - return ( - <> - + + {/* TRANSLATORS: push button label */} + + {/* TRANSLATORS: push button label */} + + + ) + } + > + + {isPasswordFormOpen && ( )} - + ); } diff --git a/web/src/components/users/UsersPage.jsx b/web/src/components/users/UsersPage.tsx similarity index 71% rename from web/src/components/users/UsersPage.jsx rename to web/src/components/users/UsersPage.tsx index 1913cab2e2..341cfb9fd1 100644 --- a/web/src/components/users/UsersPage.jsx +++ b/web/src/components/users/UsersPage.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -20,9 +20,9 @@ */ import React from "react"; -import { CardField, IssuesHint, Page } from "~/components/core"; +import { Grid, GridItem } from "@patternfly/react-core"; +import { IssuesHint, Page } from "~/components/core"; import { FirstUser, RootAuthMethods } from "~/components/users"; -import { CardBody, Grid, GridItem } from "@patternfly/react-core"; import { useIssues } from "~/queries/issues"; import { _ } from "~/i18n"; @@ -35,27 +35,19 @@ export default function UsersPage() {

{_("Users")}

- + - - - - - + - - - - - + - + ); } diff --git a/web/src/queries/issues.ts b/web/src/queries/issues.ts index 8fc03bb953..d095957519 100644 --- a/web/src/queries/issues.ts +++ b/web/src/queries/issues.ts @@ -20,15 +20,9 @@ */ import React from "react"; -import { - useQueries, - useQuery, - useQueryClient, - useSuspenseQueries, - useSuspenseQuery, -} from "@tanstack/react-query"; +import { useQueryClient, useSuspenseQueries, useSuspenseQuery } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; -import { Issue, IssuesList, IssuesScope } from "~/types/issues"; +import { IssuesList, IssuesScope } from "~/types/issues"; import { fetchIssues } from "~/api/issues"; const scopesFromPath = { diff --git a/web/src/utils.js b/web/src/utils.js index 851a86497d..245002f0e6 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -49,6 +49,37 @@ const isObjectEmpty = (value) => { 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 + * empty string; true otherwise + */ +const isEmpty = (value) => { + if (value === null || value === undefined) { + return true; + } + + if (typeof value === "number" && !Number.isNaN(value)) { + return false; + } + + if (typeof value === "function") { + return false; + } + + if (typeof value === "string") { + return value.trim() === ""; + } + + if (isObject(value)) { + return isObjectEmpty(value); + } + + return true; +}; + /** * Returns an empty function useful to be used as a default callback. * @@ -416,6 +447,7 @@ const timezoneUTCOffset = (timezone) => { export { noop, identity, + isEmpty, isObject, isObjectEmpty, partition,