Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions web/package/agama-web-ui.changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
-------------------------------------------------------------------
Tue Aug 13 14:57:21 UTC 2024 - David Diaz <dgonzalez@suse.com>

- Allow links look like buttons again (gh#openSUSE/agama#1536).

-------------------------------------------------------------------
Fri Jul 26 11:42:05 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com>

Expand Down
101 changes: 101 additions & 0 deletions web/src/components/core/Link.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* 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 } from "@testing-library/react";
import { installerRender } from "~/test-utils";
import { Link } from "~/components/core";

describe("Link", () => {
it("renders an HTML `a` tag with the `href` attribute set to given `to` prop", () => {
installerRender(<Link to="somewhere">Agama Link</Link>);
const link = screen.getByRole("link", { name: "Agama Link" }) as HTMLLinkElement;
// NOTE: Link uses ReactRouter#useHref hook which is mocked in test-utils.js
// TODO: Not using toHaveAttribute("href", "somewhere") because some weird problems at CI
expect(link.href).toContain("somewhere");
});

it("renders it as primary when either, using a truthy `isPrimary` prop or `variant` is set to primary", () => {
const { rerender } = installerRender(<Link to="somewhere">Agama Link</Link>);
const link = screen.getByRole("link", { name: "Agama Link" });

expect(link.classList.contains("pf-m-primary")).not.toBe(true);

rerender(
<Link to="somewhere" isPrimary>
Agama Link
</Link>,
);
expect(link.classList.contains("pf-m-primary")).toBe(true);

rerender(
<Link to="somewhere" isPrimary={false}>
{" "}
Agama Link
</Link>,
);
expect(link.classList.contains("pf-m-primary")).not.toBe(true);

rerender(
<Link to="somewhere" variant="primary">
Agama Link
</Link>,
);
expect(link.classList.contains("pf-m-primary")).toBe(true);

rerender(
<Link to="somewhere" isPrimary={false} variant="primary">
{" "}
Agama Link
</Link>,
);
expect(link.classList.contains("pf-m-primary")).toBe(true);
});

it("renders it as secondary when neither is given, a truthy `isPrimary` nor `variant`", () => {
const { rerender } = installerRender(<Link to="somewhere">Agama Link</Link>);
const link = screen.getByRole("link", { name: "Agama Link" });

expect(link.classList.contains("pf-m-secondary")).toBe(true);

rerender(
<Link to="somewhere" isPrimary={false}>
Agama Link
</Link>,
);
expect(link.classList.contains("pf-m-secondary")).toBe(true);

rerender(
// Unexpected, but possible since isPrimary is just a "helper"
<Link to="somewhere" isPrimary={false} variant="primary">
Agama Link
</Link>,
);
expect(link.classList.contains("pf-m-secondary")).not.toBe(true);

rerender(
<Link to="somewhere" variant="link">
Agama Link
</Link>,
);
expect(link.classList.contains("pf-m-secondary")).not.toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,28 @@
*/

import React from "react";
import { Link } from "react-router-dom";
import buttonStyles from "@patternfly/react-styles/css/components/Button/button";
import { Button, ButtonProps } from "@patternfly/react-core";
import { useHref } from "react-router-dom";

// TODO: Evaluate which is better, this approach or just using a
// PF/Button with onClick callback and "component" prop sets as "a"
type LinkProps = Omit<ButtonProps, "component"> & {
/** The target route */
to: string;
/** Whether use PF/Button primary variant */
isPrimary?: boolean;
};

export default function ButtonLink({ to, isPrimary = false, children, ...props }) {
/**
* Returns an HTML `<a>` tag built on top of PF/Button and useHref ReactRouter hook
*
* @note when isPrimary not given or false and props does not contain a variant prop,
* it will default to "secondary" variant
*/
export default function Link({ to, isPrimary, variant, children, ...props }: LinkProps) {
const href = useHref(to);
const linkVariant = isPrimary ? "primary" : variant || "secondary";
return (
<Link
to={to}
className={[buttonStyles.button, buttonStyles.modifiers[isPrimary ? "primary" : "scondary"]]
.join(" ")
.trim()}
{...props}
>
<Button component="a" href={href} variant={linkVariant} {...props}>
{children}
</Link>
</Button>
);
}
2 changes: 1 addition & 1 deletion web/src/components/core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ 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 ButtonLink } from "./ButtonLink";
export { default as Link } from "./Link";
export { default as EmptyState } from "./EmptyState";
export { default as InstallerOptions } from "./InstallerOptions";
export { default as Drawer } from "./Drawer";
14 changes: 7 additions & 7 deletions web/src/components/l10n/L10nPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

import React from "react";
import { Gallery, GalleryItem } from "@patternfly/react-core";
import { ButtonLink, CardField, Page } from "~/components/core";
import { Link, CardField, Page } from "~/components/core";
import { PATHS } from "~/routes/l10n";
import { _ } from "~/i18n";
import { useL10n } from "~/queries/l10n";
Expand Down Expand Up @@ -55,17 +55,17 @@ export default function L10nPage() {
label={_("Language")}
value={locale ? `${locale.name} - ${locale.territory}` : _("Not selected yet")}
>
<ButtonLink to={PATHS.localeSelection} isPrimary={!locale}>
<Link to={PATHS.localeSelection} isPrimary={!locale}>
{locale ? _("Change") : _("Select")}
</ButtonLink>
</Link>
</Section>
</GalleryItem>

<GalleryItem>
<Section label={_("Keyboard")} value={keymap ? keymap.name : _("Not selected yet")}>
<ButtonLink to={PATHS.keymapSelection} isPrimary={!keymap}>
<Link to={PATHS.keymapSelection} isPrimary={!keymap}>
{keymap ? _("Change") : _("Select")}
</ButtonLink>
</Link>
</Section>
</GalleryItem>

Expand All @@ -74,9 +74,9 @@ export default function L10nPage() {
label={_("Time zone")}
value={timezone ? (timezone.parts || []).join(" - ") : _("Not selected yet")}
>
<ButtonLink to={PATHS.timezoneSelection} isPrimary={!timezone}>
<Link to={PATHS.timezoneSelection} isPrimary={!timezone}>
{timezone ? _("Change") : _("Select")}
</ButtonLink>
</Link>
</Section>
</GalleryItem>
</Gallery>
Expand Down
21 changes: 8 additions & 13 deletions web/src/components/l10n/L10nPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
*/

import React from "react";
import { render, screen, within } from "@testing-library/react";
import { screen, within } from "@testing-library/react";
import { installerRender } from "~/test-utils";
import L10nPage from "~/components/l10n/L10nPage";

let mockLoadedData;
Expand All @@ -40,12 +41,6 @@ const timezones = [
{ id: "Europe/Madrid", parts: ["Europe", "Madrid"] },
];

jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
// TODO: mock the link because it needs a working router.
Link: ({ children }) => <button>{children}</button>,
}));

jest.mock("~/queries/l10n", () => ({
useL10n: () => mockLoadedData,
}));
Expand All @@ -62,7 +57,7 @@ beforeEach(() => {
});

it("renders a section for configuring the language", () => {
render(<L10nPage />);
installerRender(<L10nPage />);
const region = screen.getByRole("region", { name: "Language" });
within(region).getByText("English - United States");
within(region).getByText("Change");
Expand All @@ -74,15 +69,15 @@ describe("if there is no selected language", () => {
});

it("renders a button for selecting a language", () => {
render(<L10nPage />);
installerRender(<L10nPage />);
const region = screen.getByRole("region", { name: "Language" });
within(region).getByText("Not selected yet");
within(region).getByText("Select");
});
});

it("renders a section for configuring the keyboard", () => {
render(<L10nPage />);
installerRender(<L10nPage />);
const region = screen.getByRole("region", { name: "Keyboard" });
within(region).getByText("English");
within(region).getByText("Change");
Expand All @@ -94,15 +89,15 @@ describe("if there is no selected keyboard", () => {
});

it("renders a button for selecting a keyboard", () => {
render(<L10nPage />);
installerRender(<L10nPage />);
const region = screen.getByRole("region", { name: "Keyboard" });
within(region).getByText("Not selected yet");
within(region).getByText("Select");
});
});

it("renders a section for configuring the time zone", () => {
render(<L10nPage />);
installerRender(<L10nPage />);
const region = screen.getByRole("region", { name: "Time zone" });
within(region).getByText("Europe - Berlin");
within(region).getByText("Change");
Expand All @@ -114,7 +109,7 @@ describe("if there is no selected time zone", () => {
});

it("renders a button for selecting a time zone", () => {
render(<L10nPage />);
installerRender(<L10nPage />);
const region = screen.getByRole("region", { name: "Time zone" });
within(region).getByText("Not selected yet");
within(region).getByText("Select");
Expand Down
6 changes: 3 additions & 3 deletions web/src/components/network/NetworkPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

import React from "react";
import { CardBody, Grid, GridItem } from "@patternfly/react-core";
import { ButtonLink, CardField, EmptyState, Page } from "~/components/core";
import { Link, CardField, EmptyState, Page } from "~/components/core";
import { ConnectionsTable } from "~/components/network";
import { formatIp } from "~/client/network/utils";
import { useNetwork, useNetworkConfigChanges } from "~/queries/network";
Expand Down Expand Up @@ -71,9 +71,9 @@ export default function NetworkPage() {
<CardField
label={_("Wi-Fi")}
actions={
<ButtonLink isPrimary={!activeConnection} to={PATHS.wifis}>
<Link isPrimary={!activeConnection} to={PATHS.wifis}>
{activeConnection ? _("Change") : _("Connect")}
</ButtonLink>
</Link>
}
>
<CardField.Content>
Expand Down
18 changes: 9 additions & 9 deletions web/src/components/network/WifiNetworksListPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { generatePath } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Icon } from "~/components/layout";
import { WifiConnectionForm } from "~/components/network";
import { ButtonLink } from "~/components/core";
import { Link } from "~/components/core";
import { DeviceState } from "~/client/network/model";
import { formatIp } from "~/client/network/utils";
import { useInstallerClient } from "~/context/installer";
Expand Down Expand Up @@ -108,12 +108,12 @@ const WifiDrawerPanelBody = ({ network, onCancel }) => {
if (network.settings && !network.device) {
return (
<Split hasGutter>
<ButtonLink onClick={async () => await client.network.connectTo(network.settings)}>
<Link onClick={async () => await client.network.connectTo(network.settings)}>
{_("Connect")}
</ButtonLink>
<ButtonLink to={generatePath(PATHS.editConnection, { id: network.settings.id })}>
</Link>
<Link to={generatePath(PATHS.editConnection, { id: network.settings.id })}>
{_("Edit")}
</ButtonLink>
</Link>
<Button variant="secondary" isDanger onClick={forgetNetwork}>
{_("Forget")}
</Button>
Expand All @@ -132,12 +132,12 @@ const WifiDrawerPanelBody = ({ network, onCancel }) => {
<Stack>
<ConnectionData network={network} />
<Split hasGutter>
<ButtonLink onClick={async () => await client.network.disconnect(network.settings)}>
<Link onClick={async () => await client.network.disconnect(network.settings)}>
{_("Disconnect")}
</ButtonLink>
<ButtonLink to={generatePath(PATHS.editConnection, { id: network.settings.id })}>
</Link>
<Link to={generatePath(PATHS.editConnection, { id: network.settings.id })}>
{_("Edit")}
</ButtonLink>
</Link>
<Button
variant="secondary"
isDanger
Expand Down
6 changes: 3 additions & 3 deletions web/src/components/software/SoftwarePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
GridItem,
Stack,
} from "@patternfly/react-core";
import { ButtonLink, CardField, IssuesHint, Page } from "~/components/core";
import { Link, CardField, IssuesHint, Page } from "~/components/core";
import UsedSize from "./UsedSize";
import { useIssues } from "~/queries/issues";
import { usePatterns, useProposal, useProposalChanges } from "~/queries/software";
Expand Down Expand Up @@ -67,9 +67,9 @@ const SelectedPatterns = ({ patterns }): React.ReactNode => (
<CardField
label={_("Selected patterns")}
actions={
<ButtonLink to={PATHS.patternsSelection} isPrimary>
<Link to={PATHS.patternsSelection} isPrimary>
{_("Change selection")}
</ButtonLink>
</Link>
}
>
<CardBody>
Expand Down
6 changes: 3 additions & 3 deletions web/src/components/storage/InstallationDeviceField.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

import React from "react";
import { Skeleton } from "@patternfly/react-core";
import { ButtonLink, CardField } from "~/components/core";
import { Link, CardField } from "~/components/core";
import { deviceLabel } from "~/components/storage/utils";
import { PATHS } from "~/routes/storage";
import { _ } from "~/i18n";
Expand Down Expand Up @@ -105,9 +105,9 @@ export default function InstallationDeviceField({
isLoading ? (
<Skeleton fontSize="sm" width="100px" />
) : (
<ButtonLink to={PATHS.targetDevice} isPrimary={false}>
<Link to={PATHS.targetDevice} isPrimary={false}>
{_("Change")}
</ButtonLink>
</Link>
)
}
>
Expand Down
Loading