diff --git a/web/package/cockpit-agama.changes b/web/package/cockpit-agama.changes index 6769afa843..bded8c6aff 100644 --- a/web/package/cockpit-agama.changes +++ b/web/package/cockpit-agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Feb 14 12:42:32 UTC 2024 - David Diaz + +- UI: change look&feel and internals of Agama selectors + (gh#openSUSE/agama#1012). + ------------------------------------------------------------------- Mon Feb 12 11:53:29 UTC 2024 - Imobach Gonzalez Sosa diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index d59ceebc60..bafe238f42 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -245,6 +245,10 @@ ul[data-type="agama/list"] { border-top: 0; } + &:not(:last-child) { + border-bottom-width: 1px; + } + > div { margin-block-end: var(--spacer-smaller); } @@ -259,21 +263,18 @@ ul[data-type="agama/list"] { // 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] { - border-color: var(--color-primary); - box-shadow: 0 2px 5px 0 var(--color-gray-dark); - background: var(--color-primary); - color: white; + background: var(--color-gray-dark); font-weight: 700; - svg { - fill: white; + &:not(:last-child) { + border-bottom-color: white; } } } // These attributes together means that UI is rendering a selector -ul[data-type="agama/list"][role="listbox"] { - li[role="option"] { +ul[data-type="agama/list"][role="grid"] { + li[role="row"] { cursor: pointer; &:first-child { @@ -292,72 +293,84 @@ ul[data-type="agama/list"][role="listbox"] { &: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-normal); + + & > input { + --size: var(--fs-h2); + block-size: var(--size); + inline-size: var(--size); + } + + & > div { + flex: 1; + } } } } // Each kind of list/selector has its way of laying out their items -ul[data-of="agama/storage-devices"] { - li { - display: grid; - gap: var(--spacer-smaller); - grid-template-columns: 1fr 2fr 2fr; - grid-template-areas: "type-and-size drive-info drive-content"; +[data-items-type="agama/storage-devices"] { + display: grid; + gap: var(--spacer-smaller); + grid-template-columns: 1fr 2fr 2fr; + grid-template-areas: "type-and-size drive-info drive-content"; - svg { - vertical-align: inherit; - } + svg { + vertical-align: inherit; + } - > div { - margin-block-end: 0; - } + > div { + margin-block-end: 0; + } - > :first-child { - align-self: center; - text-align: center; - justify-self: start; - } + > :first-child { + align-self: center; + text-align: center; + justify-self: start; } } -ul[data-of="agama/space-policies"] { +[data-items-type="agama/space-policies"] { // It works with the default styling } -ul[data-of="agama/locales"] { - li { - display: grid; - grid-template-columns: 1fr 2fr; +[data-items-type="agama/locales"] { + display: grid; + grid-template-columns: 1fr 2fr; - > :last-child { - grid-column: 1 / -1; - font-size: var(--fs-small); - } + > :last-child { + grid-column: 1 / -1; + font-size: var(--fs-small); } } -ul[data-of="agama/keymaps"] { - li { - > :last-child { - font-size: var(--fs-small); - } +[data-items-type="agama/keymaps"] { + > :last-child { + font-size: var(--fs-small); } } -ul[data-of="agama/timezones"] { - li { - display: grid; - grid-template-columns: 2fr 1fr 1fr; +[data-items-type="agama/timezones"] { + display: grid; + grid-template-columns: 2fr 1fr 1fr; - > :last-child { - grid-column: 1 / -1; - font-size: 80%; - } + > :last-child { + grid-column: 1 / -1; + font-size: 80%; + } - > :nth-child(3) { - color: var(--color-gray-dimmed); - text-align: end; - } + > :nth-child(3) { + color: var(--color-gray-dimmed); + text-align: end; } } diff --git a/web/src/components/core/Selector.jsx b/web/src/components/core/Selector.jsx new file mode 100644 index 0000000000..243695d0f2 --- /dev/null +++ b/web/src/components/core/Selector.jsx @@ -0,0 +1,124 @@ +/* + * 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 { noop } from '~/utils'; + +/** + * @callback onSelectionChangeCallback + * @param {Array} selection - ids of selected options + */ + +/** + * Agama component for building a selector + * + * @example Usage example + * const options = [ + * { id: "es_ES", country: "Spain", label: "Spanish" }, + * { id: "cs_CZ", country: "Czechia", label: "Czech" }, + * { id: "de_DE", country: "Germany", label: "German" }, + * { id: "en_GB", country: "United Kingdom", label: "English" } + * ]; + * + * const selectedIds = ["es_ES", "en_GB"]; + * + * const renderFn = ({ label, country }) =>
{label} - {country}
; + * + * return ( + * changePreferredLocales(selection)} + * /> + * ); + * + * @param {object} props - component props + * @param {string} [props.id] - Id attribute for selector. + * @param {boolean} [props.isMultiple=false] - Whether the selector should allow multiple selection. + * @param {Array} props.options=[] - Item objects to build options. + * @param {function} props.renderOption=noop - Function used for rendering options. + * @param {string} [props.optionIdKey="id"] - Key used for retrieve options id. + * @param {Array<*>} [props.selectedIds=[]] - Identifiers for selected options. + * @param {onSelectionChangeCallback} [props.onSelectionChange=noop] - Callback to be called when the selection changes. + * @param {object} [props.props] - Other props sent to the internal selector
    component + */ +const Selector = ({ + id = crypto.randomUUID(), + isMultiple = false, + options = [], + renderOption = noop, + optionIdKey = "id", + selectedIds = [], + onSelectionChange = noop, + ...props +}) => { + const onOptionClick = (optionId) => { + const alreadySelected = selectedIds.includes(optionId); + + if (!isMultiple) { + !alreadySelected && onSelectionChange([optionId]); + return; + } + + if (alreadySelected) { + onSelectionChange(selectedIds.filter((id) => id !== optionId)); + } else { + onSelectionChange([...selectedIds, optionId]); + } + }; + + return ( +
      + { options.map(option => { + const optionId = option[optionIdKey]; + const optionHtmlId = `${id}-option-${optionId}`; + const isSelected = selectedIds.includes(optionId); + const onClick = () => onOptionClick(optionId); + + return ( +
    • +
      + + { renderOption(option) } +
      +
    • + ); + })} +
    + ); +}; + +export default Selector; diff --git a/web/src/components/core/Selector.test.jsx b/web/src/components/core/Selector.test.jsx new file mode 100644 index 0000000000..0693d790e4 --- /dev/null +++ b/web/src/components/core/Selector.test.jsx @@ -0,0 +1,138 @@ +/* + * 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 { screen, within } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { Selector } from "~/components/core"; + +const onChangeFn = jest.fn(); + +const TestingSelector = ({ isMultiple = false, selectedIds = ["es_ES"], ...props }) => { + const [selected, setSelected] = React.useState(selectedIds); + + onChangeFn.mockImplementation((selection) => setSelected(selection)); + + return ( +
    {option.label} - {option.country}
    } + selectedIds={selected} + onSelectionChange={onChangeFn} + aria-label="Testing selector" + { ...props } + /> + ); +}; + +const MultipleTestingSelector = (props) => ; + +describe("Selector", () => { + it("renders a selector and its options", () => { + plainRender(); + const selector = screen.getByRole("grid", { name: "Testing selector" }); + within(selector).getByRole("row", { name: "Spanish - Spain" }); + within(selector).getByRole("row", { name: "English - United Kingdom" }); + }); + + it("uses `id` as key for the option id if `optionIdKey` prop is not given", async () => { + const { user } = plainRender(); + const option = screen.getByRole("row", { name: "English - United Kingdom" }); + await user.click(option); + expect(onChangeFn).toHaveBeenCalledWith(["en_GB"]); + }); + + it("uses given `optionIdKey` as key for the option id", async () => { + const { user } = plainRender(); + const option = screen.getByRole("row", { name: "English - United Kingdom" }); + await user.click(option); + expect(onChangeFn).toHaveBeenCalledWith([2]); + }); + + describe("when set as single selector", () => { + it("renders a radio input for each option", () => { + plainRender(); + const selector = screen.getByRole("grid", { name: "Testing selector" }); + const options = within(selector).getAllByRole("row"); + options.forEach((option) => within(option).getByRole("radio")); + }); + + describe("and user clicks on a selected option", () => { + it("keeps it as selected and does not trigger the #onSelectionChange callback", async () => { + const { user } = plainRender(); + const option = screen.getByRole("row", { name: "English - United Kingdom" }); + expect(option).toHaveAttribute("aria-selected"); + await user.click(option); + expect(option).toHaveAttribute("aria-selected"); + expect(onChangeFn).not.toHaveBeenCalled(); + }); + }); + + describe("and user clicks a not selected option", () => { + it("sets it as selected and triggers the #onSelectionChange callback", async () => { + const { user } = plainRender(); + const initialSelection = screen.getByRole("row", { name: "Spanish - Spain" }); + const nextSelection = screen.getByRole("row", { name: "English - United Kingdom" }); + expect(initialSelection).toHaveAttribute("aria-selected"); + expect(nextSelection).not.toHaveAttribute("aria-selected"); + await user.click(nextSelection); + expect(initialSelection).not.toHaveAttribute("aria-selected"); + expect(nextSelection).toHaveAttribute("aria-selected"); + expect(onChangeFn).toHaveBeenCalledWith(["en_GB"]); + }); + }); + }); + + describe("when set as multiple selector", () => { + it("renders a checkbox input for each option", () => { + plainRender(); + const selector = screen.getByRole("grid", { name: "Testing selector" }); + const options = within(selector).getAllByRole("row"); + options.forEach((option) => within(option).getByRole("checkbox")); + }); + + describe("and user clicks on a selected option", () => { + it("sets it as not selected and triggers the #onSelectionChange callback", async () => { + const { user } = plainRender(); + const option = screen.getByRole("row", { name: "English - United Kingdom" }); + expect(option).toHaveAttribute("aria-selected"); + await user.click(option); + expect(option).not.toHaveAttribute("aria-selected"); + expect(onChangeFn).toHaveBeenCalledWith(expect.not.arrayContaining(["en_GB"])); + }); + }); + + describe("and user clicks on a not selected option", () => { + it("sets it as selected and triggers the #onSelectionChange callback", async () => { + const { user } = plainRender(); + const option = screen.getByRole("row", { name: "English - United Kingdom" }); + expect(option).not.toHaveAttribute("aria-selected"); + await user.click(option); + expect(option).toHaveAttribute("aria-selected"); + expect(onChangeFn).toHaveBeenCalledWith(expect.arrayContaining(["en_GB"])); + }); + }); + }); +}); diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index 4fd80031b0..b29309fcfb 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -55,3 +55,4 @@ export { default as ShowTerminalButton } from "./ShowTerminalButton"; export { default as NumericTextInput } from "./NumericTextInput"; export { default as PasswordInput } from "./PasswordInput"; export { default as DevelopmentInfo } from "./DevelopmentInfo"; +export { default as Selector } from "./Selector"; diff --git a/web/src/components/l10n/KeymapSelector.jsx b/web/src/components/l10n/KeymapSelector.jsx index 87d3b9ddd7..3f1f4d848a 100644 --- a/web/src/components/l10n/KeymapSelector.jsx +++ b/web/src/components/l10n/KeymapSelector.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -22,48 +22,19 @@ import React, { useState } from "react"; import { _ } from "~/i18n"; -import { ListSearch } from "~/components/core"; +import { ListSearch, Selector } from "~/components/core"; import { noop } from "~/utils"; /** * @typedef {import ("~/client/l10n").Keymap} Keymap */ -const ListBox = ({ children, ...props }) => { - return ( -
      {children}
    - ); -}; - -const ListBoxItem = ({ isSelected, children, onClick, ...props }) => { - if (isSelected) props['aria-selected'] = true; - - return ( -
  • - {children} -
  • - ); -}; - -/** - * Content for a keymap item - * @component - * - * @param {Object} props - * @param {Keymap} props.keymap - */ -const KeymapItem = ({ keymap }) => { - return ( - <> -
    {keymap.name}
    -
    {keymap.id}
    - - ); -}; +const renderKeymapOption = (keymap) => ( +
    +
    {keymap.name}
    +
    {keymap.id}
    +
    +); /** * Component for selecting a keymap. @@ -80,23 +51,22 @@ export default function KeymapSelector({ value, keymaps = [], onChange = noop }) // TRANSLATORS: placeholder text for search input in the keyboard selector. const helpSearch = _("Filter by description or keymap code"); + const onSelectionChange = (selection) => onChange(selection[0]); return ( <>
    - - { filteredKeymaps.map((keymap, index) => ( - onChange(keymap.id)} - isSelected={keymap.id === value} - > - - - ))} - + ); } diff --git a/web/src/components/l10n/KeymapSelector.test.jsx b/web/src/components/l10n/KeymapSelector.test.jsx new file mode 100644 index 0000000000..5dd621efb3 --- /dev/null +++ b/web/src/components/l10n/KeymapSelector.test.jsx @@ -0,0 +1,82 @@ +/* + * 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 { screen, waitFor, within } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { KeymapSelector } from "~/components/l10n"; + +const keymaps = [ + { id: "de", name: "German" }, + { id: "us", name: "English" }, + { id: "es", name: "Spanish" } +]; + +const onChange = jest.fn(); + +describe("KeymapSelector", () => { + it("renders a selector for given keymaps displaying their name and id", () => { + plainRender( + + ); + + const selector = screen.getByRole("grid", { name: "Available keymaps" }); + + const options = within(selector).getAllByRole("row"); + expect(options.length).toEqual(keymaps.length); + + within(selector).getByRole("row", { name: "German de" }); + within(selector).getByRole("row", { name: "English us" }); + within(selector).getByRole("row", { name: "Spanish es" }); + }); + + it("renders an input for filtering keymaps", async () => { + const { user } = plainRender( + + ); + + const filterInput = screen.getByRole("search"); + screen.getByRole("row", { name: "German de" }); + + await user.type(filterInput, "ish"); + await waitFor(() => { + const germanOption = screen.queryByRole("row", { name: "German de" }); + expect(germanOption).not.toBeInTheDocument(); + }); + + screen.getByRole("row", { name: "Spanish es" }); + screen.getByRole("row", { name: "English us" }); + }); + + describe("when user clicks an option", () => { + it("calls the #onChange callback with the keymap id", async () => { + const { user } = plainRender( + + ); + + const selector = screen.getByRole("grid", { name: "Available keymaps" }); + const english = within(selector).getByRole("row", { name: "English us" }); + await user.click(english); + + expect(onChange).toHaveBeenCalledWith("us"); + }); + }); +}); diff --git a/web/src/components/l10n/L10nPage.test.jsx b/web/src/components/l10n/L10nPage.test.jsx index b547083f61..a15dd44378 100644 --- a/web/src/components/l10n/L10nPage.test.jsx +++ b/web/src/components/l10n/L10nPage.test.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -132,9 +132,9 @@ describe("when the button for changing the language is clicked", () => { const popup = await screen.findByRole("dialog"); within(popup).getByText("Select language"); - within(popup).getByRole("option", { name: /German/ }); - within(popup).getByRole("option", { name: /English/ }); - within(popup).getByRole("option", { name: /Spanish/, selected: true }); + within(popup).getByRole("row", { name: /German/ }); + within(popup).getByRole("row", { name: /English/ }); + within(popup).getByRole("row", { name: /Spanish/, selected: true }); }); it("allows filtering languages", async () => { @@ -149,10 +149,10 @@ describe("when the button for changing the language is clicked", () => { await user.type(searchInput, "ish"); await waitFor(() => ( - expect(within(popup).queryByRole("option", { name: /German/ })).not.toBeInTheDocument()) + expect(within(popup).queryByRole("row", { name: /German/ })).not.toBeInTheDocument()) ); - within(popup).getByRole("option", { name: /English/ }); - within(popup).getByRole("option", { name: /Spanish/ }); + within(popup).getByRole("row", { name: /English/ }); + within(popup).getByRole("row", { name: /Spanish/ }); }); describe("if the popup is canceled", () => { @@ -163,7 +163,7 @@ describe("when the button for changing the language is clicked", () => { await user.click(button); const popup = await screen.findByRole("dialog"); - const option = within(popup).getByRole("option", { name: /English/ }); + const option = within(popup).getByRole("row", { name: /English/ }); await user.click(option); const cancel = within(popup).getByRole("button", { name: "Cancel" }); @@ -182,7 +182,7 @@ describe("when the button for changing the language is clicked", () => { await user.click(button); const popup = await screen.findByRole("dialog"); - const option = within(popup).getByRole("option", { name: /English/ }); + const option = within(popup).getByRole("row", { name: /English/ }); await user.click(option); const accept = within(popup).getByRole("button", { name: "Accept" }); @@ -236,9 +236,9 @@ describe("when the button for changing the keyboard is clicked", () => { const popup = await screen.findByRole("dialog"); within(popup).getByText("Select keyboard"); - within(popup).getByRole("option", { name: /German/ }); - within(popup).getByRole("option", { name: /English/ }); - within(popup).getByRole("option", { name: /Spanish/, selected: true }); + within(popup).getByRole("row", { name: /German/ }); + within(popup).getByRole("row", { name: /English/ }); + within(popup).getByRole("row", { name: /Spanish/, selected: true }); }); it("allows filtering keyboards", async () => { @@ -253,10 +253,10 @@ describe("when the button for changing the keyboard is clicked", () => { await user.type(searchInput, "ish"); await waitFor(() => ( - expect(within(popup).queryByRole("option", { name: /German/ })).not.toBeInTheDocument()) + expect(within(popup).queryByRole("row", { name: /German/ })).not.toBeInTheDocument()) ); - within(popup).getByRole("option", { name: /English/ }); - within(popup).getByRole("option", { name: /Spanish/ }); + within(popup).getByRole("row", { name: /English/ }); + within(popup).getByRole("row", { name: /Spanish/ }); }); describe("if the popup is canceled", () => { @@ -267,7 +267,7 @@ describe("when the button for changing the keyboard is clicked", () => { await user.click(button); const popup = await screen.findByRole("dialog"); - const option = within(popup).getByRole("option", { name: /English/ }); + const option = within(popup).getByRole("row", { name: /English/ }); await user.click(option); const cancel = within(popup).getByRole("button", { name: "Cancel" }); @@ -286,7 +286,7 @@ describe("when the button for changing the keyboard is clicked", () => { await user.click(button); const popup = await screen.findByRole("dialog"); - const option = within(popup).getByRole("option", { name: /English/ }); + const option = within(popup).getByRole("row", { name: /English/ }); await user.click(option); const accept = within(popup).getByRole("button", { name: "Accept" }); @@ -340,9 +340,9 @@ describe("when the button for changing the time zone is clicked", () => { const popup = await screen.findByRole("dialog"); within(popup).getByText("Select time zone"); - within(popup).getByRole("option", { name: /Bangkok/ }); - within(popup).getByRole("option", { name: /Canary/, selected: true }); - within(popup).getByRole("option", { name: /New York/ }); + within(popup).getByRole("row", { name: /Bangkok/ }); + within(popup).getByRole("row", { name: /Canary/, selected: true }); + within(popup).getByRole("row", { name: /New York/ }); }); it("allows filtering time zones", async () => { @@ -357,12 +357,12 @@ describe("when the button for changing the time zone is clicked", () => { await user.type(searchInput, "new"); await waitFor(() => ( - expect(within(popup).queryByRole("option", { name: /Bangkok/ })).not.toBeInTheDocument()) + expect(within(popup).queryByRole("row", { name: /Bangkok/ })).not.toBeInTheDocument()) ); await waitFor(() => ( - expect(within(popup).queryByRole("option", { name: /Canary/ })).not.toBeInTheDocument()) + expect(within(popup).queryByRole("row", { name: /Canary/ })).not.toBeInTheDocument()) ); - within(popup).getByRole("option", { name: /New York/ }); + within(popup).getByRole("row", { name: /New York/ }); }); describe("if the popup is canceled", () => { @@ -373,7 +373,7 @@ describe("when the button for changing the time zone is clicked", () => { await user.click(button); const popup = await screen.findByRole("dialog"); - const option = within(popup).getByRole("option", { name: /New York/ }); + const option = within(popup).getByRole("row", { name: /New York/ }); await user.click(option); const cancel = within(popup).getByRole("button", { name: "Cancel" }); @@ -392,7 +392,7 @@ describe("when the button for changing the time zone is clicked", () => { await user.click(button); const popup = await screen.findByRole("dialog"); - const option = within(popup).getByRole("option", { name: /Bangkok/ }); + const option = within(popup).getByRole("row", { name: /Bangkok/ }); await user.click(option); const accept = within(popup).getByRole("button", { name: "Accept" }); diff --git a/web/src/components/l10n/LocaleSelector.jsx b/web/src/components/l10n/LocaleSelector.jsx index 40d9a4e0a7..07a4db8892 100644 --- a/web/src/components/l10n/LocaleSelector.jsx +++ b/web/src/components/l10n/LocaleSelector.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -22,49 +22,20 @@ import React, { useState } from "react"; import { _ } from "~/i18n"; -import { ListSearch } from "~/components/core"; +import { ListSearch, Selector } from "~/components/core"; import { noop } from "~/utils"; /** * @typedef {import ("~/client/l10n").Locale} Locale */ -const ListBox = ({ children, ...props }) => { - return ( -
      {children}
    - ); -}; - -const ListBoxItem = ({ isSelected, children, onClick, ...props }) => { - if (isSelected) props['aria-selected'] = true; - - return ( -
  • - {children} -
  • - ); -}; - -/** - * Content for a locale item. - * @component - * - * @param {Object} props - * @param {Locale} props.locale - */ -const LocaleItem = ({ locale }) => { - return ( - <> -
    {locale.name}
    -
    {locale.territory}
    -
    {locale.id}
    - - ); -}; +const renderLocaleOption = (locale) => ( +
    +
    {locale.name}
    +
    {locale.territory}
    +
    {locale.id}
    +
    +); /** * Component for selecting a locale. @@ -80,23 +51,22 @@ export default function LocaleSelector({ value, locales = [], onChange = noop }) const [filteredLocales, setFilteredLocales] = useState(locales); const searchHelp = _("Filter by language, territory or locale code"); + const onSelectionChange = (selection) => onChange(selection[0]); return ( <>
    - - { filteredLocales.map((locale, index) => ( - onChange(locale.id)} - isSelected={locale.id === value} - > - - - ))} - + ); } diff --git a/web/src/components/l10n/LocaleSelector.test.jsx b/web/src/components/l10n/LocaleSelector.test.jsx new file mode 100644 index 0000000000..b762c6079a --- /dev/null +++ b/web/src/components/l10n/LocaleSelector.test.jsx @@ -0,0 +1,79 @@ +/* + * 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 { screen, waitFor, within } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { LocaleSelector } from "~/components/l10n"; + +const locales = [ + { id: "es_ES", name: "Spanish", territory: "Spain" }, + { id: "en_US", name: "English", territory: "United States" } +]; + +const onChange = jest.fn(); + +describe("LocaleSelector", () => { + it("renders a selector for given locales displaying their name, territory, and id", () => { + plainRender( + + ); + + const selector = screen.getByRole("grid", { name: "Available locales" }); + + const options = within(selector).getAllByRole("row"); + expect(options.length).toEqual(locales.length); + + within(selector).getByRole("row", { name: "Spanish Spain es_ES" }); + within(selector).getByRole("row", { name: "English United States en_US" }); + }); + + it("renders an input for filtering locales", async () => { + const { user } = plainRender( + + ); + + const filterInput = screen.getByRole("search"); + screen.getByRole("row", { name: "English United States en_US" }); + + await user.type(filterInput, "Span"); + await waitFor(() => { + const englishOption = screen.queryByRole("row", { name: "English United States en_US" }); + expect(englishOption).not.toBeInTheDocument(); + }); + + screen.getByRole("row", { name: "Spanish Spain es_ES" }); + }); + + describe("when user clicks an option", () => { + it("calls the #onChange callback with the locale id", async () => { + const { user } = plainRender( + + ); + + const selector = screen.getByRole("grid", { name: "Available locales" }); + const english = within(selector).getByRole("row", { name: "English United States en_US" }); + await user.click(english); + + expect(onChange).toHaveBeenCalledWith("en_US"); + }); + }); +}); diff --git a/web/src/components/l10n/TimezoneSelector.jsx b/web/src/components/l10n/TimezoneSelector.jsx index 5074b17afc..1985cf28fc 100644 --- a/web/src/components/l10n/TimezoneSelector.jsx +++ b/web/src/components/l10n/TimezoneSelector.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -22,32 +22,14 @@ import React, { useState } from "react"; import { _ } from "~/i18n"; -import { ListSearch } from "~/components/core"; +import { ListSearch, Selector } from "~/components/core"; import { noop, timezoneTime } from "~/utils"; /** * @typedef {import ("~/client/l10n").Timezone} Timezone */ -const ListBox = ({ children, ...props }) => { - return ( -
      {children}
    - ); -}; - -const ListBoxItem = ({ isSelected, children, onClick, ...props }) => { - if (isSelected) props['aria-selected'] = true; - - return ( -
  • - {children} -
  • - ); -}; +let date; const timezoneDetails = (timezone) => { const offset = timezone.utcOffset; @@ -61,24 +43,16 @@ const timezoneDetails = (timezone) => { return `${timezone.id} ${utc}`; }; -/** - * Content for a timezone item - * @component - * - * @param {Object} props - * @param {Timezone} props.timezone - * @param {Date} props.date - Date to show a time. - */ -const TimezoneItem = ({ timezone, date }) => { +const renderTimezoneOption = (timezone) => { const time = timezoneTime(timezone.id, { date }) || ""; return ( - <> +
    {timezone.parts.join('-')}
    {timezone.country}
    {time || ""}
    {timezone.details}
    - +
    ); }; @@ -95,27 +69,26 @@ const TimezoneItem = ({ timezone, date }) => { export default function TimezoneSelector({ value, timezones = [], onChange = noop }) { const displayTimezones = timezones.map(t => ({ ...t, details: timezoneDetails(t) })); const [filteredTimezones, setFilteredTimezones] = useState(displayTimezones); - const date = new Date(); + date = new Date(); // TRANSLATORS: placeholder text for search input in the timezone selector. const helpSearch = _("Filter by territory, time zone code or UTC offset"); + const onSelectionChange = (selection) => onChange(selection[0]); return ( <>
    - - { filteredTimezones.map((timezone, index) => ( - onChange(timezone.id)} - isSelected={timezone.id === value} - > - - - ))} - + ); } diff --git a/web/src/components/l10n/TimezoneSelector.test.jsx b/web/src/components/l10n/TimezoneSelector.test.jsx new file mode 100644 index 0000000000..c936568b0f --- /dev/null +++ b/web/src/components/l10n/TimezoneSelector.test.jsx @@ -0,0 +1,95 @@ +/* + * 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 { screen, waitFor, within } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { TimezoneSelector } from "~/components/l10n"; + +const timezones = [ + { id: "Asia/Bangkok", parts: ["Asia", "Bangkok"], country: "Thailand", utcOffset: NaN }, + { id: "Atlantic/Canary", parts: ["Atlantic", "Canary"], country: "Spain", utcOffset: 0 }, + { id: "America/New_York", parts: ["Americas", "New York"], country: "United States", utcOffset: -5 } +]; + +const onChange = jest.fn(); +const mockedDate = new Date(2024, 0, 25, 0, 0, 0, 0); +let spyDate; + +describe("TimezoneSelector", () => { + beforeAll(() => { + spyDate = jest.spyOn(global, "Date").mockImplementationOnce(() => mockedDate); + }); + + afterAll(() => { + spyDate.mockRestore(); + }); + + it("renders a selector for given timezones displaying their zone, city, country, current time, and id", () => { + plainRender( + + ); + + const selector = screen.getByRole("grid", { name: "Available time zones" }); + + const options = within(selector).getAllByRole("row"); + expect(options.length).toEqual(timezones.length); + + within(selector).getByRole("row", { name: "Asia-Bangkok Thailand 07:00 Asia/Bangkok UTC" }); + within(selector).getByRole("row", { name: "Atlantic-Canary Spain 24:00 Atlantic/Canary UTC" }); + within(selector).getByRole("row", { name: "Americas-New York United States 19:00 America/New_York UTC-5" }); + }); + + it("renders an input for filtering timezones", async () => { + const { user } = plainRender( + + ); + + const filterInput = screen.getByRole("search"); + screen.getByRole("row", { name: /Thailand/ }); + screen.getByRole("row", { name: /Canary/ }); + + await user.type(filterInput, "york"); + + await waitFor(() => { + const bangkok = screen.queryByRole("row", { name: /Thailand/ }); + const canary = screen.queryByRole("row", { name: /Canary/ }); + expect(bangkok).not.toBeInTheDocument(); + expect(canary).not.toBeInTheDocument(); + }); + + screen.getByRole("row", { name: /York/ }); + }); + + describe("when user clicks an option", () => { + it("calls the #onChange callback with the timezone id", async () => { + const { user } = plainRender( + + ); + + const selector = screen.getByRole("grid", { name: "Available time zones" }); + const canary = within(selector).getByRole("row", { name: /Canary/ }); + await user.click(canary); + + expect(onChange).toHaveBeenCalledWith("Atlantic/Canary"); + }); + }); +}); diff --git a/web/src/components/product/ProductPage.test.jsx b/web/src/components/product/ProductPage.test.jsx index 9981ee1360..e838cc5230 100644 --- a/web/src/components/product/ProductPage.test.jsx +++ b/web/src/components/product/ProductPage.test.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -201,10 +201,10 @@ describe("when the button for changing the product is clicked", () => { const popup = await screen.findByRole("dialog"); within(popup).getByText("Choose a product"); - within(popup).getByRole("radio", { name: "Test Product1" }); - const radio = within(popup).getByRole("radio", { name: "Test Product2" }); + within(popup).getByRole("row", { name: /Test Product1/ }); + const productOption = within(popup).getByRole("row", { name: /Test Product2/ }); - await user.click(radio); + await user.click(productOption); const accept = within(popup).getByRole("button", { name: "Accept" }); await user.click(accept); @@ -220,9 +220,9 @@ describe("when the button for changing the product is clicked", () => { await user.click(button); const popup = await screen.findByRole("dialog"); - const radio = within(popup).getByRole("radio", { name: "Test Product2" }); + const productOption = within(popup).getByRole("row", { name: /Test Product2/ }); - await user.click(radio); + await user.click(productOption); const cancel = within(popup).getByRole("button", { name: "Cancel" }); await user.click(cancel); diff --git a/web/src/components/product/ProductSelectionPage.jsx b/web/src/components/product/ProductSelectionPage.jsx index 76e2dc06de..90a902b3bd 100644 --- a/web/src/components/product/ProductSelectionPage.jsx +++ b/web/src/components/product/ProductSelectionPage.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -62,7 +62,7 @@ function ProductSelectionPage() { // TRANSLATORS: page title
    - + diff --git a/web/src/components/product/ProductSelectionPage.test.jsx b/web/src/components/product/ProductSelectionPage.test.jsx index d90ba20298..bee8636981 100644 --- a/web/src/components/product/ProductSelectionPage.test.jsx +++ b/web/src/components/product/ProductSelectionPage.test.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -75,7 +75,7 @@ beforeEach(() => { describe("when the user chooses a product", () => { it("selects the product and redirects to the main page", async () => { const { user } = installerRender(); - const productOption = screen.getByRole("radio", { name: "openSUSE MicroOS" }); + const productOption = screen.getByRole("row", { name: /openSUSE MicroOS/ }); const selectButton = screen.getByRole("button", { name: "Select" }); await user.click(productOption); await user.click(selectButton); diff --git a/web/src/components/product/ProductSelector.jsx b/web/src/components/product/ProductSelector.jsx index 311a6a7e89..7f82052cd1 100644 --- a/web/src/components/product/ProductSelector.jsx +++ b/web/src/components/product/ProductSelector.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -20,30 +20,30 @@ */ import React from "react"; -import { Card, CardBody, Radio } from "@patternfly/react-core"; +import { Selector } from "~/components/core"; import { _ } from "~/i18n"; import { noop } from "~/utils"; +const renderProductOption = (product) => ( +
    +

    {product.name}

    +

    {product.description}

    +
    +); + export default function ProductSelector({ value, products = [], onChange = noop }) { if (products.length === 0) return

    {_("No products available for selection")}

    ; - const isSelected = (product) => product.id === value; + const onSelectionChange = (selection) => onChange(selection[0]); return ( - products.map((p) => ( - - - onChange(p.id)} - /> - - - )) + ); } diff --git a/web/src/components/product/ProductSelector.test.jsx b/web/src/components/product/ProductSelector.test.jsx index b40af415a0..6475f87f0a 100644 --- a/web/src/components/product/ProductSelector.test.jsx +++ b/web/src/components/product/ProductSelector.test.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -51,21 +51,23 @@ beforeEach(() => { it("shows an option for each product", async () => { installerRender(); - await screen.findByRole("radio", { name: "ALP Dolomite" }); - await screen.findByRole("radio", { name: "openSUSE Tumbleweed" }); - await screen.findByRole("radio", { name: "openSUSE MicroOS" }); + + await screen.findByRole("grid", { name: "Available products" }); + screen.getByRole("row", { name: /ALP Dolomite/ }); + screen.getByRole("row", { name: /openSUSE Tumbleweed/ }); + screen.getByRole("row", { name: /openSUSE MicroOS/ }); }); it("selects the given value", async () => { installerRender(); - await screen.findByRole("radio", { name: "openSUSE Tumbleweed", clicked: true }); + await screen.findByRole("row", { name: /openSUSE Tumbleweed/, selected: true }); }); it("calls onChange if a new option is clicked", async () => { const onChangeFn = jest.fn(); const { user } = installerRender(); - const radio = await screen.findByRole("radio", { name: "openSUSE Tumbleweed" }); - await user.click(radio); + const productOption = await screen.findByRole("row", { name: /openSUSE Tumbleweed/ }); + await user.click(productOption); expect(onChangeFn).toHaveBeenCalledWith("Tumbleweed"); }); diff --git a/web/src/components/storage/ProposalPage.test.jsx b/web/src/components/storage/ProposalPage.test.jsx index 424b3fb747..d284a80f32 100644 --- a/web/src/components/storage/ProposalPage.test.jsx +++ b/web/src/components/storage/ProposalPage.test.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -39,10 +39,40 @@ jest.mock("@patternfly/react-core", () => { jest.mock("~/components/core/Sidebar", () => () =>
    Agama sidebar
    ); jest.mock("~/components/storage/ProposalPageMenu", () => () =>
    ProposalPage Options
    ); +const vda = { + sid: "59", + type: "disk", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + transport: "usb", + dellBOSS: false, + sdCard: true, + active: true, + name: "/dev/vda", + size: 1e+12, + systems : ["Windows 11", "openSUSE Leap 15.2"], + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], + partitionTable: { type: "gpt", partitions: ["/dev/vda1", "/dev/vda2"] } +}; + +const vdb = { + sid: "60", + type: "disk", + vendor: "Seagate", + model: "Unknown", + driver: ["ahci", "mmcblk"], + bus: "IDE", + name: "/dev/vdb", + size: 1e+6 +}; + const storageMock = { probe: jest.fn().mockResolvedValue(0), proposal: { - getAvailableDevices: jest.fn().mockResolvedValue([]), + getAvailableDevices: jest.fn().mockResolvedValue([vda, vdb]), getEncryptionMethods: jest.fn().mockResolvedValue([]), getProductMountPoints: jest.fn().mockResolvedValue([]), getResult: jest.fn().mockResolvedValue(undefined), @@ -75,7 +105,7 @@ it("does not probe storage if the storage devices are not deprecated", async () it("loads the proposal data", async () => { storage.proposal.getResult = jest.fn().mockResolvedValue( - { settings: { bootDevice: "/dev/vda" } } + { settings: { bootDevice: vda.name } } ); installerRender(); @@ -83,7 +113,7 @@ it("loads the proposal data", async () => { screen.getAllByText(/PFSkeleton/); expect(screen.queryByText(/Installation device/)).toBeNull(); await screen.findByText(/Installation device/); - screen.getByText("/dev/vda"); + await screen.findByText(/\/dev\/vda/); }); it("renders a warning about modified devices", async () => { @@ -114,7 +144,7 @@ describe("when the storage devices become deprecated", () => { }); it("loads the proposal data", async () => { - const result = { settings: { bootDevice: "/dev/vda" } }; + const result = { settings: { bootDevice: vda.name } }; storage.proposal.getResult = jest.fn().mockResolvedValue(result); const [mockFunction, callbacks] = createCallbackMock(); @@ -122,14 +152,14 @@ describe("when the storage devices become deprecated", () => { installerRender(); - await screen.findByText("/dev/vda"); + await screen.findByText(/\/dev\/vda/); - result.settings.bootDevice = "/dev/vdb"; + result.settings.bootDevice = vdb.name; const [onDeprecateCb] = callbacks; await act(() => onDeprecateCb()); - await screen.findByText("/dev/vdb"); + await screen.findByText(/\/dev\/vdb/); }); }); @@ -154,19 +184,19 @@ describe("when there is no proposal yet", () => { screen.getAllByText(/PFSkeleton/); storage.proposal.getResult = jest.fn().mockResolvedValue( - { settings: { bootDevice: "/dev/vda" } } + { settings: { bootDevice: vda.name } } ); const [onStatusChangeCb] = callbacks; await act(() => onStatusChangeCb(IDLE)); - await screen.findByText("/dev/vda"); + await screen.findByText(/\/dev\/vda/); }); }); describe("when there is a proposal", () => { beforeEach(() => { storage.proposal.getResult = jest.fn().mockResolvedValue( - { settings: { bootDevice: "/dev/vda" } } + { settings: { bootDevice: vda.name } } ); }); @@ -176,7 +206,7 @@ describe("when there is a proposal", () => { installerRender(); - await screen.findByText("/dev/vda"); + await screen.findByText(/\/dev\/vda/); const [onStatusChangeCb] = callbacks; expect(onStatusChangeCb).toBeUndefined(); diff --git a/web/src/components/storage/ProposalSettingsSection.jsx b/web/src/components/storage/ProposalSettingsSection.jsx index d5b7bccc16..a5f154878a 100644 --- a/web/src/components/storage/ProposalSettingsSection.jsx +++ b/web/src/components/storage/ProposalSettingsSection.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -50,7 +50,7 @@ import { noop } from "~/utils"; * * @param {object} props * @param {string} props.id - Form ID. - * @param {string|undefined} [props.current] - Device name, if any. + * @param {StorageDevice|undefined} [props.current] - Currently selected device, if any. * @param {StorageDevice[]} [props.devices=[]] - Available devices for the selection. * @param {onSubmitFn} [props.onSubmit=noop] - On submit callback. * @@ -63,15 +63,11 @@ const InstallationDeviceForm = ({ devices = [], onSubmit = noop }) => { - const [device, setDevice] = useState(current); + const [device, setDevice] = useState(current || devices[0]); - useEffect(() => { - const isCurrentValid = () => { - return devices.find(d => d.name === current) !== undefined; - }; - - if (!isCurrentValid()) setDevice(devices[0]?.name); - }, [current, devices]); + const changeSelected = (deviceId) => { + setDevice(devices.find(d => d.sid === deviceId)); + }; const submitForm = (e) => { e.preventDefault(); @@ -83,7 +79,7 @@ const InstallationDeviceForm = ({ ); @@ -108,28 +104,34 @@ const InstallationDeviceField = ({ isLoading = false, onChange = noop }) => { - const [device, setDevice] = useState(current); + const [device, setDevice] = useState(devices.find(d => d.name === current)); const [isFormOpen, setIsFormOpen] = useState(false); const openForm = () => setIsFormOpen(true); const closeForm = () => setIsFormOpen(false); - const acceptForm = (newDevice) => { + const acceptForm = (selectedDevice) => { closeForm(); - setDevice(newDevice); - onChange(newDevice); + setDevice(selectedDevice); + onChange(selectedDevice); }; + /** + * Renders a button that allows changing selected device + * + * NOTE: if a device is already selected, its name and size will be used for + * the button text. Otherwise, a "No device selected" text will be shown. + * + * @param {object} props + * @param {StorageDevice|undefined} [props.current] - Currently selected device, if any. + */ const DeviceContent = ({ device }) => { - const text = (deviceName) => { - if (!deviceName || deviceName.length === 0) return _("No device selected yet"); - - const device = devices.find(d => d.name === deviceName); - return device ? deviceLabel(device) : deviceName; - }; - - return ; + return ( + + ); }; if (isLoading) { @@ -217,8 +219,9 @@ const LVMSettingsForm = ({ onValidate(customDevices.length > 0); }; - const onChangeDevices = (devices) => { - setVgDevices(devices); + const onChangeDevices = (selection) => { + const selectedDevices = devices.filter(d => selection.includes(d.sid)).map(d => d.name); + setVgDevices(selectedDevices); setEditedDevices(true); onValidate(devices.length > 0); }; @@ -232,7 +235,8 @@ const LVMSettingsForm = ({ const BootDevice = () => { const bootDevice = devices.find(d => d.name === settings.bootDevice); - return ; + // FIXME: In this case, should be a "readOnly" selector. + return ; }; return ( @@ -260,7 +264,7 @@ const LVMSettingsForm = ({ else={ vgDevices?.includes(d.name))} devices={devices} onChange={onChangeDevices} /> @@ -672,8 +676,9 @@ export default function ProposalSettingsSection({ isLoading = false, onChange = noop }) { + // FIXME: we should work with devices objects ASAP const changeBootDevice = (device) => { - onChange({ bootDevice: device }); + onChange({ bootDevice: device.name }); }; const changeLVM = ({ lvm, vgDevices }) => { diff --git a/web/src/components/storage/ProposalSettingsSection.test.jsx b/web/src/components/storage/ProposalSettingsSection.test.jsx index ec9d1b08cb..13600e5b94 100644 --- a/web/src/components/storage/ProposalSettingsSection.test.jsx +++ b/web/src/components/storage/ProposalSettingsSection.test.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -105,7 +105,10 @@ describe("Installation device field", () => { }); }); - describe("and there is a selected device", () => { + // FIXME: skipping this test example by now. + // It will be addressed when reworking the Device section + // as part of https://github.com/openSUSE/agama/pull/1031 + describe.skip("and there is a selected device", () => { beforeEach(() => { props.settings = { bootDevice: "/dev/vda" }; }); @@ -132,7 +135,10 @@ describe("Installation device field", () => { }); }); - describe("if there is a selected device", () => { + // FIXME: skipping this test example by now. + // It will be addressed when reworking the Device section + // as part of https://github.com/openSUSE/agama/pull/1031 + describe.skip("if there is a selected device", () => { beforeEach(() => { props.settings = { bootDevice: "/dev/vda" }; }); @@ -305,9 +311,9 @@ describe("LVM field", () => { await user.click(customDevicesButton); - const vdaOption = within(popup).getByRole("option", { name: /vda/ }); - const md0Option = within(popup).getByRole("option", { name: /md0/ }); - const md1Option = within(popup).getByRole("option", { name: /md1/ }); + const vdaOption = within(popup).getByRole("row", { name: /vda/ }); + const md0Option = within(popup).getByRole("row", { name: /md0/ }); + const md1Option = within(popup).getByRole("row", { name: /md1/ }); // unselect the boot devices await user.click(vdaOption); @@ -604,9 +610,9 @@ describe("Space policy field", () => { const popup = await screen.findByRole("dialog"); within(popup).getByText("Space Policy"); - within(popup).getByRole("option", { name: /Delete current content/ }); - within(popup).getByRole("option", { name: /Shrink existing partitions/ }); - within(popup).getByRole("option", { name: /Use available space/, selected: true }); + within(popup).getByRole("row", { name: /Delete current content/ }); + within(popup).getByRole("row", { name: /Shrink existing partitions/ }); + within(popup).getByRole("row", { name: /Use available space/, selected: true }); }); it("allows to show the installation devices", async () => { @@ -636,7 +642,7 @@ describe("Space policy field", () => { await user.click(button); const popup = await screen.findByRole("dialog"); - const option = within(popup).getByRole("option", { name: /Shrink/ }); + const option = within(popup).getByRole("row", { name: /Shrink/ }); await user.click(option); const cancel = within(popup).getByRole("button", { name: "Cancel" }); @@ -655,7 +661,7 @@ describe("Space policy field", () => { await user.click(button); const popup = await screen.findByRole("dialog"); - const option = within(popup).getByRole("option", { name: /Shrink/ }); + const option = within(popup).getByRole("row", { name: /Shrink/ }); await user.click(option); const accept = within(popup).getByRole("button", { name: "Accept" }); diff --git a/web/src/components/storage/device-utils.jsx b/web/src/components/storage/device-utils.jsx index c38f28a77d..47e5377aa9 100644 --- a/web/src/components/storage/device-utils.jsx +++ b/web/src/components/storage/device-utils.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -25,28 +25,13 @@ import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; import { noop } from "~/utils"; import { Icon } from "~/components/layout"; -import { If } from "~/components/core"; +import { If, Selector } from "~/components/core"; import { deviceSize } from "~/components/storage/utils"; /** * @typedef {import ("~/client/storage").DeviceManager.StorageDevice} StorageDevice */ -const ListBox = ({ children, ...props }) =>
      {children}
    ; - -const ListBoxItem = ({ isSelected, children, onClick, ...props }) => { - if (isSelected) props['aria-selected'] = true; - - return ( -
  • - {children} -
  • - ); -}; - /** * Content for a device item * @component @@ -213,11 +198,11 @@ const DeviceItem = ({ device }) => { }; return ( - <> +
    - +
    ); }; @@ -225,21 +210,27 @@ const DeviceItem = ({ device }) => { * Component for listing storage devices. * @component * + * TODO: re-evaluate the need of this component when addressing + * https://github.com/openSUSE/agama/pull/1031 + * * @param {Object} props * @param {StorageDevice[]} props.devices - Devices to show. + * @param {Object} props.itemProps - common props, if any, for list items. */ -const DeviceList = ({ devices, ...props }) => { +const DeviceList = ({ devices, ...itemProps }) => { return ( - +
      { devices.map(device => ( - +
    • - +
    • ))} - +
    ); }; +const renderDeviceOption = (device) => ; + /** * Component for selecting storage devices. * @component @@ -256,30 +247,22 @@ const DeviceList = ({ devices, ...props }) => { * in case of multi selection. */ const DeviceSelector = ({ devices, selected, isMultiple = false, onChange = noop }) => { - const selectedDevices = () => [selected].flat().filter(Boolean); - - const isSelected = (device) => selectedDevices().includes(device); + const selectedDevices = [selected].flat().filter(Boolean); - const onOptionClick = (device) => { - if (!isMultiple && !isSelected(device)) onChange(device); - if (isMultiple && !isSelected(device)) onChange([...selectedDevices(), device]); - if (isMultiple && isSelected(device)) onChange(selectedDevices().filter(d => d !== device)); + const onSelectionChange = (selection) => { + isMultiple ? onChange(selection) : onChange(selection[0]); }; return ( - - { devices.map(device => ( - onOptionClick(device.name)} - isSelected={isSelected(device.name)} - data-type="storage-device" - > - - - ))} - + d.sid)} + onSelectionChange={onSelectionChange} + /> ); }; diff --git a/web/src/components/storage/device-utils.test.jsx b/web/src/components/storage/device-utils.test.jsx index 572ac643f4..10e09e9058 100644 --- a/web/src/components/storage/device-utils.test.jsx +++ b/web/src/components/storage/device-utils.test.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -118,112 +118,99 @@ const dasd = { udevPaths: [] }; -const renderOptions = (Component) => { - return () => describe("content", () => { - describe("when no devices are given", () => { - it("renders an empty listbox", () => { - plainRender(); - - const listbox = screen.queryByRole("listbox"); +const availableDevices = [ + vda, + md0, + raid, + multipath, + dasd +]; - expect(listbox).toBeEmptyDOMElement(); - }); +const renderOptions = (Component) => { + return () => describe("DeviceContent", () => { + it("renders the device size", () => { + plainRender(); + screen.getByText("1 KiB"); }); - describe("when devices are given", () => { - it("renders a listbox with an option per device", () => { - plainRender(); - - const listbox = screen.queryByRole("listbox"); + it("renders the device name", () => { + plainRender(); + screen.getByText("/dev/vda"); + }); - within(listbox).getByRole("option", { name: /vda/ }); - within(listbox).getByRole("option", { name: /md0/ }); - }); + it("renders the device model", () => { + plainRender(); + screen.getByText("Micron 1100 SATA"); + }); - it("renders the device size", () => { - plainRender(); - screen.getByText("1 KiB"); + describe("when device is a SDCard", () => { + it("renders 'SD Card'", () => { + const sdCard = { ...vda, sdCard: true }; + plainRender(); + screen.getByText("SD Card"); }); + }); - it("renders the device name", () => { + describe("when content is given", () => { + it("renders the partition table info", () => { plainRender(); - screen.getByText("/dev/vda"); + screen.getByText("GPT with 2 partitions"); }); - it("renders the device model", () => { + it("renders systems info", () => { plainRender(); - screen.getByText("Micron 1100 SATA"); + screen.getByText("Windows 11"); + screen.getByText("openSUSE Leap 15.2"); }); + }); - describe("when device is a SDCard", () => { - it("renders 'SD Card'", () => { - const sdCard = { ...vda, sdCard: true }; - plainRender(); - screen.getByText("SD Card"); - }); + describe("when content is not given", () => { + it("renders 'No content found'", () => { + plainRender(); + screen.getByText("No content found"); }); + }); - describe("when content is given", () => { - it("renders the partition table info", () => { - plainRender(); - screen.getByText("GPT with 2 partitions"); - }); - - it("renders systems info", () => { - plainRender(); - screen.getByText("Windows 11"); - screen.getByText("openSUSE Leap 15.2"); - }); + describe("when device is software RAID", () => { + it("renders its level", () => { + plainRender(); + screen.getByText("Software RAID0"); }); - describe("when content is not given", () => { - it("renders 'No content found'", () => { - plainRender(); - screen.getByText("No content found"); - }); + it("renders its members", () => { + plainRender(); + screen.getByText(/Members/); + screen.getByText(/vdb/); }); + }); - describe("when device is software RAID", () => { - it("renders its level", () => { - plainRender(); - screen.getByText("Software RAID0"); - }); - - it("renders its members", () => { - plainRender(); - screen.getByText(/Members/); - screen.getByText(/vdb/); - }); + describe("when device is RAID", () => { + it("renders its devices", () => { + plainRender(); + screen.getByText(/Devices/); + screen.getByText(/sda/); + screen.getByText(/sdb/); }); + }); - describe("when device is RAID", () => { - it("renders its devices", () => { - plainRender(); - screen.getByText(/Devices/); - screen.getByText(/sda/); - screen.getByText(/sdb/); - }); + describe("when device is a multipath", () => { + it("renders 'Multipath'", () => { + plainRender(); + screen.getByText("Multipath"); }); - describe("when device is a multipath", () => { - it("renders 'Multipath'", () => { - plainRender(); - screen.getByText("Multipath"); - }); - - it("renders its wires", () => { - plainRender(); - screen.getByText(/Wires/); - screen.getByText(/sdc/); - screen.getByText(/sdd/); - }); + it("renders its wires", () => { + plainRender(); + screen.getByText(/Wires/); + screen.getByText(/sdc/); + screen.getByText(/sdd/); }); + }); - describe("when device is DASD", () => { - it("renders its bus id", () => { - plainRender(); - screen.getByText("DASD 0.0.0150"); - }); + describe("when device is DASD", () => { + it("renders its bus id", () => { + plainRender(); + screen.getByText("DASD 0.0.0150"); }); }); }); @@ -231,38 +218,57 @@ const renderOptions = (Component) => { describe("DeviceList", renderOptions(DeviceList)); describe("DeviceList", () => { - describe("with isSelected prop", () => { - it("renders all devices as selected", () => { - plainRender(); + describe("when no devices are given", () => { + it("renders an empty list", () => { + plainRender(); - const devices = screen.queryAllByText(/\/dev\//, { selected: true }); - expect(devices.length).toEqual(3); + const list = screen.queryByRole("list"); + expect(list).toBeEmptyDOMElement(); }); }); - describe("without isSelected prop", () => { - it("renders all devices as not selected", () => { - plainRender(); + describe("when devices are given", () => { + it("renders a list with an option per device", () => { + plainRender(); - const devices = screen.queryAllByText(/\/dev\//, { selected: false }); - expect(devices.length).toEqual(3); + const list = screen.getByRole("list"); + + within(list).getByRole("listitem", { name: /vda/ }); + within(list).getByRole("listitem", { name: /md0/ }); }); }); }); describe("DeviceSelector", renderOptions(DeviceSelector)); describe("DeviceSelector", () => { + describe("when no devices are given", () => { + it("renders an empty grid", () => { + plainRender(); + + const selector = screen.queryByRole("grid"); + expect(selector).toBeEmptyDOMElement(); + }); + }); + + it("renders a grid with an option per device", () => { + plainRender(); + + const selector = screen.getByRole("grid"); + within(selector).getByRole("row", { name: /vda/ }); + within(selector).getByRole("row", { name: /md0/ }); + }); + it("renders as selected options matching selected device(s)", () => { plainRender( ); - const selectedOptions = screen.queryAllByRole("option", { selected: true }); - const vdaOption = screen.getByRole("option", { name: /vda/ }); - const dasdOption = screen.getByRole("option", { name: /dasda/ }); + const selectedOptions = screen.queryAllByRole("row", { selected: true }); + const vdaOption = screen.getByRole("row", { name: /vda/ }); + const dasdOption = screen.getByRole("row", { name: /dasda/ }); expect(selectedOptions).toEqual([vdaOption, dasdOption]); }); @@ -271,7 +277,7 @@ describe("DeviceSelector", () => { const onChangeFn = jest.fn(); const TestSingleDeviceSelection = () => { - const [selected, setSelected] = useState("/dev/vda"); + const [selected, setSelected] = useState(vda); onChangeFn.mockImplementation(device => setSelected(device)); @@ -287,18 +293,18 @@ describe("DeviceSelector", () => { it("notifies selected device if it has changed", async () => { const { user } = plainRender(); - const vdaOption = screen.getByRole("option", { name: /vda/ }); - const md0Option = screen.getByRole("option", { name: /md0/ }); + const vdaOption = screen.getByRole("row", { name: /vda/ }); + const md0Option = screen.getByRole("row", { name: /md0/ }); // click on selected device to check nothing is notified await user.click(vdaOption); expect(onChangeFn).not.toHaveBeenCalled(); await user.click(md0Option); - expect(onChangeFn).toHaveBeenCalledWith("/dev/md0"); + expect(onChangeFn).toHaveBeenCalledWith(md0.sid); await user.click(vdaOption); - expect(onChangeFn).toHaveBeenCalledWith("/dev/vda"); + expect(onChangeFn).toHaveBeenCalledWith(vda.sid); }); }); @@ -306,9 +312,11 @@ describe("DeviceSelector", () => { const onChangeFn = jest.fn(); const TestMultipleDeviceSelection = () => { - const [selected, setSelected] = useState("/dev/vda"); + const [selected, setSelected] = useState(vda); - onChangeFn.mockImplementation(devices => setSelected(devices)); + onChangeFn.mockImplementation(selection => setSelected( + availableDevices.filter(d => selection.includes(d.sid))) + ); return ( { it("notifies selected devices", async () => { const { user } = plainRender(); - const vdaOption = screen.getByRole("option", { name: /vda/ }); - const md0Option = screen.getByRole("option", { name: /md0/ }); - const dasdOption = screen.getByRole("option", { name: /dasda/ }); + const vdaOption = screen.getByRole("row", { name: /vda/ }); + const md0Option = screen.getByRole("row", { name: /md0/ }); + const dasdOption = screen.getByRole("row", { name: /dasda/ }); await user.click(md0Option); - expect(onChangeFn).toHaveBeenCalledWith(["/dev/vda", "/dev/md0"]); + expect(onChangeFn).toHaveBeenCalledWith([vda.sid, md0.sid]); await user.click(dasdOption); - expect(onChangeFn).toHaveBeenCalledWith(["/dev/vda", "/dev/md0", "/dev/dasda"]); + expect(onChangeFn).toHaveBeenCalledWith([vda.sid, md0.sid, dasd.sid]); // click on selected device to check it is notified as not selected await user.click(vdaOption); - expect(onChangeFn).toHaveBeenCalledWith(["/dev/md0", "/dev/dasda"]); + expect(onChangeFn).toHaveBeenCalledWith([md0.sid, dasd.sid]); }); }); }); diff --git a/web/src/components/storage/space-policy-utils.jsx b/web/src/components/storage/space-policy-utils.jsx index fbb5fc5275..6d3259aeb9 100644 --- a/web/src/components/storage/space-policy-utils.jsx +++ b/web/src/components/storage/space-policy-utils.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -25,23 +25,9 @@ import { _, n_ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { noop } from "~/utils"; import { Button, ExpandableSection, Hint, HintBody } from "@patternfly/react-core"; +import { Selector } from "~/components/core"; import { DeviceList } from "~/components/storage"; -const ListBox = ({ children, ...props }) =>
      {children}
    ; - -const ListBoxItem = ({ isSelected, children, onClick, ...props }) => { - if (isSelected) props['aria-selected'] = true; - - return ( -
  • - {children} -
  • - ); -}; - /** * Content for a space policy item * @component @@ -91,13 +77,15 @@ Only the space that is not assigned to any partition will be used."); }; return ( - <> +
    <Description /> - </> + </div> ); }; +const renderPolicyOption = ({ id }) => <PolicyItem policy={id} />; + /** * Component for selecting a policy to make space. * @component @@ -108,19 +96,21 @@ Only the space that is not assigned to any partition will be used."); * changes. */ const SpacePolicySelector = ({ value, onChange = noop }) => { + const onSelectionChange = (selection) => onChange(selection[0]); + const options = [ + { id: "delete" }, + { id: "resize" }, + { id: "keep" } + ]; + return ( - <ListBox aria-label={_("Select a mechanism to make space")} role="listbox"> - { ["delete", "resize", "keep"].map(policy => ( - <ListBoxItem - key={policy} - role="option" - onClick={() => onChange(policy)} - isSelected={policy === value} - > - <PolicyItem policy={policy} /> - </ListBoxItem> - ))} - </ListBox> + <Selector + aria-label={_("Select a mechanism to make space")} + options={options} + renderOption={renderPolicyOption} + selectedIds={[value]} + onSelectionChange={onSelectionChange} + /> ); };