From 7c2b6b1a1e39cfeb41aa656478e61421db38f0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 14 Aug 2024 08:45:43 +0100 Subject: [PATCH 01/19] fix(web): drop unused CSS --- web/src/assets/styles/blocks.scss | 204 ------------------------------ 1 file changed, 204 deletions(-) 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;
 

From ad9df3a3b7918b0e61bbf97628a732bce857e98a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= 
Date: Wed, 14 Aug 2024 16:00:17 +0100
Subject: [PATCH 02/19] feature(web): add a wrapper for PF/Flex

In order to being able to use it with shortcuts for responsive props
instead of having to write the `propName={{ default: "propValue" }}` all
the time.
---
 web/src/components/layout/Flex.tsx | 151 +++++++++++++++++++++++++++++
 web/src/components/layout/index.js |   1 +
 2 files changed, 152 insertions(+)
 create mode 100644 web/src/components/layout/Flex.tsx

diff --git a/web/src/components/layout/Flex.tsx b/web/src/components/layout/Flex.tsx
new file mode 100644
index 0000000000..6246a81cc2
--- /dev/null
+++ b/web/src/components/layout/Flex.tsx
@@ -0,0 +1,151 @@
+/*
+ * 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: Don't know why these ommited props are being captured otherwise
+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";

From 7414d622a00801c2098575f04435cf6bd0011a1d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= 
Date: Wed, 14 Aug 2024 16:01:24 +0100
Subject: [PATCH 03/19] feature(web): adds a new core/Page component

For replacing the current one by doing things a bit better. Still WIP,
reason why it's in a PageNext.tsx file.
---
 web/src/components/core/PageNext.tsx | 219 +++++++++++++++++++++++++++
 1 file changed, 219 insertions(+)
 create mode 100644 web/src/components/core/PageNext.tsx

diff --git a/web/src/components/core/PageNext.tsx b/web/src/components/core/PageNext.tsx
new file mode 100644
index 0000000000..3ff4e23020
--- /dev/null
+++ b/web/src/components/core/PageNext.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 {
+  Button,
+  ButtonProps,
+  Card,
+  CardBody,
+  CardBodyProps,
+  CardFooter,
+  CardHeader,
+  CardHeaderProps,
+  CardProps,
+  PageGroup,
+  PageGroupProps,
+  PageSection,
+  PageSectionProps,
+  Split,
+  Stack,
+  TitleProps,
+} from "@patternfly/react-core";
+import { Flex } from "~/components/layout";
+import { _ } from "~/i18n";
+import textStyles from "@patternfly/react-styles/css/utilities/Text/text";
+import flexStyles from "@patternfly/react-styles/css/utilities/Flex/flex";
+import { useNavigate } from "react-router-dom";
+
+type SectionProps = {
+  title?: string;
+  value?: React.ReactNode;
+  description?: string;
+  actions?: React.ReactNode;
+  descriptionProps?: CardBodyProps;
+  headerLevel?: TitleProps["headingLevel"];
+  pfCardProps?: CardProps;
+  pfCardHeaderProps?: CardHeaderProps;
+  pfCardBodyProps?: CardBodyProps;
+};
+
+// FIXME: add a Page.Back for navigationg to -1 insetad of ".."?
+type PageActionProps = { navigateTo?: string | number } & ButtonProps;
+type PageSubmitActionProps = { form: string } & ButtonProps;
+
+const defaultCardProps: CardProps = { isRounded: true, isCompact: true, isFullHeight: true };
+
+const Header = ({ hasGutter = true, children, ...props }) => {
+  return (
+    
+      {children}
+    
+  );
+};
+
+/**
+ * Creates a page region on top of PF/Card component
+ *
+ * @example Simple usage
+ *   
+ *     
+ *   
+ */
+const Section = ({
+  title,
+  value,
+  description,
+  actions,
+  headerLevel: Title = "h3",
+  pfCardProps,
+  pfCardHeaderProps,
+  pfCardBodyProps,
+  children,
+}: React.PropsWithChildren) => {
+  const renderTitle = !!title && title.trim() !== "";
+  const renderValue = React.isValidElement(value);
+  const renderDescription = !!description && description.trim() !== "";
+  const renderHeader = renderTitle || renderValue;
+
+  console.log("here");
+
+  return (
+    
+      {renderHeader && (
+        
+          
+            
+              {renderTitle && {title}}
+              {renderValue && (
+                
+                  {value}
+                
+              )}
+            
+            {renderDescription && 
{description}
} +
+
+ )} + {children} + {actions && ( + + {actions} + + )} +
+ ); +}; + +/** + * Wraps given children in an PageGroup sticky at the bottom + * + * @example Simple usage + * + * + * + */ +const Actions = ({ children }: React.PropsWithChildren) => { + return ( + + + {children} + + + ); +}; + +/** + * A convenient component for rendering a page action + * + * Built on top of {@link https://www.patternfly.org/components/button | PF/Button} + */ +const Action = ({ navigateTo, children, ...props }: PageActionProps) => { + const navigate = useNavigate(); + + const onClickFn = props.onClick; + + props.onClick = (e) => { + if (typeof onClickFn === "function") onClickFn(e); + if (navigateTo) navigate(navigateTo); + }; + + const buttonProps = { size: "lg" as const, ...props }; + return ; +}; + +/** + * Convenient component for a "Cancel" action + */ +const Cancel = ({ navigateTo = "..", children, ...props }: PageActionProps) => { + return ( + + {children || _("Cancel")} + + ); +}; + +/** + * Convenient component for a "form submission" action + */ +const Submit = ({ children, ...props }: PageSubmitActionProps) => { + return ( + + {children || _("Accept")} + + ); +}; + +const Content = ({ children, ...pageSectionProps }: React.PropsWithChildren) => ( + + {children} + +); + +/** + * Wraps in a PF/PageGroup the content given by a router Outlet + * + * @example Simple usage + * + * + * + */ +const Page = ({ + children, + ...pageGroupProps +}: React.PropsWithChildren): React.ReactNode => { + return {children}; +}; + +Page.displayName = "agama/core/Page"; +Page.Header = Header; +Page.Content = Content; +Page.Actions = Actions; +Page.Cancel = Cancel; +Page.Submit = Submit; +Page.Action = Action; +Page.Section = Section; + +export default Page; From e4624d7dad31819c8e58434e2d5c119e95e867a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 14 Aug 2024 16:04:12 +0100 Subject: [PATCH 04/19] feature(web): uses the new Page component Which still imported from core/PageNext. --- web/src/components/core/LoginPage.jsx | 4 +- web/src/components/core/PageNext.tsx | 6 +- web/src/components/core/index.js | 3 +- web/src/components/l10n/KeyboardSelection.jsx | 20 +- web/src/components/l10n/L10nPage.jsx | 34 ++-- web/src/components/l10n/LocaleSelection.jsx | 19 +- web/src/components/l10n/TimezoneSelection.jsx | 19 +- web/src/components/network/IpSettingsForm.tsx | 42 ++-- web/src/components/network/NetworkPage.tsx | 87 ++++---- .../components/network/WifiSelectorPage.tsx | 11 +- web/src/components/overview/OverviewPage.jsx | 62 +++--- .../product/ProductSelectionPage.test.tsx | 4 +- .../product/ProductSelectionPage.tsx | 26 ++- web/src/components/software/SoftwarePage.tsx | 58 +++--- .../software/SoftwarePatternsSelection.tsx | 40 ++-- web/src/components/storage/BootSelection.tsx | 183 ++++++++--------- web/src/components/storage/DASDPage.tsx | 4 +- web/src/components/storage/DASDTable.tsx | 119 ++++++----- .../components/storage/DeviceSelection.tsx | 190 ++++++++---------- .../components/storage/EncryptionField.tsx | 10 +- .../storage/InstallationDeviceField.tsx | 18 +- .../components/storage/PartitionsField.tsx | 59 +++--- .../storage/ProposalActionsSummary.tsx | 56 +++--- web/src/components/storage/ProposalPage.tsx | 7 +- .../storage/ProposalResultSection.tsx | 38 ++-- .../storage/SpacePolicySelection.tsx | 67 +++--- web/src/components/users/FirstUser.jsx | 55 +++-- web/src/components/users/FirstUserForm.jsx | 26 ++- web/src/components/users/RootAuthMethods.jsx | 179 +++++++++-------- web/src/components/users/UsersPage.jsx | 20 +- 30 files changed, 715 insertions(+), 751 deletions(-) diff --git a/web/src/components/core/LoginPage.jsx b/web/src/components/core/LoginPage.jsx index 4a008904f9..fd48fb0546 100644 --- a/web/src/components/core/LoginPage.jsx +++ b/web/src/components/core/LoginPage.jsx @@ -82,7 +82,7 @@ user privileges.", ).split(/[[\]]/); return ( - +
@@ -122,6 +122,6 @@ user privileges.",
-
+ ); } diff --git a/web/src/components/core/PageNext.tsx b/web/src/components/core/PageNext.tsx index 3ff4e23020..d20f57d8f8 100644 --- a/web/src/components/core/PageNext.tsx +++ b/web/src/components/core/PageNext.tsx @@ -94,8 +94,6 @@ const Section = ({ const renderDescription = !!description && description.trim() !== ""; const renderHeader = renderTitle || renderValue; - console.log("here"); - return ( {renderHeader && ( @@ -157,7 +155,9 @@ const Action = ({ navigateTo, children, ...props }: PageActionProps) => { props.onClick = (e) => { if (typeof onClickFn === "function") onClickFn(e); - if (navigateTo) navigate(navigateTo); + // FIXME: look for a better overloading alternative. See https://github.com/remix-run/react-router/issues/10505#issuecomment-2237126223 + // and https://www.typescriptlang.org/docs/handbook/2/functions.html#function-overloads + if (navigateTo) typeof navigateTo === "number" ? navigate(navigateTo) : navigate(navigateTo); }; const buttonProps = { size: "lg" as const, ...props }; diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index d755b19ce4..4f8841814d 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -39,7 +39,8 @@ export { default as ListSearch } from "./ListSearch"; export { default as LoginPage } from "./LoginPage"; export { default as LogsButton } from "./LogsButton"; export { default as RowActions } from "./RowActions"; -export { default as Page } from "./Page"; +// FIXME: rename PageNext to Page when all componentes are migrated +export { default as Page } from "./PageNext"; export { default as PasswordAndConfirmationInput } from "./PasswordAndConfirmationInput"; export { default as Popup } from "./Popup"; export { default as ProgressReport } from "./ProgressReport"; diff --git a/web/src/components/l10n/KeyboardSelection.jsx b/web/src/components/l10n/KeyboardSelection.jsx index 9fd97137af..db89487351 100644 --- a/web/src/components/l10n/KeyboardSelection.jsx +++ b/web/src/components/l10n/KeyboardSelection.jsx @@ -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.jsx b/web/src/components/l10n/LocaleSelection.jsx index 75ed98f6d2..4d9f4a3d50 100644 --- a/web/src/components/l10n/LocaleSelection.jsx +++ b/web/src/components/l10n/LocaleSelection.jsx @@ -79,19 +79,18 @@ export default function LocaleSelection() { - - + +
{localesList}
-
-
- - - - {_("Select")} - - + + + + + + {_("Select")} + ); } diff --git a/web/src/components/l10n/TimezoneSelection.jsx b/web/src/components/l10n/TimezoneSelection.jsx index 56bc6a525d..cc6fad3c05 100644 --- a/web/src/components/l10n/TimezoneSelection.jsx +++ b/web/src/components/l10n/TimezoneSelection.jsx @@ -112,19 +112,18 @@ export default function TimezoneSelection() { /> - - + +
{timezonesList}
-
-
- - - - {_("Select")} - - + + + + + + {_("Select")} + ); } 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.jsx b/web/src/components/overview/OverviewPage.jsx index 7363eb58fd..ce72cb06b0 100644 --- a/web/src/components/overview/OverviewPage.jsx +++ b/web/src/components/overview/OverviewPage.jsx @@ -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,10 +33,9 @@ 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"; @@ -59,9 +57,8 @@ const ReadyForInstallation = () => ( ); -// FIXME: improve const IssuesList = ({ issues }) => { - const { isEmpty, issues: issuesByScope } = issues; + 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..ccb5aaad2a 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", () => { diff --git a/web/src/components/product/ProductSelectionPage.tsx b/web/src/components/product/ProductSelectionPage.tsx index 09000b10dc..342b42e2fd 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 && } - } + {_("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.jsx index 0320d4b2d9..4cf6065016 100644 --- a/web/src/components/users/FirstUser.jsx +++ b/web/src/components/users/FirstUser.jsx @@ -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.jsx index 53ce49625f..51b9fedb0e 100644 --- a/web/src/components/users/FirstUserForm.jsx +++ b/web/src/components/users/FirstUserForm.jsx @@ -201,7 +201,7 @@ export default function FirstUserForm() {

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

- +
{errors.length > 0 && ( @@ -212,7 +212,7 @@ export default function FirstUserForm() { )} - + - + - + {state.isEditing && ( - + - + - + -
+ - - - - {_("Accept")} - - + + + + ); } diff --git a/web/src/components/users/RootAuthMethods.jsx b/web/src/components/users/RootAuthMethods.jsx index b8b5143573..01873c870f 100644 --- a/web/src/components/users/RootAuthMethods.jsx +++ b/web/src/components/users/RootAuthMethods.jsx @@ -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.jsx index 1913cab2e2..e6c9bdc4b3 100644 --- a/web/src/components/users/UsersPage.jsx +++ b/web/src/components/users/UsersPage.jsx @@ -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")}

- + - - - - - + - - - - - + - + ); } From 9e4cbee46e754906679239f33c569f316f0e66ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 11 Sep 2024 12:55:18 +0100 Subject: [PATCH 05/19] fix(web): add a Page.Back dedicated action Mainly to avoid the tricky workaround for the ReactRouterDom#navigate overloading. See https://github.com/remix-run/react-router/issues/10505#issuecomment-2237126223 --- web/src/components/core/PageNext.tsx | 20 ++++++++++++------- .../product/ProductSelectionPage.test.tsx | 2 +- .../product/ProductSelectionPage.tsx | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/web/src/components/core/PageNext.tsx b/web/src/components/core/PageNext.tsx index d20f57d8f8..0e5d8f1a1e 100644 --- a/web/src/components/core/PageNext.tsx +++ b/web/src/components/core/PageNext.tsx @@ -56,8 +56,7 @@ type SectionProps = { pfCardBodyProps?: CardBodyProps; }; -// FIXME: add a Page.Back for navigationg to -1 insetad of ".."? -type PageActionProps = { navigateTo?: string | number } & ButtonProps; +type PageActionProps = { navigateTo?: string } & ButtonProps; type PageSubmitActionProps = { form: string } & ButtonProps; const defaultCardProps: CardProps = { isRounded: true, isCompact: true, isFullHeight: true }; @@ -155,13 +154,10 @@ const Action = ({ navigateTo, children, ...props }: PageActionProps) => { props.onClick = (e) => { if (typeof onClickFn === "function") onClickFn(e); - // FIXME: look for a better overloading alternative. See https://github.com/remix-run/react-router/issues/10505#issuecomment-2237126223 - // and https://www.typescriptlang.org/docs/handbook/2/functions.html#function-overloads - if (navigateTo) typeof navigateTo === "number" ? navigate(navigateTo) : navigate(navigateTo); + if (navigateTo) navigate(navigateTo); }; - const buttonProps = { size: "lg" as const, ...props }; - return ; + return ; }; /** @@ -175,6 +171,15 @@ const Cancel = ({ navigateTo = "..", children, ...props }: PageActionProps) => { ); }; +/** + * Convenient component for a "Back" action + */ +const Back = ({ children, ...props }: ButtonProps) => { + const navigate = useNavigate(); + + return ; +}; + /** * Convenient component for a "form submission" action */ @@ -211,6 +216,7 @@ 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; diff --git a/web/src/components/product/ProductSelectionPage.test.tsx b/web/src/components/product/ProductSelectionPage.test.tsx index ccb5aaad2a..260df90c0e 100644 --- a/web/src/components/product/ProductSelectionPage.test.tsx +++ b/web/src/components/product/ProductSelectionPage.test.tsx @@ -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 342b42e2fd..02ccfffc2a 100644 --- a/web/src/components/product/ProductSelectionPage.tsx +++ b/web/src/components/product/ProductSelectionPage.tsx @@ -119,7 +119,7 @@ function ProductSelectionPage() { - {selectedProduct && !isLoading && } + {selectedProduct && !isLoading && {_("Cancel")}} Date: Wed, 11 Sep 2024 13:09:01 +0100 Subject: [PATCH 06/19] fix(web): add an id to the Page.Section title Useful for using the aria-labelledby when no aria-label is given. --- web/src/components/core/PageNext.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web/src/components/core/PageNext.tsx b/web/src/components/core/PageNext.tsx index 0e5d8f1a1e..9a87fef27f 100644 --- a/web/src/components/core/PageNext.tsx +++ b/web/src/components/core/PageNext.tsx @@ -19,7 +19,7 @@ * find current contact information at www.suse.com. */ -import React from "react"; +import React, { useId } from "react"; import { Button, ButtonProps, @@ -88,18 +88,22 @@ const Section = ({ pfCardBodyProps, children, }: React.PropsWithChildren) => { + const titleId = useId(); const renderTitle = !!title && title.trim() !== ""; const renderValue = React.isValidElement(value); const renderDescription = !!description && description.trim() !== ""; const renderHeader = renderTitle || renderValue; + // FIXME: use aria-labelledby only if there is title AND aria-label was not + // given + const props = { ...defaultCardProps, "aria-labelledby": titleId }; return ( - + {renderHeader && ( - {renderTitle && {title}} + {renderTitle && {title}} {renderValue && ( {value} From 14cfeabadc4235105ee34b27cf61dd76a6d5f41f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 11 Sep 2024 13:12:48 +0100 Subject: [PATCH 07/19] fix(web): render Page.Section as HTML
By using the PF/Card#component prop. --- web/src/components/core/PageNext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/core/PageNext.tsx b/web/src/components/core/PageNext.tsx index 9a87fef27f..0cb6844b32 100644 --- a/web/src/components/core/PageNext.tsx +++ b/web/src/components/core/PageNext.tsx @@ -59,7 +59,7 @@ type SectionProps = { type PageActionProps = { navigateTo?: string } & ButtonProps; type PageSubmitActionProps = { form: string } & ButtonProps; -const defaultCardProps: CardProps = { isRounded: true, isCompact: true, isFullHeight: true }; +const defaultCardProps: CardProps = { isRounded: true, isCompact: true, isFullHeight: true, component: "section" }; const Header = ({ hasGutter = true, children, ...props }) => { return ( From 4b958a1ba6cd1f368ac07787c0c4f3c9cf477553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 11 Sep 2024 13:13:07 +0100 Subject: [PATCH 08/19] fix(web): render Page.Header and Page.Content as HTML
Instead of an HTML
to avoid having too much nested sections. This change might not be final and changed back again later, once we have more clear the better structure for a better a11y. --- web/src/components/core/PageNext.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/core/PageNext.tsx b/web/src/components/core/PageNext.tsx index 0cb6844b32..7b5f31f9d0 100644 --- a/web/src/components/core/PageNext.tsx +++ b/web/src/components/core/PageNext.tsx @@ -63,7 +63,7 @@ const defaultCardProps: CardProps = { isRounded: true, isCompact: true, isFullHe const Header = ({ hasGutter = true, children, ...props }) => { return ( - + {children} ); @@ -196,7 +196,7 @@ const Submit = ({ children, ...props }: PageSubmitActionProps) => { }; const Content = ({ children, ...pageSectionProps }: React.PropsWithChildren) => ( - + {children} ); From 08d12a3a708be3f1b2e62509176a369b816d77de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 11 Sep 2024 13:58:38 +0100 Subject: [PATCH 09/19] feat(web): add isEmpty util function --- web/src/utils.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) 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, From e39090fd611c8adeacb7cb68f044ad57ad0f7b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 11 Sep 2024 14:00:53 +0100 Subject: [PATCH 10/19] fix(web): Page.Section internal improvements Among other minor improvements, the aria-labelledby attribute is now added only when needed. --- web/src/components/core/PageNext.tsx | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/web/src/components/core/PageNext.tsx b/web/src/components/core/PageNext.tsx index 7b5f31f9d0..ef8256d0fc 100644 --- a/web/src/components/core/PageNext.tsx +++ b/web/src/components/core/PageNext.tsx @@ -43,6 +43,7 @@ import { _ } from "~/i18n"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import flexStyles from "@patternfly/react-styles/css/utilities/Flex/flex"; import { useNavigate } from "react-router-dom"; +import { isEmpty, isObject } from "~/utils"; type SectionProps = { title?: string; @@ -89,28 +90,28 @@ const Section = ({ children, }: React.PropsWithChildren) => { const titleId = useId(); - const renderTitle = !!title && title.trim() !== ""; - const renderValue = React.isValidElement(value); - const renderDescription = !!description && description.trim() !== ""; - const renderHeader = renderTitle || renderValue; - // FIXME: use aria-labelledby only if there is title AND aria-label was not - // given - const props = { ...defaultCardProps, "aria-labelledby": titleId }; + const hasTitle = !isEmpty(title); + const hasValue = !isEmpty(value); + const hasDescription = !isEmpty(description); + const hasHeader = hasTitle || hasValue; + const hasAriaLabel = isObject(pfCardProps) && "aria-label" in pfCardProps; + const props = { ...defaultCardProps }; + if (!hasAriaLabel && hasTitle) props["aria-labelledby"] = titleId; return ( - {renderHeader && ( + {hasHeader && ( - {renderTitle && {title}} - {renderValue && ( + {hasTitle && {title}} + {hasValue && ( {value} )} - {renderDescription &&
{description}
} + {hasDescription &&
{description}
}
)} From e6f131272ca60ca7108fc86c3323c353b1107066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 11 Sep 2024 14:02:34 +0100 Subject: [PATCH 11/19] fix(web): please prettier for core/Page component And also use constants for sticky top and bottom values. --- web/src/components/core/PageNext.tsx | 30 +++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/web/src/components/core/PageNext.tsx b/web/src/components/core/PageNext.tsx index ef8256d0fc..89db1cde23 100644 --- a/web/src/components/core/PageNext.tsx +++ b/web/src/components/core/PageNext.tsx @@ -60,11 +60,19 @@ type SectionProps = { type PageActionProps = { navigateTo?: string } & ButtonProps; type PageSubmitActionProps = { form: string } & ButtonProps; -const defaultCardProps: CardProps = { isRounded: true, isCompact: true, isFullHeight: true, component: "section" }; +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" }); const Header = ({ hasGutter = true, children, ...props }) => { return ( - + {children} ); @@ -135,11 +143,7 @@ const Section = ({ */ const Actions = ({ children }: React.PropsWithChildren) => { return ( - + {children} @@ -162,7 +166,11 @@ const Action = ({ navigateTo, children, ...props }: PageActionProps) => { if (navigateTo) navigate(navigateTo); }; - return ; + return ( + + ); }; /** @@ -182,7 +190,11 @@ const Cancel = ({ navigateTo = "..", children, ...props }: PageActionProps) => { const Back = ({ children, ...props }: ButtonProps) => { const navigate = useNavigate(); - return ; + return ( + + ); }; /** From 45d255233f3a82e733a149492c44acfbae93e7ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 11 Sep 2024 15:13:56 +0100 Subject: [PATCH 12/19] feat(web): drop core/CardField components Replaced by Page.Section. --- web/src/components/core/CardField.jsx | 86 --------------------------- web/src/components/core/index.js | 1 - 2 files changed, 87 deletions(-) delete mode 100644 web/src/components/core/CardField.jsx 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/index.js b/web/src/components/core/index.js index 4f8841814d..f4de9b23ac 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -51,7 +51,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"; From 1ccb25fd7ef5e2f5a62e960ba2d7ca89ecace197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 11 Sep 2024 15:17:13 +0100 Subject: [PATCH 13/19] refactor(web): drop former Page component --- web/src/components/core/Page.test.jsx | 290 -------------------------- web/src/components/core/Page.tsx | 170 --------------- 2 files changed, 460 deletions(-) delete mode 100644 web/src/components/core/Page.test.jsx delete mode 100644 web/src/components/core/Page.tsx 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.tsx b/web/src/components/core/Page.tsx deleted file mode 100644 index 03d165ecd7..0000000000 --- a/web/src/components/core/Page.tsx +++ /dev/null @@ -1,170 +0,0 @@ -/* - * Copyright (c) [2023] 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 { NavLink, useNavigate } from "react-router-dom"; -import { - Button, - ButtonProps, - Card, - CardBody, - CardHeader, - CardProps, - Flex, - PageGroup, - PageSection, - Stack, -} from "@patternfly/react-core"; -import { _ } from "~/i18n"; -import tabsStyles from "@patternfly/react-styles/css/components/Tabs/tabs"; -import flexStyles from "@patternfly/react-styles/css/utilities/Flex/flex"; - -type PageActionProps = { navigateTo?: string } & ButtonProps; -type PageCancelActionProps = { text?: string } & PageActionProps; - -/** - * A convenient component for rendering a page action - * - * Built on top of {@link https://www.patternfly.org/components/button | PF/Button} - */ -const Action = ({ navigateTo, children, ...props }: PageActionProps) => { - const navigate = useNavigate(); - - const onClickFn = props.onClick; - - props.onClick = (e) => { - if (typeof onClickFn === "function") onClickFn(e); - if (navigateTo) navigate(navigateTo); - }; - - const buttonProps = { size: "lg" as const, ...props }; - return ; -}; - -/** - * Convenient component for a Cancel / Back action - */ -const CancelAction = ({ - text = _("Cancel"), - navigateTo = "..", - ...props -}: PageCancelActionProps) => { - return ( - - {text} - - ); -}; - -/** - * Wrapper component built on top of PF/PageSection for holding the Page actions - * - * Required for placing content to be used as Page actions, usually a - * Page.Action or a PF/Button - */ -const Actions = ({ children }: React.PropsWithChildren) => ( - - {children} - -); - -const MainContent = ({ children, ...props }) => ( - - {children} - -); - -const Navigation = ({ routes }) => { - if (!Array.isArray(routes) || routes.length === 0) return; - - // 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 }) => { - return ( - - {children} - - ); -}; - -const CardSection = ({ title, children, ...props }: CardProps & { title?: string }) => { - return ( - - {title && {title} } - {children && {children}} - - ); -}; - -/** - * Wraps children in a PF/PageGroup - * - * @example Simple usage - * - * - * - */ -const Page = ({ children }) => { - return {children}; -}; - -Page.CardSection = CardSection; -Page.NextActions = Actions; -Page.Action = Action; -Page.MainContent = MainContent; -Page.CancelAction = CancelAction; -Page.Header = Header; - -export default Page; From cefda03db13e781f45f7599bd43b460f7b0fe2a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 12 Sep 2024 10:58:24 +0100 Subject: [PATCH 14/19] refactor(web): move core/PageNext to core/Page And also adds basic unit testing for such a new Page component. --- web/src/components/core/Page.test.tsx | 212 ++++++++++++++++++ .../core/{PageNext.tsx => Page.tsx} | 26 ++- web/src/components/core/index.js | 3 +- 3 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 web/src/components/core/Page.test.tsx rename web/src/components/core/{PageNext.tsx => Page.tsx} (89%) diff --git a/web/src/components/core/Page.test.tsx b/web/src/components/core/Page.test.tsx new file mode 100644 index 0000000000..f81c690a7c --- /dev/null +++ b/web/src/components/core/Page.test.tsx @@ -0,0 +1,212 @@ +/* + * 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", async () => { + 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); + }); + }); + + 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", async () => { + 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", async () => { + 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", async () => { + 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)", async () => { + 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/PageNext.tsx b/web/src/components/core/Page.tsx similarity index 89% rename from web/src/components/core/PageNext.tsx rename to web/src/components/core/Page.tsx index 89db1cde23..122b37981a 100644 --- a/web/src/components/core/PageNext.tsx +++ b/web/src/components/core/Page.tsx @@ -47,6 +47,7 @@ import { isEmpty, isObject } from "~/utils"; type SectionProps = { title?: string; + "aria-label"?: string; value?: React.ReactNode; description?: string; actions?: React.ReactNode; @@ -70,6 +71,7 @@ const defaultCardProps: CardProps = { 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 ( @@ -88,6 +90,7 @@ const Header = ({ hasGutter = true, children, ...props }) => { */ const Section = ({ title, + "aria-label": ariaLabel, value, description, actions, @@ -102,9 +105,15 @@ const Section = ({ const hasValue = !isEmpty(value); const hasDescription = !isEmpty(description); const hasHeader = hasTitle || hasValue; - const hasAriaLabel = isObject(pfCardProps) && "aria-label" in pfCardProps; - const props = { ...defaultCardProps }; - if (!hasAriaLabel && hasTitle) props["aria-labelledby"] = titleId; + 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 ( @@ -140,11 +149,18 @@ const Section = ({ * * * + * + * TODO: check if it contentinfo role really should have the banner role */ const Actions = ({ children }: React.PropsWithChildren) => { return ( - - + + {children} diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index f4de9b23ac..e602a4b8f6 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -39,8 +39,7 @@ export { default as ListSearch } from "./ListSearch"; export { default as LoginPage } from "./LoginPage"; export { default as LogsButton } from "./LogsButton"; export { default as RowActions } from "./RowActions"; -// FIXME: rename PageNext to Page when all componentes are migrated -export { default as Page } from "./PageNext"; +export { default as Page } from "./Page"; export { default as PasswordAndConfirmationInput } from "./PasswordAndConfirmationInput"; export { default as Popup } from "./Popup"; export { default as ProgressReport } from "./ProgressReport"; From 7f05aa8066abc55bc3231219b0574be1287b0301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 12 Sep 2024 13:05:08 +0100 Subject: [PATCH 15/19] refactor(web): migrate some files to TypeScript Those .jsx files that were touched in previous commits on the context of https://github.com/openSUSE/agama/pull/1540. --- web/src/components/core/ListSearch.jsx | 4 +- ...{LoginPage.test.jsx => LoginPage.test.tsx} | 13 ++-- .../core/{LoginPage.jsx => LoginPage.tsx} | 7 +- ... => PasswordAndConfirmationInput.test.tsx} | 6 +- ...t.jsx => PasswordAndConfirmationInput.tsx} | 35 +++++---- ...on.test.jsx => KeyboardSelection.test.tsx} | 0 ...ardSelection.jsx => KeyboardSelection.tsx} | 6 +- ...tion.test.jsx => LocaleSelection.test.tsx} | 0 ...ocaleSelection.jsx => LocaleSelection.tsx} | 6 +- ...on.test.jsx => TimezoneSelection.test.tsx} | 0 ...oneSelection.jsx => TimezoneSelection.tsx} | 75 ++++++++++--------- ...iewPage.test.jsx => OverviewPage.test.tsx} | 30 +++----- .../{OverviewPage.jsx => OverviewPage.tsx} | 4 +- .../users/{FirstUser.jsx => FirstUser.tsx} | 2 +- .../{FirstUserForm.jsx => FirstUserForm.tsx} | 35 +++++---- ...hods.test.jsx => RootAuthMethods.test.tsx} | 4 +- ...ootAuthMethods.jsx => RootAuthMethods.tsx} | 0 .../users/{UsersPage.jsx => UsersPage.tsx} | 2 +- web/src/queries/issues.ts | 10 +-- 19 files changed, 118 insertions(+), 121 deletions(-) rename web/src/components/core/{LoginPage.test.jsx => LoginPage.test.tsx} (93%) rename web/src/components/core/{LoginPage.jsx => LoginPage.tsx} (98%) rename web/src/components/core/{PasswordAndConfirmationInput.test.jsx => PasswordAndConfirmationInput.test.tsx} (94%) rename web/src/components/core/{PasswordAndConfirmationInput.jsx => PasswordAndConfirmationInput.tsx} (75%) rename web/src/components/l10n/{KeyboardSelection.test.jsx => KeyboardSelection.test.tsx} (100%) rename web/src/components/l10n/{KeyboardSelection.jsx => KeyboardSelection.tsx} (94%) rename web/src/components/l10n/{LocaleSelection.test.jsx => LocaleSelection.test.tsx} (100%) rename web/src/components/l10n/{LocaleSelection.jsx => LocaleSelection.tsx} (95%) rename web/src/components/l10n/{TimezoneSelection.test.jsx => TimezoneSelection.test.tsx} (100%) rename web/src/components/l10n/{TimezoneSelection.jsx => TimezoneSelection.tsx} (69%) rename web/src/components/overview/{OverviewPage.test.jsx => OverviewPage.test.tsx} (80%) rename web/src/components/overview/{OverviewPage.jsx => OverviewPage.tsx} (96%) rename web/src/components/users/{FirstUser.jsx => FirstUser.tsx} (98%) rename web/src/components/users/{FirstUserForm.jsx => FirstUserForm.tsx} (91%) rename web/src/components/users/{RootAuthMethods.test.jsx => RootAuthMethods.test.tsx} (99%) rename web/src/components/users/{RootAuthMethods.jsx => RootAuthMethods.tsx} (100%) rename web/src/components/users/{UsersPage.jsx => UsersPage.tsx} (97%) 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 98% rename from web/src/components/core/LoginPage.jsx rename to web/src/components/core/LoginPage.tsx index fd48fb0546..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); 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..efa6714b34 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 uncontrlled 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/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 94% rename from web/src/components/l10n/KeyboardSelection.jsx rename to web/src/components/l10n/KeyboardSelection.tsx index db89487351..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 ( 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 95% rename from web/src/components/l10n/LocaleSelection.jsx rename to web/src/components/l10n/LocaleSelection.tsx index 4d9f4a3d50..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 ( 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 69% rename from web/src/components/l10n/TimezoneSelection.jsx rename to web/src/components/l10n/TimezoneSelection.tsx index cc6fad3c05..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"; + +type TimezoneWithDetails = Timezone & { details: string }; -let date; +let date: Date; -const timezoneWithDetails = (timezone) => { +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 ( 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 96% rename from web/src/components/overview/OverviewPage.jsx rename to web/src/components/overview/OverviewPage.tsx index ce72cb06b0..206dfa689e 100644 --- a/web/src/components/overview/OverviewPage.jsx +++ b/web/src/components/overview/OverviewPage.tsx @@ -41,7 +41,7 @@ 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"), @@ -57,7 +57,7 @@ const ReadyForInstallation = () => ( ); -const IssuesList = ({ issues }) => { +const IssuesList = ({ issues }: { issues: IssuesListType }) => { const { issues: issuesByScope } = issues; const list = []; Object.entries(issuesByScope).forEach(([scope, issues], idx) => { diff --git a/web/src/components/users/FirstUser.jsx b/web/src/components/users/FirstUser.tsx similarity index 98% rename from web/src/components/users/FirstUser.jsx rename to web/src/components/users/FirstUser.tsx index 4cf6065016..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. * diff --git a/web/src/components/users/FirstUserForm.jsx b/web/src/components/users/FirstUserForm.tsx similarity index 91% rename from web/src/components/users/FirstUserForm.jsx rename to web/src/components/users/FirstUserForm.tsx index 51b9fedb0e..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 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 100% rename from web/src/components/users/RootAuthMethods.jsx rename to web/src/components/users/RootAuthMethods.tsx diff --git a/web/src/components/users/UsersPage.jsx b/web/src/components/users/UsersPage.tsx similarity index 97% rename from web/src/components/users/UsersPage.jsx rename to web/src/components/users/UsersPage.tsx index e6c9bdc4b3..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. * 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 = { From ca9bdba2a58705beaa66f85b629e2074c80c0123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 12 Sep 2024 20:40:58 +0100 Subject: [PATCH 16/19] fix(web): update and improve core/Page documentation --- web/src/components/core/Page.tsx | 120 +++++++++++++++++++++++++------ 1 file changed, 97 insertions(+), 23 deletions(-) diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index 122b37981a..4df926f122 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -42,24 +42,44 @@ import { Flex } from "~/components/layout"; import { _ } from "~/i18n"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import flexStyles from "@patternfly/react-styles/css/utilities/Flex/flex"; -import { useNavigate } from "react-router-dom"; +import { To, useNavigate } from "react-router-dom"; import { isEmpty, isObject } from "~/utils"; +/** + * 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; - description?: string; + /** Elements to be rendered in the section footer */ actions?: React.ReactNode; - descriptionProps?: CardBodyProps; + /** As sort 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 PageActionProps = { navigateTo?: string } & ButtonProps; -type PageSubmitActionProps = { form: string } & ButtonProps; +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, @@ -85,7 +105,19 @@ const Header = ({ hasGutter = true, children, ...props }) => { * * @example Simple usage * - * + * + * + * @example Complex usage + * : } + * > + * + * )} * */ const Section = ({ @@ -143,14 +175,17 @@ const Section = ({ }; /** - * Wraps given children in an PageGroup sticky at the bottom - * - * @example Simple usage - * - * - * + * 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 ( @@ -168,11 +203,11 @@ const Actions = ({ children }: React.PropsWithChildren) => { }; /** - * 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; @@ -190,9 +225,13 @@ const Action = ({ navigateTo, children, ...props }: PageActionProps) => { }; /** - * Convenient component for a "Cancel" 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 Cancel = ({ navigateTo = "..", children, ...props }: PageActionProps) => { +const Cancel = ({ navigateTo = "..", children, ...props }: ActionProps) => { return ( {children || _("Cancel")} @@ -201,7 +240,16 @@ const Cancel = ({ navigateTo = "..", children, ...props }: PageActionProps) => { }; /** - * Convenient component for a "Back" action + * 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) + * + * 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 Back = ({ children, ...props }: ButtonProps) => { const navigate = useNavigate(); @@ -214,9 +262,9 @@ const Back = ({ children, ...props }: ButtonProps) => { }; /** - * Convenient component for a "form submission" action + * Handy component to submit a form matching the id given in the `form` prop */ -const Submit = ({ children, ...props }: PageSubmitActionProps) => { +const Submit = ({ children, ...props }: SubmitActionProps) => { return ( {children || _("Accept")} @@ -224,6 +272,11 @@ const Submit = ({ children, ...props }: PageSubmitActionProps) => { ); }; +/** + * 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} @@ -231,11 +284,32 @@ const Content = ({ children, ...pageSectionProps }: React.PropsWithChildrenSimple usage + * @see [Patternfly Page/PageGroup](https://www.patternfly.org/components/page#pagegroup) + * + * @example * - * + * + *

{_("Software")}

+ *
+ * + * + * + * + * + * + * {patterns.length === 0 ? : } + * + * + * + * + * + * + * + * + * + * *
*/ const Page = ({ From 178fe83ffc4276fb83f3ddc0be738f2a96f530fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 12 Sep 2024 20:50:09 +0100 Subject: [PATCH 17/19] fix(web) change Page.Back default look&feel Also drop a bunch of not needed `async` keywords in Page.test.tsx --- web/src/components/core/Page.test.tsx | 17 ++++++++++++----- web/src/components/core/Page.tsx | 4 ++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/web/src/components/core/Page.test.tsx b/web/src/components/core/Page.test.tsx index f81c690a7c..8b4af016c7 100644 --- a/web/src/components/core/Page.test.tsx +++ b/web/src/components/core/Page.test.tsx @@ -91,7 +91,7 @@ describe("Page", () => { }); describe("Page.Content", () => { - it("renders a node that fills all the available space", async () => { + 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); @@ -114,6 +114,13 @@ describe("Page", () => { 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", () => { @@ -140,7 +147,7 @@ describe("Page", () => { }); }); describe("Page.Header", () => { - it("renders a node that sticks to top", async () => { + it("renders a node that sticks to top", () => { plainRender({_("The Header")}); const content = screen.getByText("The Header"); const container = content.parentNode as HTMLElement; @@ -154,13 +161,13 @@ describe("Page", () => { expect(console.error).toHaveBeenCalledWith(expect.stringContaining("must have either")); }); - it("renders a section node", async () => { + 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", async () => { + it("adds the aria-labelledby attribute when title is given but aria-label is not", () => { const { rerender } = plainRender( {_("The Content")}, ); @@ -188,7 +195,7 @@ describe("Page", () => { expect(section).not.toHaveAttribute("aria-labelledby"); }); - it("renders given content props (title, value, description, actions, and children (content)", async () => { + it("renders given content props (title, value, description, actions, and children (content)", () => { plainRender( { * types, "To" and "number", without a TypeScript complain. To know more, see * https://github.com/remix-run/react-router/issues/10505#issuecomment-2237126223 */ -const Back = ({ children, ...props }: ButtonProps) => { +const Back = ({ children, ...props }: Omit) => { const navigate = useNavigate(); return ( - ); From a2241d9e8c8a5bc0633d76fd783285b0e1d1b854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 13 Sep 2024 11:01:12 +0100 Subject: [PATCH 18/19] fix(web): updates from code review --- web/src/components/core/Page.tsx | 2 +- web/src/components/core/PasswordAndConfirmationInput.tsx | 2 +- web/src/components/layout/Flex.tsx | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index 391d62d420..4e736c911b 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -59,7 +59,7 @@ type SectionProps = { value?: React.ReactNode; /** Elements to be rendered in the section footer */ actions?: React.ReactNode; - /** As sort as possible yet as much as needed text for describing what the section is about, if needed */ + /** 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"]; diff --git a/web/src/components/core/PasswordAndConfirmationInput.tsx b/web/src/components/core/PasswordAndConfirmationInput.tsx index efa6714b34..afcb448659 100644 --- a/web/src/components/core/PasswordAndConfirmationInput.tsx +++ b/web/src/components/core/PasswordAndConfirmationInput.tsx @@ -26,7 +26,7 @@ import { _ } from "~/i18n"; // TODO: // * add documentation, -// * allow working only in uncontrlled mode if needed, and +// * allow working only in uncontrolled mode if needed, and // * improve the showErrors thingy type PasswordAndConfirmationInputProps = { diff --git a/web/src/components/layout/Flex.tsx b/web/src/components/layout/Flex.tsx index 6246a81cc2..35ce2333c5 100644 --- a/web/src/components/layout/Flex.tsx +++ b/web/src/components/layout/Flex.tsx @@ -37,7 +37,9 @@ import { Flex as PFFlex, FlexProps, FlexItem, FlexItemProps } from "@patternfly/ */ // NOTE: PF/Flex#order prop is missing "sm" breakpoint -// NOTE: Don't know why these ommited props are being captured otherwise +// NOTE: The ommited props match the extends constraint becuase 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; From 1dfad2707c66d161a4a062491965ebeef7f06497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz?= <1691872+dgdavid@users.noreply.github.com> Date: Fri, 13 Sep 2024 11:11:42 +0100 Subject: [PATCH 19/19] fix(web): typos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Imobach González Sosa --- web/src/components/layout/Flex.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/layout/Flex.tsx b/web/src/components/layout/Flex.tsx index 35ce2333c5..e8fb988e31 100644 --- a/web/src/components/layout/Flex.tsx +++ b/web/src/components/layout/Flex.tsx @@ -37,7 +37,7 @@ import { Flex as PFFlex, FlexProps, FlexItem, FlexItemProps } from "@patternfly/ */ // NOTE: PF/Flex#order prop is missing "sm" breakpoint -// NOTE: The ommited props match the extends constraint becuase they are typed +// 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 = {