Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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 .changeset/light-corners-lick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"landscape-ui": patch
---

Allow selection of more than one architecture when creating a publication
Comment on lines +1 to +5
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { renderWithProviders } from "@/tests/render";
import { describe, expect } from "vitest";
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import MirrorDetails from "./MirrorDetails";
import { mirrors } from "@/tests/mocks/mirrors";
import { Suspense } from "react";
import LoadingState from "@/components/layout/LoadingState";
import { expectLoadingState } from "@/tests/helpers";
import { screen } from "@testing-library/react";

describe("MirrorDetails", () => {
it("renders", async () => {
it("renders the mirror display name once loaded", async () => {
renderWithProviders(
<Suspense fallback={<LoadingState />}>
<MirrorDetails />
Expand All @@ -17,10 +17,30 @@
`?name=${mirrors[0].name}`,
);

expect(
await screen.findByRole("heading", { name: mirrors[0].displayName }),
).toBeInTheDocument();
});

it("displays preserve signatures status", async () => {
Comment thread
marqode marked this conversation as resolved.
Outdated
const mirrorWithPreserveSignatures = mirrors.find(
({ preserveSignatures }) => preserveSignatures,
);

assert(mirrorWithPreserveSignatures);

renderWithProviders(
<Suspense fallback={<LoadingState />}>
<MirrorDetails />
</Suspense>,
undefined,
`?name=${mirrorWithPreserveSignatures.name}`,
);

await expectLoadingState();

expect(
screen.getByRole("heading", { name: mirrors[0].displayName }),

Check failure on line 43 in src/features/mirrors/components/MirrorDetails/MirrorDetails.test.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

src/features/mirrors/components/MirrorDetails/MirrorDetails.test.tsx > MirrorDetails > displays preserve signatures status

TestingLibraryElementError: Unable to find an accessible element with the role "heading" and name "Ubuntu archive mirror" Here are the accessible roles: heading: Name "Security mirror": <h2 class="p-panel__title" /> Name "Details": <h4 class="p-heading--5 p-text--small-caps u-no-padding" /> Name "Contents": <h4 class="p-heading--5 p-text--small-caps u-no-padding" /> Name "Authentication": <h4 class="p-heading--5 p-text--small-caps u-no-padding" /> Name "Used in": <h4 class="p-heading--5 p-text--small-caps u-no-padding" /> -------------------------------------------------- button: Name "Close": <button aria-label="Close" class="p-button--base has-icon" /> Name "Edit": <button class="p-button has-icon p-segmented-control__button" type="button" /> Name "Update": <button class="p-button has-icon p-segmented-control__button" type="button" /> Name "Publish": <button class="p-button has-icon p-segmented-control__button" type="button" /> Name "Remove": <button class="p-button has-icon p-segmented-control__button" type="button" /> -------------------------------------------------- navigation: Name "": <nav class="p-tabs" /> -------------------------------------------------- list: Name "": <ul class="p-tabs__list marginBottom" /> -------------------------------------------------- listitem: Name "": <li class="p-tabs__item" /> Name "": <li class="p-tabs__item" /> -------------------------------------------------- link: Name "https://security.ubuntu.com/ubuntu": <a href="https://security.ubuntu.com/ubuntu" rel="noopener noreferrer" target="_blank" /> -------------------------------------------------- alert: Name "": <span aria-live="polite" class="p-text--default" role="alert" /> -------------------------------------------------- status: Name "": <div class="p-strip" role="status" /> -------------------------------------------------- complementary: Name "": <aside class="l-aside is-collapsed" /> -------------------------------------------------- Ignored nodes: comments, script, style <body style="color-scheme: light;" > <div> <div class="p-panel__header" > <h2 class="p-panel__title" > Security mirror </h2> <div class="p-panel__controls" > <button aria-label="Close" class="p-button--base has-icon" > <i class="p-icon--close" /> </button> </div> </div> <div class="p-panel__content content" > <div class="inner" > <div class="p-segmented-control" > <button class="p-button has-icon p-segmented-control__button" type="button" > <i class="p-icon--edit" /> <span> Edit </span> </button> <button class="p-button has-icon p-segmented-control__button" type="button" > <i class="p-icon--restart" /> <span> Update </span> </button> <button class="p-button has-icon p-segmented-control__button" type="button" > <i class="p-icon--upload" /> <span> Publish </span> </button> <button class="p-button has-icon p-segmented-control__button" type="button" > <i class="p-icon--delete--negative" /> <span class="u-text--negative" > Remove </span> </button> </div> <nav class="p-tabs" > <ul
).toBeInTheDocument();

expect(
Expand Down Expand Up @@ -73,6 +93,10 @@
expect(
screen.getByText(mirrors[2].gpgKey?.fingerprint),
).toBeInTheDocument();

const label = screen.getByText("Preserve signatures");

Check failure on line 97 in src/features/mirrors/components/MirrorDetails/MirrorDetails.test.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

src/features/mirrors/components/MirrorDetails/MirrorDetails.test.tsx > MirrorDetails > renders GPG key fingerprint

TestingLibraryElementError: Unable to find an element with the text: Preserve signatures. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. Ignored nodes: comments, script, style <body style="color-scheme: light;" > <div> <div class="p-panel__header" > <h2 class="p-panel__title" > Third party mirror </h2> <div class="p-panel__controls" > <button aria-label="Close" class="p-button--base has-icon" > <i class="p-icon--close" /> </button> </div> </div> <div class="p-panel__content content" > <div class="inner" > <div class="p-segmented-control" > <button class="p-button has-icon p-segmented-control__button" type="button" > <i class="p-icon--edit" /> <span> Edit </span> </button> <button class="p-button has-icon p-segmented-control__button" type="button" > <i class="p-icon--restart" /> <span> Update </span> </button> <button class="p-button has-icon p-segmented-control__button" type="button" > <i class="p-icon--upload" /> <span> Publish </span> </button> <button class="p-button has-icon p-segmented-control__button" type="button" > <i class="p-icon--delete--negative" /> <span class="u-text--negative" > Remove </span> </button> </div> <nav class="p-tabs" > <ul class="p-tabs__list marginBottom" > <li class="p-tabs__item" > <a aria-selected="true" class="p-tabs__link" data-testid="tab-link-General details" > General details </a> </li> <li class="p-tabs__item" > <a aria-selected="false" class="p-tabs__link" data-testid="tab-link-Packages" > Packages </a> </li> </ul> </nav> <div class="blocks" > <section class="item" > <div class="heading" > <h4 class="p-heading--5 p-text--small-caps u-no-padding" > Details </h4> </div> <div class="infoGrid" > <div class="grid denseGrid" > <div> <div class="p-text--x-small u-text--muted label" > Name </div> <div> Third party mirror </div> </div> <div> <div class="p-text--x-small u-text--muted label" > Source type </div> <div> Third party </div> </div> <div class="largeItem" > <div class="p-text--x-small u-text--muted label" > Source URL </div> <div> <a
Comment thread
marqode marked this conversation as resolved.
Outdated
expect(label).toBeInTheDocument();
expect(label.closest("div")?.nextSibling?.textContent).toBe("Yes");
});

it("displays preserve signatures status", async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import LoadingState from "@/components/layout/LoadingState";
import { renderWithProviders } from "@/tests/render";
import { screen, waitFor } from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Suspense } from "react";
import { describe, expect, it } from "vitest";
Expand Down Expand Up @@ -60,8 +60,6 @@ describe("AddPublicationForm", () => {
expect(
screen.getByRole("combobox", { name: "Architectures" }),
).toBeInTheDocument();
expect(screen.getByRole("option", { name: "amd64" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "arm64" })).toBeInTheDocument();
});

it("updates mirror publication fields based on the selected source", async () => {
Expand All @@ -87,10 +85,6 @@ describe("AddPublicationForm", () => {
publicationTargetSelect,
"aaaaaaaa-0000-0000-0000-000000000001",
);
await user.selectOptions(
screen.getByRole("combobox", { name: "Architectures" }),
"amd64",
);
await user.type(
screen.getByRole("textbox", { name: "Directory prefix" }),
"edge",
Expand All @@ -108,13 +102,19 @@ describe("AddPublicationForm", () => {
await user.click(
screen.getByRole("checkbox", { name: /Skip content indexing/i }),
);
const archCombobox = screen.getByRole("combobox", {
name: "Architectures",
});
await user.click(archCombobox);
await user.click(await screen.findByRole("checkbox", { name: "amd64" }));

expect(publicationTargetSelect).toHaveValue(
"aaaaaaaa-0000-0000-0000-000000000001",
);
expect(screen.getByRole("combobox", { name: "Architectures" })).toHaveValue(
"amd64",
);
expect(screen.getByRole("checkbox", { name: "amd64" })).toBeChecked();
expect(
screen.getByRole("combobox", { name: "Architectures" }),
).toBeInTheDocument();
expect(
screen.getByRole("textbox", { name: "Directory prefix" }),
).toHaveValue("edge");
Expand Down Expand Up @@ -207,4 +207,161 @@ describe("AddPublicationForm", () => {
screen.queryByRole("heading", { name: "Signing GPG Key" }),
).not.toBeInTheDocument();
});

it("shows validation error when all architectures are deselected", async () => {
const user = userEvent.setup();

renderForm();

await selectMirrorSource(user);

const archCombobox = screen.getByRole("combobox", {
name: "Architectures",
});
await user.click(archCombobox);

const amd64Checkbox = await screen.findByRole("checkbox", {
name: "amd64",
});
await user.click(amd64Checkbox);
await user.click(amd64Checkbox);

await waitFor(() => {
expect(screen.getAllByText("This field is required")).not.toHaveLength(0);
});
});

it("shows success notification after submitting a valid local source publication", async () => {
const user = userEvent.setup();

renderForm();

await user.type(
await screen.findByRole("textbox", { name: "Publication name" }),
"new-local-publication",
);
await selectLocalSource(user);

const publicationTargetSelect = screen.getByRole("combobox", {
name: "Publication target",
});
await waitFor(() => {
expect(publicationTargetSelect).toBeEnabled();
});
await user.selectOptions(
publicationTargetSelect,
"bbbbbbbb-0000-0000-0000-000000000002",
);

await user.click(screen.getByRole("button", { name: "Add publication" }));

expect(
await screen.findByText(
'Publication "new-local-publication" has been created.',
),
).toBeInTheDocument();
});

it("shows success notification after submitting a mirror publication with a custom signing key", async () => {
const user = userEvent.setup();

renderForm();

await user.type(
await screen.findByRole("textbox", { name: "Publication name" }),
"new-mirror-publication",
);
await selectMirrorSource(user);

const publicationTargetSelect = screen.getByRole("combobox", {
name: "Publication target",
});
await waitFor(() => {
expect(publicationTargetSelect).toBeEnabled();
});
await user.selectOptions(
publicationTargetSelect,
"aaaaaaaa-0000-0000-0000-000000000001",
);

const signingKeySection = screen
.getByRole("heading", { name: "Signing GPG Key" })
.closest("section");
if (!signingKeySection)
throw new Error("Signing GPG Key section not found");
await user.type(
within(signingKeySection).getByRole("textbox"),
"-----BEGIN PGP PRIVATE KEY BLOCK-----test-key",
);

const archCombobox = screen.getByRole("combobox", {
name: "Architectures",
});
await user.click(archCombobox);
await user.click(await screen.findByRole("checkbox", { name: "amd64" }));

await user.click(screen.getByRole("button", { name: "Add publication" }));

expect(
await screen.findByText(
'Publication "new-mirror-publication" has been created.',
),
).toBeInTheDocument();
});

it("allows selecting multiple architectures simultaneously", async () => {
const user = userEvent.setup();

renderForm();

await selectMirrorSource(user);

const archCombobox = screen.getByRole("combobox", {
name: "Architectures",
});
await user.click(archCombobox);

await user.click(await screen.findByRole("checkbox", { name: "amd64" }));
await user.click(screen.getByRole("checkbox", { name: "arm64" }));

expect(screen.getByRole("checkbox", { name: "amd64" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "arm64" })).toBeChecked();
});

it("shows success notification after submitting a valid publication", async () => {
const user = userEvent.setup();

renderForm();

await user.type(
await screen.findByRole("textbox", { name: "Publication name" }),
"new-mirror-publication",
);
await selectMirrorSource(user);

const publicationTargetSelect = screen.getByRole("combobox", {
name: "Publication target",
});
await waitFor(() => {
expect(publicationTargetSelect).toBeEnabled();
});
await user.selectOptions(
publicationTargetSelect,
"aaaaaaaa-0000-0000-0000-000000000001",
);

const archCombobox = screen.getByRole("combobox", {
name: "Architectures",
});
await user.click(archCombobox);
await user.click(await screen.findByRole("checkbox", { name: "amd64" }));

await user.click(screen.getByRole("button", { name: "Add publication" }));

expect(
await screen.findByText(
'Publication "new-mirror-publication" has been created.',
),
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SidePanelFormButtons from "@/components/form/SidePanelFormButtons";
import ReadOnlyField from "@/components/form/ReadOnlyField";
import MultiSelectField from "@/components/form/MultiSelectField";
import Blocks from "@/components/layout/Blocks";
import { useGetLocalRepositories } from "@/features/local-repositories";
import { useListMirrors } from "@/features/mirrors";
Expand All @@ -16,6 +17,7 @@ import {
Select,
Textarea,
Tooltip,
type MultiSelectItem,
} from "@canonical/react-components";
import { useFormik } from "formik";
import type { FC } from "react";
Expand Down Expand Up @@ -139,17 +141,12 @@ const AddPublicationForm: FC = () => {
[publicationTargets],
);

const architectureOptions = useMemo(
() => [
{
label: "Select architecture",
value: "",
},
...(selectedSource?.architectures ?? []).map((architecture) => ({
const architectureItems = useMemo(
() =>
(selectedSource?.architectures ?? []).map((architecture) => ({
label: architecture,
value: architecture,
})),
],
[selectedSource],
);

Expand All @@ -158,8 +155,7 @@ const AddPublicationForm: FC = () => {
): Promise<void> => {
await formik.setFieldValue("source_type", event.target.value);
await formik.setFieldValue("source", "");
Comment thread
marqode marked this conversation as resolved.
Comment thread
marqode marked this conversation as resolved.
await formik.setFieldValue("uploader_distribution", "");
await formik.setFieldValue("uploader_architectures", "");
await formik.setFieldValue("uploader_architectures", []);
await formik.setFieldValue("signing_key", "");
};

Expand All @@ -175,15 +171,17 @@ const AddPublicationForm: FC = () => {
source?.distribution ?? "",
);

if (source?.sourceType === SOURCE_TYPE_LOCAL_REPOSITORY) {
await formik.setFieldValue("uploader_architectures", "");
await formik.setFieldValue("signing_key", "");

return;
}
await formik.setFieldValue("uploader_architectures", []);
Comment thread
marqode marked this conversation as resolved.
Comment thread
marqode marked this conversation as resolved.
};

await formik.setFieldValue("uploader_architectures", "");
await formik.setFieldValue("signing_key", "");
const handleArchitectureChange = async (
items: MultiSelectItem[],
): Promise<void> => {
await formik.setFieldTouched("uploader_architectures", true);
await formik.setFieldValue(
"uploader_architectures",
items.map(({ value }) => String(value)),
);
};

return (
Expand Down Expand Up @@ -253,13 +251,18 @@ const AddPublicationForm: FC = () => {
)}

{!isLocalSourceType && (
<Select
<MultiSelectField
variant="condensed"
hasSelectedItemsFirst={false}
label="Architectures"
required
disabled={!formik.values.source}
options={architectureOptions}
items={architectureItems}
selectedItems={architectureItems.filter(({ value }) =>
formik.values.uploader_architectures.includes(value),
)}
onItemsUpdate={handleArchitectureChange}
error={getFormikError(formik, "uploader_architectures")}
{...formik.getFieldProps("uploader_architectures")}
/>
)}
</Blocks.Item>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export const INITIAL_VALUES: FormProps = {
publication_target: "",
prefix: "",
uploader_distribution: "",
uploader_architectures: "",
signing_key: "",
uploader_architectures: [],
hash_indexing: false,
automatic_installation: false,
automatic_upgrades: false,
Expand Down
Loading
Loading