From a55b3846031ddc9778faae8eb2877424af3c6009 Mon Sep 17 00:00:00 2001 From: Ahmet Can Buyukyilmaz Date: Mon, 6 Apr 2026 17:43:30 +0300 Subject: [PATCH 01/10] feat(settings): Implement multiple source support MAASENG-6130 (#5976) --- src/app/api/query/imageSources.ts | 58 ++++ .../SelectUpstreamImagesForm.test.tsx | 36 +-- .../SelectUpstreamImagesForm.tsx | 19 +- .../ImageListHeader/ImageListHeader.test.tsx | 6 +- .../ImageListHeader/ImageListHeader.tsx | 2 +- src/app/settings/constants.ts | 3 +- src/app/settings/urls.ts | 3 +- .../Images/ChangeSource/ChangeSource.test.tsx | 231 ------------- .../Images/ChangeSource/ChangeSource.tsx | 253 --------------- .../ChangeSourceForm.test.tsx | 118 ------- .../ChangeSourceForm/ChangeSourceForm.tsx | 294 ----------------- .../CustomSourceForm.test.tsx | 201 ------------ .../CustomSourceForm/CustomSourceForm.tsx | 206 ------------ .../CustomSourceForm/index.ts | 1 - .../MaasIoSourceForm.test.tsx | 65 ---- .../MaasIoSourceForm/MaasIoSourceForm.tsx | 152 --------- .../MaasIoSourceForm/index.ts | 1 - .../ChangeSource/ChangeSourceForm/index.ts | 1 - .../views/Images/ChangeSource/index.ts | 1 - .../views/Images/Sources/Sources.test.tsx | 45 +++ .../settings/views/Images/Sources/Sources.tsx | 50 +++ .../components/AddSource/AddSource.test.tsx | 246 ++++++++++++++ .../components/AddSource/AddSource.tsx | 268 +++++++++++++++ .../Sources/components/AddSource/index.ts | 1 + .../DeleteSource/DeleteSource.test.tsx | 55 ++++ .../components/DeleteSource/DeleteSource.tsx | 75 +++++ .../Sources/components/DeleteSource/index.ts | 1 + .../DisableSource/DisableSource.tsx | 65 ++++ .../Sources/components/DisableSource/index.ts | 1 + .../components/EditSource/EditSource.test.tsx | 264 +++++++++++++++ .../components/EditSource/EditSource.tsx | 304 ++++++++++++++++++ .../Sources/components/EditSource/index.ts | 1 + .../components/EnableSource/EnableSource.tsx | 65 ++++ .../Sources/components/EnableSource/index.ts | 1 + .../SourcesTable/SourcesTable.test.tsx | 200 ++++++++++++ .../components/SourcesTable/SourcesTable.tsx | 119 +++++++ .../components/SourcesTable/_index.scss | 21 ++ .../Sources/components/SourcesTable/index.ts | 1 + .../useSourcesTableColumns.tsx | 192 +++++++++++ .../views/Images/Sources/constants.ts | 8 + .../settings/views/Images/Sources/index.ts | 1 + .../Synchronization/Synchronization.test.tsx | 72 +++++ .../Synchronization/Synchronization.tsx | 118 +++++++ .../views/Images/Synchronization/index.ts | 1 + src/router.tsx | 20 +- src/testing/factories/index.ts | 1 + 46 files changed, 2262 insertions(+), 1585 deletions(-) delete mode 100644 src/app/settings/views/Images/ChangeSource/ChangeSource.test.tsx delete mode 100644 src/app/settings/views/Images/ChangeSource/ChangeSource.tsx delete mode 100644 src/app/settings/views/Images/ChangeSource/ChangeSourceForm/ChangeSourceForm.test.tsx delete mode 100644 src/app/settings/views/Images/ChangeSource/ChangeSourceForm/ChangeSourceForm.tsx delete mode 100644 src/app/settings/views/Images/ChangeSource/ChangeSourceForm/CustomSourceForm/CustomSourceForm.test.tsx delete mode 100644 src/app/settings/views/Images/ChangeSource/ChangeSourceForm/CustomSourceForm/CustomSourceForm.tsx delete mode 100644 src/app/settings/views/Images/ChangeSource/ChangeSourceForm/CustomSourceForm/index.ts delete mode 100644 src/app/settings/views/Images/ChangeSource/ChangeSourceForm/MaasIoSourceForm/MaasIoSourceForm.test.tsx delete mode 100644 src/app/settings/views/Images/ChangeSource/ChangeSourceForm/MaasIoSourceForm/MaasIoSourceForm.tsx delete mode 100644 src/app/settings/views/Images/ChangeSource/ChangeSourceForm/MaasIoSourceForm/index.ts delete mode 100644 src/app/settings/views/Images/ChangeSource/ChangeSourceForm/index.ts delete mode 100644 src/app/settings/views/Images/ChangeSource/index.ts create mode 100644 src/app/settings/views/Images/Sources/Sources.test.tsx create mode 100644 src/app/settings/views/Images/Sources/Sources.tsx create mode 100644 src/app/settings/views/Images/Sources/components/AddSource/AddSource.test.tsx create mode 100644 src/app/settings/views/Images/Sources/components/AddSource/AddSource.tsx create mode 100644 src/app/settings/views/Images/Sources/components/AddSource/index.ts create mode 100644 src/app/settings/views/Images/Sources/components/DeleteSource/DeleteSource.test.tsx create mode 100644 src/app/settings/views/Images/Sources/components/DeleteSource/DeleteSource.tsx create mode 100644 src/app/settings/views/Images/Sources/components/DeleteSource/index.ts create mode 100644 src/app/settings/views/Images/Sources/components/DisableSource/DisableSource.tsx create mode 100644 src/app/settings/views/Images/Sources/components/DisableSource/index.ts create mode 100644 src/app/settings/views/Images/Sources/components/EditSource/EditSource.test.tsx create mode 100644 src/app/settings/views/Images/Sources/components/EditSource/EditSource.tsx create mode 100644 src/app/settings/views/Images/Sources/components/EditSource/index.ts create mode 100644 src/app/settings/views/Images/Sources/components/EnableSource/EnableSource.tsx create mode 100644 src/app/settings/views/Images/Sources/components/EnableSource/index.ts create mode 100644 src/app/settings/views/Images/Sources/components/SourcesTable/SourcesTable.test.tsx create mode 100644 src/app/settings/views/Images/Sources/components/SourcesTable/SourcesTable.tsx create mode 100644 src/app/settings/views/Images/Sources/components/SourcesTable/_index.scss create mode 100644 src/app/settings/views/Images/Sources/components/SourcesTable/index.ts create mode 100644 src/app/settings/views/Images/Sources/components/SourcesTable/useSourcesTableColumns/useSourcesTableColumns.tsx create mode 100644 src/app/settings/views/Images/Sources/constants.ts create mode 100644 src/app/settings/views/Images/Sources/index.ts create mode 100644 src/app/settings/views/Images/Synchronization/Synchronization.test.tsx create mode 100644 src/app/settings/views/Images/Synchronization/Synchronization.tsx create mode 100644 src/app/settings/views/Images/Synchronization/index.ts diff --git a/src/app/api/query/imageSources.ts b/src/app/api/query/imageSources.ts index c799ec0826..dc170efa21 100644 --- a/src/app/api/query/imageSources.ts +++ b/src/app/api/query/imageSources.ts @@ -24,6 +24,8 @@ import type { FetchBootsourcesAvailableImagesData, FetchBootsourcesAvailableImagesResponses, FetchBootsourcesAvailableImagesErrors, + DeleteBootsourceData, + DeleteBootsourceResponses, } from "@/app/apiclient"; import { updateBootsource, @@ -101,6 +103,24 @@ export const useChangeImageSource = () => { }); }; +export const useCreateImageSource = ( + mutationOptions?: Options +) => { + const queryClient = useQueryClient(); + return useMutation({ + ...mutationOptionsWithHeaders< + CreateBootsourceResponses, + CreateBootsourceErrors, + CreateBootsourceData + >(mutationOptions, createBootsource), + onSuccess: () => { + return queryClient.invalidateQueries({ + queryKey: listBootsourcesQueryKey(), + }); + }, + }); +}; + export const useUpdateImageSource = ( mutationOptions?: Options ) => { @@ -119,6 +139,24 @@ export const useUpdateImageSource = ( }); }; +export const useDeleteImageSource = ( + mutationOptions?: Options +) => { + const queryClient = useQueryClient(); + return useMutation({ + ...mutationOptionsWithHeaders< + DeleteBootsourceResponses, + DeleteBootsourceErrors, + DeleteBootsourceData + >(mutationOptions, deleteBootsource), + onSuccess: () => { + return queryClient.invalidateQueries({ + queryKey: listBootsourcesQueryKey(), + }); + }, + }); +}; + export const useFetchImageSource = ( mutationOptions?: Options ) => { @@ -130,3 +168,23 @@ export const useFetchImageSource = ( >(mutationOptions, fetchBootsourcesAvailableImages), }); }; + +// TODO: implement default source enabling when v3 is ready +export const useEnableImageSource = () => { + return { + error: {}, + mutate: (_args: object) => {}, + isPending: false, + isSuccess: true, + }; +}; + +// TODO: implement default source disabling when v3 is ready +export const useDisableImageSource = () => { + return { + error: {}, + mutate: (_args: object) => {}, + isPending: false, + isSuccess: true, + }; +}; diff --git a/src/app/images/components/SelectUpstreamImagesForm/SelectUpstreamImagesForm.test.tsx b/src/app/images/components/SelectUpstreamImagesForm/SelectUpstreamImagesForm.test.tsx index a183131936..f37af6d407 100644 --- a/src/app/images/components/SelectUpstreamImagesForm/SelectUpstreamImagesForm.test.tsx +++ b/src/app/images/components/SelectUpstreamImagesForm/SelectUpstreamImagesForm.test.tsx @@ -1,7 +1,5 @@ import SelectUpstreamImagesForm from "./SelectUpstreamImagesForm"; -import * as factory from "@/testing/factories"; -import { imageSourceResolvers } from "@/testing/resolvers/imageSources"; import { imageResolvers } from "@/testing/resolvers/images"; import { userEvent, @@ -12,8 +10,7 @@ import { waitFor, } from "@/testing/utils"; -const mockServer = setupMockServer( - imageSourceResolvers.listImageSources.handler(), +setupMockServer( imageResolvers.listSelections.handler(), imageResolvers.listAvailableSelections.handler(), imageResolvers.addSelections.handler() @@ -72,35 +69,4 @@ describe("SelectUpstreamImagesForm", () => { expect(imageResolvers.addSelections.resolved).toBeTruthy(); }); }); - - it("disables form with a notification if more than one source detected", async () => { - mockServer.use( - imageSourceResolvers.listImageSources.handler({ - items: [ - factory.imageSourceFactory.build(), - factory.imageSourceFactory.build(), - ], - total: 2, - }) - ); - - renderWithProviders(); - - await waitFor(() => { - expect( - screen.getByText( - "More than one image source exists. The UI does not support updating synced images when more than one source has been defined. Use the API to adjust your sources." - ) - ).toBeInTheDocument(); - }); - expect( - screen.getByText( - "More than one image source exists. The UI does not support updating synced images when more than one source has been defined. Use the API to adjust your sources." - ) - ).toBeInTheDocument(); - - expect( - screen.queryByRole("button", { name: "Download" }) - ).not.toBeInTheDocument(); - }); }); diff --git a/src/app/images/components/SelectUpstreamImagesForm/SelectUpstreamImagesForm.tsx b/src/app/images/components/SelectUpstreamImagesForm/SelectUpstreamImagesForm.tsx index a105a58bb7..17c6c638d6 100644 --- a/src/app/images/components/SelectUpstreamImagesForm/SelectUpstreamImagesForm.tsx +++ b/src/app/images/components/SelectUpstreamImagesForm/SelectUpstreamImagesForm.tsx @@ -13,7 +13,6 @@ import SelectUpstreamImagesSelect, { } from "./SelectUpstreamImagesSelect"; import type { DownloadImagesSelectProps } from "./SelectUpstreamImagesSelect/SelectUpstreamImagesSelect"; -import { useImageSources } from "@/app/api/query/imageSources"; import { useAddSelections, useAvailableSelections, @@ -128,7 +127,6 @@ export const groupArchesByTitle = (images: ImagesByOS): GroupedImages => { const SelectUpstreamImagesForm = (): ReactElement => { const { closeSidePanel } = useSidePanel(); - const { data: sources, isPending: isSourcesPending } = useImageSources(); const { data: selectedImages, isPending: isSelectedImagesPending } = useSelections(); const { data: availableImages, isPending: isAvailableImagesPending } = @@ -138,9 +136,7 @@ const SelectUpstreamImagesForm = (): ReactElement => { const [groupedImages, setGroupedImages] = useState({}); - const isPending = - isSourcesPending || isSelectedImagesPending || isAvailableImagesPending; - const tooManySources = (sources?.total ?? 0) > 1; + const isPending = isSelectedImagesPending || isAvailableImagesPending; useEffect(() => { if (selectedImages && availableImages) { @@ -178,16 +174,6 @@ const SelectUpstreamImagesForm = (): ReactElement => { ) : ( <> - {tooManySources && ( - - More than one image source exists. The UI does not support - updating synced images when more than one source has been - defined. Use the API to adjust your sources. - - )} {noAvailableImages && ( { > No available upstream images found. This could be caused by an ongoing image source change. If you recently changed the image - source, please come back after some time. + source settings, please come back after some time. )} { ); expect(screen.getByText("Loading...")).toBeInTheDocument(); }); -}); -describe("Change sources", () => { it("renders the correct text for a single default source", async () => { renderWithProviders( {}} /> @@ -72,7 +70,9 @@ describe("Change sources", () => { ); await waitForLoading(); const images_from = screen.getByText("Images synced from"); - expect(within(images_from).getByText("sources")).toBeInTheDocument(); + expect( + within(images_from).getByText("multiple sources") + ).toBeInTheDocument(); }); }); diff --git a/src/app/images/views/ImageList/ImageListHeader/ImageListHeader.tsx b/src/app/images/views/ImageList/ImageListHeader/ImageListHeader.tsx index 45937d68b0..ff39ce7f84 100644 --- a/src/app/images/views/ImageList/ImageListHeader/ImageListHeader.tsx +++ b/src/app/images/views/ImageList/ImageListHeader/ImageListHeader.tsx @@ -28,7 +28,7 @@ const getImageSyncText = (sources: BootSourceResponse[]) => { } return mainSource.url; } - return "sources"; + return "multiple sources"; }; const ImageListHeader = ({ diff --git a/src/app/settings/constants.ts b/src/app/settings/constants.ts index f55e27edb3..1ff375b627 100644 --- a/src/app/settings/constants.ts +++ b/src/app/settings/constants.ts @@ -58,7 +58,8 @@ export const settingsNavItems: NavItem[] = [ { path: settingsURLs.images.ubuntu, label: "Ubuntu" }, { path: settingsURLs.images.windows, label: "Windows" }, { path: settingsURLs.images.vmware, label: "VMware" }, - { path: settingsURLs.images.source, label: "Source" }, + { path: settingsURLs.images.sources, label: "Sources" }, + { path: settingsURLs.images.sync, label: "Synchronization" }, ], }, { diff --git a/src/app/settings/urls.ts b/src/app/settings/urls.ts index 474661f7cb..82d43507dd 100644 --- a/src/app/settings/urls.ts +++ b/src/app/settings/urls.ts @@ -21,7 +21,8 @@ const urls = { ubuntu: "/settings/images/ubuntu", vmware: "/settings/images/vmware", windows: "/settings/images/windows", - source: "/settings/images/source", + sources: "/settings/images/sources", + sync: "/settings/images/sync", }, licenseKeys: { add: "/settings/license-keys/add", diff --git a/src/app/settings/views/Images/ChangeSource/ChangeSource.test.tsx b/src/app/settings/views/Images/ChangeSource/ChangeSource.test.tsx deleted file mode 100644 index 01371ded21..0000000000 --- a/src/app/settings/views/Images/ChangeSource/ChangeSource.test.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import ChangeSource from "@/app/settings/views/Images/ChangeSource/ChangeSource"; -import { Labels } from "@/app/settings/views/Images/ChangeSource/ChangeSourceForm/ChangeSourceForm"; -import { ConfigNames } from "@/app/store/config/types"; -import * as factory from "@/testing/factories"; -import { configurationsResolvers } from "@/testing/resolvers/configurations"; -import { imageSourceResolvers } from "@/testing/resolvers/imageSources"; -import { imageResolvers } from "@/testing/resolvers/images"; -import { - renderWithProviders, - screen, - setupMockServer, - userEvent, - waitFor, - waitForLoading, -} from "@/testing/utils"; - -const mockServer = setupMockServer( - imageSourceResolvers.listImageSources.handler(), - imageSourceResolvers.getImageSource.handler(), - imageSourceResolvers.fetchImageSource.handler(), - imageSourceResolvers.createImageSource.handler(), - imageSourceResolvers.updateImageSource.handler(), - imageSourceResolvers.deleteImageSource.handler(), - imageResolvers.listSelectionStatuses.handler(), - imageResolvers.listCustomImageStatuses.handler(), - configurationsResolvers.getConfiguration.handler({ - name: ConfigNames.BOOT_IMAGES_AUTO_IMPORT, - value: true, - }), - configurationsResolvers.setConfiguration.handler() -); - -describe("ChangeSource", () => { - it("dispatches an action to update config when changing the auto sync switch", async () => { - renderWithProviders(); - await waitForLoading(); - await userEvent.click( - screen.getByRole("checkbox", { name: /Automatically sync images/i }) - ); - await waitFor(() => { - expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); - }); - await userEvent.click(screen.getByRole("button", { name: "Save" })); - - expect(configurationsResolvers.setConfiguration.resolved).toBe(true); - }); - - it("disables the button to change source if resources are downloading", async () => { - mockServer.use( - imageResolvers.listSelectionStatuses.handler({ - items: [factory.imageStatusFactory.build({ status: "Downloading" })], - total: 1, - }) - ); - renderWithProviders(); - await waitForLoading(); - expect(screen.getByRole("button", { name: "Save" })).toBeAriaDisabled(); - expect( - screen.getByTestId("cannot-change-source-warning") - ).toBeInTheDocument(); - }); - - it("does not display keyring fields when unsigned keyring type is selected", async () => { - renderWithProviders(); - await waitForLoading(); - - await userEvent.click(screen.getByRole("radio", { name: Labels.Custom })); - - const select = screen.getByRole("combobox"); - await userEvent.selectOptions(select, "keyring_unsigned"); - - expect( - screen.getByRole("textbox", { name: Labels.Url }) - ).toBeInTheDocument(); - - expect( - screen.queryByPlaceholderText( - "e.g. /usr/share/keyrings/ubuntu-cloudimage-keyring.gpg" - ) - ).not.toBeInTheDocument(); - expect( - screen.queryByPlaceholderText("Contents of GPG key (base64 encoded)") - ).not.toBeInTheDocument(); - }); - - it("shows error when keyring_filename is empty and keyring_type is keyring_filename", async () => { - renderWithProviders(); - await waitForLoading(); - - await userEvent.click(screen.getByRole("radio", { name: Labels.Custom })); - - const select = screen.getByRole("combobox"); - await userEvent.selectOptions(select, "keyring_filename"); - - // Focus and blur the keyring filename field to trigger validation - const keyringFilenameInput = screen.getByPlaceholderText( - "e.g. /usr/share/keyrings/ubuntu-cloudimage-keyring.gpg" - ); - await userEvent.click(keyringFilenameInput); - await userEvent.clear(keyringFilenameInput); - await userEvent.tab(); - - await waitFor(() => { - expect( - screen.getByText("Keyring filename is required") - ).toBeInTheDocument(); - }); - }); - - it("shows error when keyring_data is empty and keyring_type is keyring_data", async () => { - renderWithProviders(); - await waitForLoading(); - - await userEvent.click(screen.getByRole("radio", { name: Labels.Custom })); - - const select = screen.getByRole("combobox"); - await userEvent.selectOptions(select, "keyring_data"); - - // Focus and blur the keyring data field to trigger validation - const keyringDataInput = screen.getByPlaceholderText( - "Contents of GPG key (base64 encoded)" - ); - await userEvent.click(keyringDataInput); - await userEvent.tab(); - - await waitFor(() => { - expect(screen.getByText("Keyring data is required")).toBeInTheDocument(); - }); - }); - - it("creates a new source and deletes the old one when URL is changed", async () => { - renderWithProviders(); - await waitForLoading(); - - // Change to custom source - await userEvent.click(screen.getByRole("radio", { name: Labels.Custom })); - - // Update the URL - const urlInput = screen.getByRole("textbox", { name: Labels.Url }); - await userEvent.clear(urlInput); - await userEvent.type(urlInput, "http://example.com/ephemeral-v3/stable/"); - - const select = screen.getByRole("combobox"); - await userEvent.selectOptions(select, "keyring_unsigned"); - - const validateButton = screen.getByRole("button", { name: "Validate" }); - await userEvent.click(validateButton); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); - }); - - await userEvent.click(screen.getByRole("button", { name: "Save" })); - - await waitFor(() => { - expect(imageSourceResolvers.createImageSource.resolved).toBe(true); - }); - }); - - it("updates existing source when URL is unchanged", async () => { - mockServer.use( - imageSourceResolvers.getImageSource.handler( - factory.imageSourceFactory.build({ - id: 1, - url: "http://custom.example.com/ephemeral-v3/stable/", - keyring_filename: "/path/to/keyring.gpg", - keyring_data: "", - priority: 0, - skip_keyring_verification: false, - }) - ) - ); - - renderWithProviders(); - await waitForLoading(); - - // Change keyring type to keyring_data (different from initial keyring_filename) - const select = screen.getByRole("combobox"); - await userEvent.selectOptions(select, "keyring_data"); - - const keyringDataInput = screen.getByPlaceholderText( - "Contents of GPG key (base64 encoded)" - ); - await userEvent.type(keyringDataInput, "aabbccdd"); - - await userEvent.click(screen.getByRole("button", { name: "Validate" })); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); - }); - - await userEvent.click(screen.getByRole("button", { name: "Save" })); - - await waitFor(() => { - expect(imageSourceResolvers.updateImageSource.resolved).toBe(true); - }); - }); - - it("displays error and keeps button as Validate if fetch fails", async () => { - mockServer.use( - imageSourceResolvers.fetchImageSource.error({ - message: "Invalid boot source URL", - code: 400, - }) - ); - - renderWithProviders(); - await waitForLoading(); - - await userEvent.click(screen.getByRole("radio", { name: Labels.Custom })); - - const urlInput = screen.getByRole("textbox", { name: Labels.Url }); - await userEvent.clear(urlInput); - await userEvent.type(urlInput, "http://invalid.example.com/"); - - const select = screen.getByRole("combobox"); - await userEvent.selectOptions(select, "keyring_unsigned"); - - await userEvent.click(screen.getByRole("button", { name: "Validate" })); - - await waitFor(() => { - expect(screen.getByText("Invalid boot source URL")).toBeInTheDocument(); - }); - - // "Save" should be disabled, while "Validate" still enabled - expect(screen.getByRole("button", { name: "Validate" })).toBeEnabled(); - expect(screen.queryByRole("button", { name: "Save" })).toBeDisabled(); - }); -}); diff --git a/src/app/settings/views/Images/ChangeSource/ChangeSource.tsx b/src/app/settings/views/Images/ChangeSource/ChangeSource.tsx deleted file mode 100644 index 424cb4ee28..0000000000 --- a/src/app/settings/views/Images/ChangeSource/ChangeSource.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import type { ReactElement } from "react"; -import { useEffect, useState } from "react"; - -import { ContentSection } from "@canonical/maas-react-components"; -import { - Col, - Notification as NotificationBanner, - Row, - Spinner, -} from "@canonical/react-components"; -import { useDispatch, useSelector } from "react-redux"; -import * as Yup from "yup"; - -import { - useGetConfiguration, - useSetConfiguration, -} from "@/app/api/query/configurations"; -import { - useChangeImageSource, - useFetchImageSource, - useGetImageSource, - useImageSources, - useUpdateImageSource, -} from "@/app/api/query/imageSources"; -import { - useCustomImageStatuses, - useSelectionStatuses, -} from "@/app/api/query/images"; -import type { BootSourceCreateRequest } from "@/app/apiclient"; -import PageContent from "@/app/base/components/PageContent"; -import { useWindowTitle } from "@/app/base/hooks"; -import ChangeSourceForm from "@/app/settings/views/Images/ChangeSource/ChangeSourceForm"; -import { ConfigNames } from "@/app/store/config/types"; -import { generalActions } from "@/app/store/general"; -import { installType } from "@/app/store/general/selectors"; - -export const ChangeSourceSchema = Yup.object() - .shape({ - keyring_type: Yup.string() - .oneOf(["keyring_data", "keyring_filename", "keyring_unsigned"]) - .required("Keyring type is required"), - keyring_data: Yup.string().when("keyring_type", { - is: "keyring_data", - then: (schema) => schema.required("Keyring data is required"), - otherwise: (schema) => schema, - }), - keyring_filename: Yup.string().when("keyring_type", { - is: "keyring_filename", - then: (schema) => schema.required("Keyring filename is required"), - otherwise: (schema) => schema, - }), - url: Yup.string().required("URL is required"), - autoSync: Yup.boolean(), - }) - .defined(); - -export type ChangeSourceValues = BootSourceCreateRequest & { - keyring_type: "keyring_data" | "keyring_filename" | "keyring_unsigned"; - autoSync: boolean; -}; - -const ChangeSource = (): ReactElement => { - const dispatch = useDispatch(); - const [isValidated, setIsValidated] = useState(false); - const [lastValidatedValues, setLastValidatedValues] = - useState(null); - const sources = useImageSources(); - // TODO: add support for multiple sources when v3 is ready - const source = useGetImageSource( - { - path: { boot_source_id: sources.data?.items[0].id ?? -1 }, - }, - sources.isSuccess - ); - const { data: selectionStatuses, error: selectionStatusesError } = - useSelectionStatuses(); - const { data: customImageStatuses, error: customImageStatusesError } = - useCustomImageStatuses(); - - const installTypeData = useSelector(installType.get); - - useEffect(() => { - dispatch(generalActions.fetchInstallType()); - }); - - const importConfig = useGetConfiguration({ - path: { name: ConfigNames.BOOT_IMAGES_AUTO_IMPORT }, - }); - const configETag = importConfig.data?.headers?.get("ETag"); - const autoImport = importConfig.data?.value as boolean; - const updateConfig = useSetConfiguration(); - const fetchImageSource = useFetchImageSource(); - const changeImageSource = useChangeImageSource(); - const updateImageSource = useUpdateImageSource(); - - const loading = - sources.isPending || source.isPending || importConfig.isPending; - - const saving = updateConfig.isPending || changeImageSource.isPending; - const saved = updateConfig.isSuccess || changeImageSource.isSuccess; - - const errors = - sources.error || - selectionStatusesError || - customImageStatusesError || - importConfig.error || - fetchImageSource.error || - updateConfig.error || - changeImageSource.error; - - useWindowTitle("Source"); - - const canChangeSource = - !!selectionStatuses && - !!customImageStatuses && - [...selectionStatuses.items, ...customImageStatuses.items].every( - (s) => s.status !== "Downloading" && s.update_status !== "Downloading" - ); - - const onValidateSource = async (values: ChangeSourceValues) => { - if (!isValidated) { - try { - await fetchImageSource.mutateAsync( - { - body: { - url: values.url, - keyring_filename: - values.keyring_type === "keyring_filename" - ? values.keyring_filename - : undefined, - keyring_data: - values.keyring_type === "keyring_data" - ? values.keyring_data - : undefined, - skip_keyring_verification: - values.keyring_type === "keyring_unsigned" ? true : undefined, - }, - }, - { - onSuccess: () => { - setIsValidated(true); - setLastValidatedValues(values); - }, - } - ); - } catch { - // Error is surfaced via fetchImageSource.error / the errors variable - } - return; - } - - setIsValidated(false); - }; - - const onSubmitSource = ( - values: ChangeSourceValues, - initialValues: ChangeSourceValues - ) => { - if (values.autoSync !== initialValues.autoSync) { - updateConfig.mutate({ - headers: { - ETag: configETag, - }, - body: { - value: values.autoSync, - }, - path: { name: ConfigNames.BOOT_IMAGES_AUTO_IMPORT }, - }); - } - - if ( - values.url !== initialValues.url || - values.keyring_data !== initialValues.keyring_data || - values.keyring_filename !== initialValues.keyring_filename || - values.skip_keyring_verification !== - initialValues.skip_keyring_verification || - values.priority !== initialValues.priority - ) { - const modificationData = { - url: values.url, - keyring_data: - values.keyring_type === "keyring_data" - ? values.keyring_data - : undefined, - keyring_filename: - values.keyring_type === "keyring_filename" - ? values.keyring_filename - : undefined, - skip_keyring_verification: - values.keyring_type === "keyring_unsigned" ? true : undefined, - priority: values.priority, - current_boot_source_id: source.data?.id ?? -1, - }; - - if (values.url !== initialValues.url) { - changeImageSource.mutate({ - body: modificationData, - }); - } else { - updateImageSource.mutate({ - path: { - boot_source_id: source.data?.id ?? -1, - }, - body: modificationData, - }); - } - } - }; - - return ( - - - - Source - - - {loading && } - {!canChangeSource && ( - - Image import is in progress, cannot change source settings. - - )} - {!loading && ( - - - - - - )} - - - - ); -}; - -export default ChangeSource; diff --git a/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/ChangeSourceForm.test.tsx b/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/ChangeSourceForm.test.tsx deleted file mode 100644 index 086f500044..0000000000 --- a/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/ChangeSourceForm.test.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import ChangeSourceForm, { - Labels, -} from "@/app/settings/views/Images/ChangeSource/ChangeSourceForm/ChangeSourceForm"; -import { imageSourceFactory } from "@/testing/factories"; -import { userEvent, renderWithProviders, screen } from "@/testing/utils"; - -describe("ChangeSourceForm", () => { - it("does not show extra fields if maas.io source is selected", async () => { - renderWithProviders( - - ); - expect( - screen.queryByRole("textbox", { name: Labels.Url }) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole("textbox", { - name: Labels.KeyringFilename, - }) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole("textbox", { - name: Labels.KeyringData, - }) - ).not.toBeInTheDocument(); - }); - - it("persists custom fields when switching to maas.io source", async () => { - renderWithProviders( - - ); - // Switch to maas.io source and back - await userEvent.click(screen.getByRole("radio", { name: Labels.MaasIo })); - await userEvent.click(screen.getByRole("radio", { name: Labels.Custom })); - - expect(screen.getByRole("textbox", { name: Labels.Url })).toHaveValue( - "http://www.example.com" - ); - }); - - it("persists autoSync value when switching between source types", async () => { - renderWithProviders( - - ); - - // Toggle autoSync checkbox in maas.io track - const autoSyncCheckbox = screen.getByRole("checkbox", { - name: Labels.AutoSyncImages, - }); - expect(autoSyncCheckbox).not.toBeChecked(); - await userEvent.click(autoSyncCheckbox); - expect(autoSyncCheckbox).toBeChecked(); - - // Switch to custom track - await userEvent.click(screen.getByRole("radio", { name: Labels.Custom })); - - // autoSync should persist - expect( - screen.getByRole("checkbox", { name: Labels.AutoSyncImages }) - ).toBeChecked(); - - // Switch back to maas.io - await userEvent.click(screen.getByRole("radio", { name: Labels.MaasIo })); - - // autoSync should still persist - expect( - screen.getByRole("checkbox", { name: Labels.AutoSyncImages }) - ).toBeChecked(); - - // Save should be enabled because autoSync is changed - expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); - }); -}); diff --git a/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/ChangeSourceForm.tsx b/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/ChangeSourceForm.tsx deleted file mode 100644 index 2f6cdf9576..0000000000 --- a/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/ChangeSourceForm.tsx +++ /dev/null @@ -1,294 +0,0 @@ -import type { Dispatch, ReactElement, SetStateAction } from "react"; -import { useEffect, useMemo, useRef, useState } from "react"; - -import { - RadioInput, - Notification as NotificationBanner, -} from "@canonical/react-components"; - -import type { - BootSourceResponse, - NotFoundBodyResponse, - ValidationErrorBodyResponse, -} from "@/app/apiclient"; -import { - MAAS_IO_DEFAULT_KEYRING_FILE_PATHS, - MAAS_IO_URLS, -} from "@/app/images/constants"; -import { BootResourceSourceType } from "@/app/images/types"; -import type { ChangeSourceValues } from "@/app/settings/views/Images/ChangeSource/ChangeSource"; -import CustomSourceForm from "@/app/settings/views/Images/ChangeSource/ChangeSourceForm/CustomSourceForm"; -import MaasIoSourceForm from "@/app/settings/views/Images/ChangeSource/ChangeSourceForm/MaasIoSourceForm"; - -export enum Labels { - AutoSyncImages = "Automatically sync images", - MaasIo = "images.maas.io", - Custom = "Custom", - Url = "URL", - KeyringFilename = "Keyring filename", - KeyringData = "Keyring data", -} - -const getKeyringType = ( - keyring_filename?: string, - keyring_data?: string -): "keyring_data" | "keyring_filename" | "keyring_unsigned" => { - if (keyring_filename) return "keyring_filename"; - if (keyring_data) return "keyring_data"; - return "keyring_unsigned"; -}; - -const getSourceType = (url: string): BootResourceSourceType => { - const isMaasIo = - new RegExp(MAAS_IO_URLS.stable).test(url) || - new RegExp(MAAS_IO_URLS.candidate).test(url); - return isMaasIo - ? BootResourceSourceType.MAAS_IO - : BootResourceSourceType.CUSTOM; -}; - -type ChangeSourceFormProps = { - errors: NotFoundBodyResponse | ValidationErrorBodyResponse | null; - canChangeSource: boolean; - autoImport: boolean; - source: BootSourceResponse; - installType: string; - onSubmitSource: ( - values: ChangeSourceValues, - initialValues: ChangeSourceValues - ) => void; - onValidateSource: (values: ChangeSourceValues) => Promise; - saved: boolean; - saving: boolean; - validated: boolean; - validating: boolean; - lastValidatedValues: ChangeSourceValues | null; - setIsValidated: Dispatch>; -}; - -const ChangeSourceForm = ({ - canChangeSource, - autoImport, - source, - installType, - saved, - saving, - validated, - validating, - errors, - onValidateSource, - onSubmitSource, - lastValidatedValues, - setIsValidated, -}: ChangeSourceFormProps): ReactElement => { - const [sourceType, setSourceType] = useState( - BootResourceSourceType.MAAS_IO - ); - const [showSourceChangeWarning, setShowSourceChangeWarning] = useState(false); - - const defaultKeyringFilename = source.keyring_filename?.length - ? source.keyring_filename - : installType === "deb" - ? MAAS_IO_DEFAULT_KEYRING_FILE_PATHS.deb - : MAAS_IO_DEFAULT_KEYRING_FILE_PATHS.snap; - - const serverValues: ChangeSourceValues = useMemo( - () => ({ - keyring_data: source.keyring_data ?? "", - keyring_filename: source.keyring_filename ?? "", - keyring_type: - getSourceType(source.url ?? "") === BootResourceSourceType.MAAS_IO - ? "keyring_filename" - : getKeyringType(source.keyring_filename, source.keyring_data), - url: source.url ?? "", - autoSync: autoImport, - priority: source.priority === 10 ? 9 : 10, - }), - [ - source.keyring_data, - source.keyring_filename, - source.url, - source.priority, - autoImport, - ] - ); - - const initialValues: ChangeSourceValues = useMemo( - () => ({ - keyring_data: source.keyring_data ?? "", - keyring_filename: defaultKeyringFilename, - keyring_type: - getSourceType(source.url ?? "") === BootResourceSourceType.MAAS_IO - ? "keyring_filename" - : getKeyringType(source.keyring_filename, source.keyring_data), - url: source.url ?? "", - autoSync: autoImport || false, - // TODO: add priority field when multiple sources are supported. - // Since priority must be unique, fake uniqueness by switching - // between 10 and 9 until multiple sources introduce an explicit - // priority field - priority: source.priority === 10 ? 9 : 10, - }), - [ - source.keyring_data, - source.keyring_filename, - source.url, - source.priority, - autoImport, - defaultKeyringFilename, - ] - ); - - const customValuesRef = useRef<{ - url: string; - keyring_filename: string; - keyring_data: string; - keyring_type: "keyring_data" | "keyring_filename" | "keyring_unsigned"; - }>({ - url: "", - keyring_filename: "", - keyring_data: "", - keyring_type: "keyring_filename", - }); - - const autoSyncRef = useRef(autoImport || false); - useEffect(() => { - autoSyncRef.current = autoImport || false; - }, [autoImport]); - - useEffect(() => { - if (source.url) { - const resolvedSourceType = getSourceType(source.url); - setSourceType(resolvedSourceType); - if (resolvedSourceType === BootResourceSourceType.CUSTOM) { - customValuesRef.current = { - url: source.url, - keyring_filename: defaultKeyringFilename, - keyring_data: source.keyring_data ?? "", - keyring_type: getKeyringType( - source.keyring_filename, - source.keyring_data - ), - }; - } - } - }, [ - source.url, - source.keyring_data, - source.keyring_filename, - defaultKeyringFilename, - ]); - - const onValuesChanged = (values: ChangeSourceValues) => { - autoSyncRef.current = values.autoSync; - - if (sourceType === BootResourceSourceType.CUSTOM) { - customValuesRef.current = { - url: values.url, - keyring_filename: values.keyring_filename ?? "", - keyring_data: values.keyring_data ?? "", - keyring_type: values.keyring_type, - }; - } - - const sourceSettingsChanged = - values.url !== (source.url ?? "") || - values.keyring_data !== (source.keyring_data ?? "") || - values.keyring_filename !== (source.keyring_filename ?? ""); - - setShowSourceChangeWarning(!saved && !saving && sourceSettingsChanged); - - if ( - lastValidatedValues && - JSON.stringify(values) !== JSON.stringify(lastValidatedValues) - ) { - setIsValidated(false); - } - }; - - return ( - <> -
    -
  • - { - setSourceType(BootResourceSourceType.MAAS_IO); - }} - value={BootResourceSourceType.MAAS_IO} - /> -
  • -
  • - { - setSourceType(BootResourceSourceType.CUSTOM); - }} - value={BootResourceSourceType.CUSTOM} - /> -
  • -
- {showSourceChangeWarning && ( - - Changing the image source will remove all currently downloaded images. - - )} - {sourceType === BootResourceSourceType.MAAS_IO ? ( - { - // Silently validate when saving a maas.io source - onValidateSource(values).then(() => { - onSubmitSource(values, serverValues); - }); - }} - onValuesChanged={onValuesChanged} - saved={saved} - saving={saving || validating} - serverValues={serverValues} - /> - ) : ( - { - onSubmitSource(values, serverValues); - }} - onValidate={onValidateSource} - onValuesChanged={onValuesChanged} - saved={saved} - saving={saving} - serverValues={serverValues} - validated={validated} - validating={validating} - /> - )} - - ); -}; - -export default ChangeSourceForm; diff --git a/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/CustomSourceForm/CustomSourceForm.test.tsx b/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/CustomSourceForm/CustomSourceForm.test.tsx deleted file mode 100644 index 7d5de8ae9a..0000000000 --- a/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/CustomSourceForm/CustomSourceForm.test.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { MAAS_IO_DEFAULT_KEYRING_FILE_PATHS } from "@/app/images/constants"; -import type { ChangeSourceValues } from "@/app/settings/views/Images/ChangeSource/ChangeSource"; -import { Labels } from "@/app/settings/views/Images/ChangeSource/ChangeSourceForm/ChangeSourceForm"; -import CustomSourceForm from "@/app/settings/views/Images/ChangeSource/ChangeSourceForm/CustomSourceForm/CustomSourceForm"; -import { userEvent, renderWithProviders, screen } from "@/testing/utils"; - -describe("CustomSourceForm", () => { - it("shows url fields if custom source is selected", async () => { - const initialValues: ChangeSourceValues = { - keyring_data: "", - keyring_filename: "", - keyring_type: "keyring_filename", - url: "", - priority: 0, - autoSync: false, - }; - - renderWithProviders( - - ); - - expect( - screen.getByRole("textbox", { name: Labels.Url }) - ).toBeInTheDocument(); - expect( - screen.getByRole("textbox", { name: Labels.KeyringFilename }) - ).toBeInTheDocument(); - expect( - screen.queryByRole("textbox", { name: Labels.KeyringData }) - ).not.toBeInTheDocument(); - }); - - it("switches between keyring filename and keyring data fields when selecting different options", async () => { - const initialValues: ChangeSourceValues = { - keyring_data: "", - keyring_filename: "", - keyring_type: "keyring_filename", - url: "", - priority: 0, - autoSync: false, - }; - - renderWithProviders( - - ); - - expect( - screen.getByRole("textbox", { name: Labels.KeyringFilename }) - ).toBeInTheDocument(); - expect( - screen.queryByRole("textbox", { name: Labels.KeyringData }) - ).not.toBeInTheDocument(); - - // Switch to keyring_data - const select = screen.getByRole("combobox"); - await userEvent.selectOptions(select, "keyring_data"); - - expect( - screen.queryByRole("textbox", { name: Labels.KeyringFilename }) - ).not.toBeInTheDocument(); - expect( - screen.getByRole("textbox", { name: Labels.KeyringData }) - ).toBeInTheDocument(); - }); - - it("clears the other field when switching between keyring types", async () => { - const initialValues: ChangeSourceValues = { - keyring_data: "some data", - keyring_filename: "/path/to/file", - keyring_type: "keyring_filename", - url: "http://example.com", - priority: 0, - autoSync: false, - }; - - renderWithProviders( - - ); - - expect( - screen.getByRole("textbox", { name: Labels.KeyringFilename }) - ).toHaveValue("/path/to/file"); - - // Switch to keyring_data - const select = screen.getByRole("combobox"); - await userEvent.selectOptions(select, "keyring_data"); - - // keyring_data should be shown with its value - expect( - screen.getByRole("textbox", { name: Labels.KeyringData }) - ).toHaveValue("some data"); - - // Switch back to keyring_filename - await userEvent.selectOptions(select, "keyring_filename"); - - // URL should still have its value - expect(screen.getByRole("textbox", { name: Labels.Url })).toHaveValue( - "http://example.com" - ); - }); - - it("pre-populates custom source with correct default keyring based on install type", async () => { - const debInitialValues: ChangeSourceValues = { - keyring_data: "", - keyring_filename: MAAS_IO_DEFAULT_KEYRING_FILE_PATHS.deb, - keyring_type: "keyring_filename", - url: "http://custom.example.com/stable/", - priority: 0, - autoSync: false, - }; - - // Test with deb install type - const { rerender } = renderWithProviders( - - ); - - // Verify deb default keyring is shown - expect( - screen.getByRole("textbox", { name: Labels.KeyringFilename }) - ).toHaveValue(MAAS_IO_DEFAULT_KEYRING_FILE_PATHS.deb); - - // Test with snap install type - const snapInitialValues: ChangeSourceValues = { - keyring_data: "", - keyring_filename: MAAS_IO_DEFAULT_KEYRING_FILE_PATHS.snap, - keyring_type: "keyring_filename", - url: "http://custom.example.com/stable/", - priority: 0, - autoSync: false, - }; - - rerender( - - ); - - // Verify snap default keyring is shown - expect( - screen.getByRole("textbox", { name: Labels.KeyringFilename }) - ).toHaveValue(MAAS_IO_DEFAULT_KEYRING_FILE_PATHS.snap); - }); -}); diff --git a/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/CustomSourceForm/CustomSourceForm.tsx b/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/CustomSourceForm/CustomSourceForm.tsx deleted file mode 100644 index cadcd46bdc..0000000000 --- a/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/CustomSourceForm/CustomSourceForm.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import type { ReactElement } from "react"; -import React, { useEffect, useRef, useState } from "react"; - -import { Icon, Select, Textarea, Tooltip } from "@canonical/react-components"; -import type { FormikContextType } from "formik"; - -import type { - NotFoundBodyResponse, - ValidationErrorBodyResponse, -} from "@/app/apiclient"; -import FormikField from "@/app/base/components/FormikField"; -import { FormikFieldChangeError } from "@/app/base/components/FormikField/FormikField"; -import FormikForm from "@/app/base/components/FormikForm"; -import type { ChangeSourceValues } from "@/app/settings/views/Images/ChangeSource/ChangeSource"; -import { ChangeSourceSchema } from "@/app/settings/views/Images/ChangeSource/ChangeSource"; -import { Labels } from "@/app/settings/views/Images/ChangeSource/ChangeSourceForm/ChangeSourceForm"; - -type CustomSourceFormProps = { - enabled: boolean; - errors: NotFoundBodyResponse | ValidationErrorBodyResponse | null; - initialValues: ChangeSourceValues; - serverValues: ChangeSourceValues; - onSubmit: (values: ChangeSourceValues) => void; - onValidate: (values: ChangeSourceValues) => void; - onValuesChanged: (values: ChangeSourceValues) => void; - saved: boolean; - saving: boolean; - validated: boolean; - validating: boolean; -}; - -const CustomSourceForm = ({ - enabled, - errors, - initialValues, - serverValues, - onSubmit, - onValidate, - onValuesChanged, - saved, - saving, - validated, - validating, -}: CustomSourceFormProps): ReactElement => { - const [selectedKeyringType, setSelectedKeyringType] = useState< - "keyring_data" | "keyring_filename" | "keyring_unsigned" - >(initialValues.keyring_type || "keyring_filename"); - - const formikRef = useRef>(null); - - useEffect(() => { - if ( - formikRef.current && - JSON.stringify(initialValues) !== JSON.stringify(serverValues) - ) { - formikRef.current.setValues(initialValues); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const prevServerValuesRef = useRef(serverValues); - useEffect(() => { - if ( - JSON.stringify(prevServerValuesRef.current) !== - JSON.stringify(serverValues) - ) { - prevServerValuesRef.current = serverValues; - formikRef.current?.resetForm({ values: serverValues }); - } - }, [serverValues]); - - useEffect(() => { - setSelectedKeyringType(initialValues.keyring_type || "keyring_filename"); - }, [initialValues.keyring_type]); - - return ( - - aria-label="Choose source" - buttonsBehavior="independent" - errors={errors} - initialValues={serverValues} - innerRef={formikRef} - onSubmit={onSubmit} - onValuesChanged={onValuesChanged} - saved={saved} - saving={saving} - secondarySubmit={onValidate} - secondarySubmitDisabled={!enabled} - secondarySubmitLabel={!validated ? "Validate" : undefined} - secondarySubmitSaved={validated} - secondarySubmitSaving={validating} - submitDisabled={!enabled || !validated} - submitLabel="Save" - validationSchema={ChangeSourceSchema} - > - {({ - setFieldValue, - validateForm, - }: FormikContextType) => { - return ( - <> - - ) => { - const newChannel = e.target.value as "candidate" | "stable"; - setSelectedChannel(newChannel); - const newUrl = MAAS_IO_URLS[newChannel]; - await setFieldValue("url", newUrl).catch((reason: unknown) => { - throw new FormikFieldChangeError( - "url", - "setFieldValue", - reason as string - ); - }); - }} - options={[ - { label: `Stable (${MAAS_IO_URLS.stable})`, value: "stable" }, - { - label: `Candidate (${MAAS_IO_URLS.candidate})`, - value: "candidate", - }, - ]} - required - value={selectedChannel} - /> - - {Labels.AutoSyncImages} - -
- -
-
- - } - name="autoSync" - type="checkbox" - /> - - ); - }} -
- ); -}; - -export default MaasIoSourceForm; diff --git a/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/MaasIoSourceForm/index.ts b/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/MaasIoSourceForm/index.ts deleted file mode 100644 index 9269122a6a..0000000000 --- a/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/MaasIoSourceForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./MaasIoSourceForm"; diff --git a/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/index.ts b/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/index.ts deleted file mode 100644 index 27b66175f0..0000000000 --- a/src/app/settings/views/Images/ChangeSource/ChangeSourceForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./ChangeSourceForm"; diff --git a/src/app/settings/views/Images/ChangeSource/index.ts b/src/app/settings/views/Images/ChangeSource/index.ts deleted file mode 100644 index 283ba033a8..0000000000 --- a/src/app/settings/views/Images/ChangeSource/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./ChangeSource"; diff --git a/src/app/settings/views/Images/Sources/Sources.test.tsx b/src/app/settings/views/Images/Sources/Sources.test.tsx new file mode 100644 index 0000000000..5cc397d680 --- /dev/null +++ b/src/app/settings/views/Images/Sources/Sources.test.tsx @@ -0,0 +1,45 @@ +import Sources from "@/app/settings/views/Images/Sources/Sources"; +import AddSource from "@/app/settings/views/Images/Sources/components/AddSource"; +import { ConfigNames } from "@/app/store/config/types"; +import { configurationsResolvers } from "@/testing/resolvers/configurations"; +import { imageSourceResolvers } from "@/testing/resolvers/imageSources"; +import { imageResolvers } from "@/testing/resolvers/images"; +import { + mockSidePanel, + renderWithProviders, + screen, + setupMockServer, + userEvent, +} from "@/testing/utils"; + +setupMockServer( + imageSourceResolvers.listImageSources.handler(), + imageSourceResolvers.getImageSource.handler(), + imageSourceResolvers.fetchImageSource.handler(), + imageSourceResolvers.createImageSource.handler(), + imageSourceResolvers.updateImageSource.handler(), + imageSourceResolvers.deleteImageSource.handler(), + imageResolvers.listSelectionStatuses.handler(), + imageResolvers.listCustomImageStatuses.handler(), + configurationsResolvers.getConfiguration.handler({ + name: ConfigNames.BOOT_IMAGES_AUTO_IMPORT, + value: true, + }), + configurationsResolvers.setConfiguration.handler() +); +const { mockOpen } = await mockSidePanel(); + +describe("Sources", () => { + it("opens add source side panel form", async () => { + renderWithProviders(); + + await userEvent.click( + screen.getByRole("button", { name: "Add custom source" }) + ); + + expect(mockOpen).toHaveBeenCalledWith({ + component: AddSource, + title: "Add custom source", + }); + }); +}); diff --git a/src/app/settings/views/Images/Sources/Sources.tsx b/src/app/settings/views/Images/Sources/Sources.tsx new file mode 100644 index 0000000000..72f7dce78d --- /dev/null +++ b/src/app/settings/views/Images/Sources/Sources.tsx @@ -0,0 +1,50 @@ +import type { ReactElement } from "react"; + +import { ContentSection, MainToolbar } from "@canonical/maas-react-components"; +import { Button } from "@canonical/react-components"; + +import type { BootSourceResponse } from "@/app/apiclient"; +import PageContent from "@/app/base/components/PageContent"; +import { useSidePanel } from "@/app/base/side-panel-context"; +import type { BootResourceSourceType } from "@/app/images/types"; +import AddSource from "@/app/settings/views/Images/Sources/components/AddSource"; +import SourcesTable from "@/app/settings/views/Images/Sources/components/SourcesTable"; + +export type ImageSource = BootSourceResponse & { + type: BootResourceSourceType; + // TODO: implement v3 enabled state + enabled?: boolean; +}; + +const Sources = (): ReactElement => { + const { openSidePanel } = useSidePanel(); + + return ( + + + + + Sources + + + + + + + + + + + ); +}; + +export default Sources; diff --git a/src/app/settings/views/Images/Sources/components/AddSource/AddSource.test.tsx b/src/app/settings/views/Images/Sources/components/AddSource/AddSource.test.tsx new file mode 100644 index 0000000000..6010fa2e9f --- /dev/null +++ b/src/app/settings/views/Images/Sources/components/AddSource/AddSource.test.tsx @@ -0,0 +1,246 @@ +import { expect, it } from "vitest"; + +import { MAAS_IO_DEFAULT_KEYRING_FILE_PATHS } from "@/app/images/constants"; +import AddSource from "@/app/settings/views/Images/Sources/components/AddSource/AddSource"; +import { Labels } from "@/app/settings/views/Images/Sources/constants"; +import * as factory from "@/testing/factories"; +import { imageSourceResolvers } from "@/testing/resolvers/imageSources"; +import { + userEvent, + renderWithProviders, + screen, + waitForLoading, + waitFor, + setupMockServer, + mockSidePanel, +} from "@/testing/utils"; + +const mockServer = setupMockServer( + imageSourceResolvers.fetchImageSource.handler(), + imageSourceResolvers.createImageSource.handler() +); +const { mockClose } = await mockSidePanel(); + +describe("AddSource", () => { + it("calls closeForm on cancel click", async () => { + renderWithProviders(); + await waitForLoading(); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(mockClose).toHaveBeenCalled(); + }); + + it("switches between keyring filename and keyring data fields when selecting different options", async () => { + renderWithProviders(); + + expect( + screen.getByRole("textbox", { name: Labels.KeyringFilename }) + ).toBeInTheDocument(); + expect( + screen.queryByRole("textbox", { name: Labels.KeyringData }) + ).not.toBeInTheDocument(); + + // Switch to keyring_data + const select = screen.getByRole("combobox"); + await userEvent.selectOptions(select, "keyring_data"); + + expect( + screen.queryByRole("textbox", { name: Labels.KeyringFilename }) + ).not.toBeInTheDocument(); + expect( + screen.getByRole("textbox", { name: Labels.KeyringData }) + ).toBeInTheDocument(); + }); + + it("clears the other field when switching between keyring types", async () => { + renderWithProviders(); + + // The default keyring filename is the snap path when no install type is set + expect( + screen.getByRole("textbox", { name: Labels.KeyringFilename }) + ).toHaveValue(MAAS_IO_DEFAULT_KEYRING_FILE_PATHS.snap); + + // Switch to keyring_data + const select = screen.getByRole("combobox"); + await userEvent.selectOptions(select, "keyring_data"); + + // keyring_data field should now be visible and empty + expect( + screen.getByRole("textbox", { name: Labels.KeyringData }) + ).toHaveValue(""); + + // Switch back to keyring_filename + await userEvent.selectOptions(select, "keyring_filename"); + + // URL should still have its (empty) initial value + expect(screen.getByRole("textbox", { name: Labels.Url })).toHaveValue(""); + }); + + it("pre-populates custom source with correct default keyring based on install type", async () => { + const state = factory.rootState({ + general: factory.generalState({ + installType: factory.installTypeState({ data: "deb" }), + }), + }); + // Test with deb install type + const { rerender } = renderWithProviders(, { + state, + }); + + // Verify deb default keyring is shown + expect( + screen.getByRole("textbox", { name: Labels.KeyringFilename }) + ).toHaveValue(MAAS_IO_DEFAULT_KEYRING_FILE_PATHS.deb); + + // Test with snap install type + state.general.installType = factory.installTypeState({ data: "snap" }); + rerender(, { state }); + + // Verify snap default keyring is shown + expect( + screen.getByRole("textbox", { name: Labels.KeyringFilename }) + ).toHaveValue(MAAS_IO_DEFAULT_KEYRING_FILE_PATHS.snap); + }); + + it("does not display keyring fields when unsigned keyring type is selected", async () => { + renderWithProviders(); + await waitForLoading(); + + const select = screen.getByRole("combobox"); + await userEvent.selectOptions(select, "keyring_unsigned"); + + expect( + screen.getByRole("textbox", { name: Labels.Url }) + ).toBeInTheDocument(); + + expect( + screen.queryByPlaceholderText( + "e.g. /usr/share/keyrings/ubuntu-cloudimage-keyring.gpg" + ) + ).not.toBeInTheDocument(); + expect( + screen.queryByPlaceholderText("Contents of GPG key (base64 encoded)") + ).not.toBeInTheDocument(); + }); + + it("shows error when keyring_filename is empty and keyring_type is keyring_filename", async () => { + renderWithProviders(); + await waitForLoading(); + + const select = screen.getByRole("combobox"); + await userEvent.selectOptions(select, "keyring_filename"); + + // Focus and blur the keyring filename field to trigger validation + const keyringFilenameInput = screen.getByPlaceholderText( + "e.g. /usr/share/keyrings/ubuntu-cloudimage-keyring.gpg" + ); + await userEvent.click(keyringFilenameInput); + await userEvent.clear(keyringFilenameInput); + await userEvent.tab(); + + await waitFor(() => { + expect( + screen.getByText("Keyring filename is required") + ).toBeInTheDocument(); + }); + }); + + it("shows error when keyring_data is empty and keyring_type is keyring_data", async () => { + renderWithProviders(); + await waitForLoading(); + + const select = screen.getByRole("combobox"); + await userEvent.selectOptions(select, "keyring_data"); + + // Focus and blur the keyring data field to trigger validation + const keyringDataInput = screen.getByPlaceholderText( + "Contents of GPG key (base64 encoded)" + ); + await userEvent.click(keyringDataInput); + await userEvent.tab(); + + await waitFor(() => { + expect(screen.getByText("Keyring data is required")).toBeInTheDocument(); + }); + }); + + it("displays error and keeps button as Validate if fetch fails", async () => { + mockServer.use( + imageSourceResolvers.fetchImageSource.error({ + message: "Invalid boot source URL", + code: 400, + }) + ); + + renderWithProviders(); + await waitForLoading(); + + const urlInput = screen.getByRole("textbox", { name: Labels.Url }); + await userEvent.clear(urlInput); + await userEvent.type(urlInput, "http://invalid.example.com/"); + + const select = screen.getByRole("combobox"); + await userEvent.selectOptions(select, "keyring_unsigned"); + + await userEvent.click(screen.getByRole("button", { name: "Validate" })); + + await waitFor(() => { + expect(screen.getByText("Invalid boot source URL")).toBeInTheDocument(); + }); + + // "Save" should be disabled, while "Validate" still enabled + expect(screen.getByRole("button", { name: "Validate" })).toBeEnabled(); + expect( + screen.queryByRole("button", { name: "Save source" }) + ).toBeDisabled(); + }); + + it("calls create source on save click", async () => { + renderWithProviders(); + await waitForLoading(); + const urlInput = screen.getByRole("textbox", { name: Labels.Url }); + await userEvent.clear(urlInput); + await userEvent.type(urlInput, "http://invalid.example.com/"); + + const select = screen.getByRole("combobox"); + await userEvent.selectOptions(select, "keyring_unsigned"); + + await userEvent.click(screen.getByRole("button", { name: "Validate" })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Save source" })).toBeEnabled(); + }); + + await userEvent.click(screen.getByRole("button", { name: "Save source" })); + await waitFor(() => { + expect(imageSourceResolvers.createImageSource.resolved).toBeTruthy(); + }); + }); + + it("displays error messages when create source fails", async () => { + mockServer.use( + imageSourceResolvers.createImageSource.error({ + code: 400, + message: "Uh oh!", + }) + ); + renderWithProviders(); + await waitForLoading(); + const urlInput = screen.getByRole("textbox", { name: Labels.Url }); + await userEvent.clear(urlInput); + await userEvent.type(urlInput, "http://invalid.example.com/"); + + const select = screen.getByRole("combobox"); + await userEvent.selectOptions(select, "keyring_unsigned"); + + await userEvent.click(screen.getByRole("button", { name: "Validate" })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Save source" })).toBeEnabled(); + }); + + await userEvent.click(screen.getByRole("button", { name: "Save source" })); + await waitFor(() => { + expect(screen.getByText(/Uh oh!/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/app/settings/views/Images/Sources/components/AddSource/AddSource.tsx b/src/app/settings/views/Images/Sources/components/AddSource/AddSource.tsx new file mode 100644 index 0000000000..19c5517534 --- /dev/null +++ b/src/app/settings/views/Images/Sources/components/AddSource/AddSource.tsx @@ -0,0 +1,268 @@ +import type { ReactElement } from "react"; +import React, { useEffect, useState } from "react"; + +import { Select, Textarea } from "@canonical/react-components"; +import type { FormikContextType } from "formik"; +import { useDispatch, useSelector } from "react-redux"; +import * as Yup from "yup"; + +import { + useCreateImageSource, + useFetchImageSource, +} from "@/app/api/query/imageSources"; +import type { + BootSourceCreateRequest, + NotFoundBodyResponse, + ValidationErrorBodyResponse, +} from "@/app/apiclient"; +import FormikField from "@/app/base/components/FormikField"; +import { FormikFieldChangeError } from "@/app/base/components/FormikField/FormikField"; +import FormikForm from "@/app/base/components/FormikForm"; +import { useSidePanel } from "@/app/base/side-panel-context"; +import { MAAS_IO_DEFAULT_KEYRING_FILE_PATHS } from "@/app/images/constants"; +import { Labels } from "@/app/settings/views/Images/Sources/constants"; +import { generalActions } from "@/app/store/general"; +import { installType } from "@/app/store/general/selectors"; + +export const SourceSchema = Yup.object() + .shape({ + keyring_type: Yup.string() + .oneOf(["keyring_data", "keyring_filename", "keyring_unsigned"]) + .required("Keyring type is required"), + keyring_data: Yup.string().when("keyring_type", { + is: "keyring_data", + then: (schema) => schema.required("Keyring data is required"), + otherwise: (schema) => schema, + }), + keyring_filename: Yup.string().when("keyring_type", { + is: "keyring_filename", + then: (schema) => schema.required("Keyring filename is required"), + otherwise: (schema) => schema, + }), + url: Yup.string().required("URL is required"), + autoSync: Yup.boolean(), + }) + .defined(); + +export type SourceValues = BootSourceCreateRequest & { + keyring_type: "keyring_data" | "keyring_filename" | "keyring_unsigned"; +}; + +const AddSource = (): ReactElement => { + const dispatch = useDispatch(); + const { closeSidePanel } = useSidePanel(); + const [isValidated, setIsValidated] = useState(false); + const [lastValidatedValues, setLastValidatedValues] = + useState(null); + + const createSource = useCreateImageSource(); + const fetchImageSource = useFetchImageSource(); + + const installTypeData = useSelector(installType.get); + + useEffect(() => { + dispatch(generalActions.fetchInstallType()); + }); + + const onValidate = async (values: SourceValues) => { + if (!isValidated) { + try { + await fetchImageSource.mutateAsync( + { + body: { + url: values.url, + keyring_filename: + values.keyring_type === "keyring_filename" + ? values.keyring_filename + : undefined, + keyring_data: + values.keyring_type === "keyring_data" + ? values.keyring_data + : undefined, + skip_keyring_verification: + values.keyring_type === "keyring_unsigned" ? true : undefined, + }, + }, + { + onSuccess: () => { + setIsValidated(true); + setLastValidatedValues(values); + }, + } + ); + } catch { + // Error is surfaced via fetchImageSource.error / the errors variable + } + return; + } + setIsValidated(false); + }; + + const onValuesChanged = (values: SourceValues) => { + if ( + lastValidatedValues && + JSON.stringify(values) !== JSON.stringify(lastValidatedValues) + ) { + setIsValidated(false); + } + }; + + const errors = + createSource.error || fetchImageSource.error + ? ((createSource.error ?? fetchImageSource.error) as + | NotFoundBodyResponse + | ValidationErrorBodyResponse + | null) + : null; + + return ( + + aria-label="Add source" + buttonsBehavior="independent" + errors={errors} + initialValues={{ + url: "", + keyring_type: "keyring_filename", + keyring_filename: + installTypeData === "deb" + ? MAAS_IO_DEFAULT_KEYRING_FILE_PATHS.deb + : MAAS_IO_DEFAULT_KEYRING_FILE_PATHS.snap, + keyring_data: "", + skip_keyring_verification: undefined, + priority: 10, + }} + onCancel={closeSidePanel} + onSubmit={(values) => { + createSource.mutate( + { + body: { + url: values.url, + keyring_filename: + values.keyring_type === "keyring_filename" + ? values.keyring_filename + : undefined, + keyring_data: + values.keyring_type === "keyring_data" + ? values.keyring_data + : undefined, + skip_keyring_verification: + values.keyring_type === "keyring_unsigned" ? true : undefined, + priority: values.priority, + }, + }, + { onSuccess: closeSidePanel } + ); + }} + onValuesChanged={onValuesChanged} + saved={createSource.isSuccess} + saving={createSource.isPending} + secondarySubmit={onValidate} + secondarySubmitLabel={!isValidated ? "Validate" : undefined} + secondarySubmitSaved={isValidated} + secondarySubmitSaving={fetchImageSource.isPending} + submitDisabled={!isValidated} + submitLabel="Save source" + validationSchema={SourceSchema} + > + {({ + setFieldValue, + validateForm, + values, + }: FormikContextType) => { + return ( + <> + {/*TODO: uncomment when name field is available*/} + {/**/} + + + ) => { + const newType = e.target.value as + | "keyring_data" + | "keyring_filename" + | "keyring_unsigned"; + await setFieldValue("keyring_type", newType).catch( + (reason: unknown) => { + throw new FormikFieldChangeError( + "keyring_type", + "setFieldValue", + reason as string + ); + } + ); + // Clear the other field when switching types + if (newType === "keyring_filename") { + await setFieldValue("keyring_data", "").catch( + (reason: unknown) => { + throw new FormikFieldChangeError( + "keyring_data", + "setFieldValue", + reason as string + ); + } + ); + } else if (newType === "keyring_data") { + await setFieldValue("keyring_filename", "").catch( + (reason: unknown) => { + throw new FormikFieldChangeError( + "keyring_filename", + "setFieldValue", + reason as string + ); + } + ); + } + await validateForm(); + }} + options={[ + { + label: "Keyring filename", + value: "keyring_filename", + }, + { label: "Keyring data", value: "keyring_data" }, + { label: "Unsigned", value: "keyring_unsigned" }, + ]} + required + value={values.keyring_type} + /> + {values.keyring_type === "keyring_filename" ? ( + + ) : values.keyring_type === "keyring_data" ? ( + + ) : null} + + )} + + + ); + }} + + )} + + ); +}; + +export default EditSource; diff --git a/src/app/settings/views/Images/Sources/components/EditSource/index.ts b/src/app/settings/views/Images/Sources/components/EditSource/index.ts new file mode 100644 index 0000000000..a0bbdf8384 --- /dev/null +++ b/src/app/settings/views/Images/Sources/components/EditSource/index.ts @@ -0,0 +1 @@ +export { default } from "./EditSource"; diff --git a/src/app/settings/views/Images/Sources/components/EnableSource/EnableSource.tsx b/src/app/settings/views/Images/Sources/components/EnableSource/EnableSource.tsx new file mode 100644 index 0000000000..9953c89177 --- /dev/null +++ b/src/app/settings/views/Images/Sources/components/EnableSource/EnableSource.tsx @@ -0,0 +1,65 @@ +import type { ReactElement } from "react"; + +import { + Notification as NotificationBanner, + Spinner, +} from "@canonical/react-components"; + +import { + useEnableImageSource, + useGetImageSource, +} from "@/app/api/query/imageSources"; +import ModelActionForm from "@/app/base/components/ModelActionForm"; +import { useSidePanel } from "@/app/base/side-panel-context"; + +type EnableSourceProps = { + id: number; +}; + +const EnableSource = ({ id }: EnableSourceProps): ReactElement => { + const { closeSidePanel } = useSidePanel(); + + const source = useGetImageSource({ path: { boot_source_id: id } }, true); + + const eTag = source.data?.headers?.get("ETag"); + const enableSource = useEnableImageSource(); + + return ( + <> + {source.isPending && } + {source.isError && ( + + {source.error.message} + + )} + {source.isSuccess && source.data && ( + + Source will now be used to download images. + + } + modelType="default source" + onCancel={closeSidePanel} + onSubmit={() => { + enableSource.mutate({ + headers: { ETag: eTag }, + path: { boot_source_id: id }, + }); + }} + onSuccess={closeSidePanel} + saved={enableSource.isSuccess} + saving={enableSource.isPending} + submitAppearance="positive" + submitLabel="Enable source" + /> + )} + + ); +}; + +export default EnableSource; diff --git a/src/app/settings/views/Images/Sources/components/EnableSource/index.ts b/src/app/settings/views/Images/Sources/components/EnableSource/index.ts new file mode 100644 index 0000000000..64499bf462 --- /dev/null +++ b/src/app/settings/views/Images/Sources/components/EnableSource/index.ts @@ -0,0 +1 @@ +export { default } from "./EnableSource"; diff --git a/src/app/settings/views/Images/Sources/components/SourcesTable/SourcesTable.test.tsx b/src/app/settings/views/Images/Sources/components/SourcesTable/SourcesTable.test.tsx new file mode 100644 index 0000000000..6c07b16f46 --- /dev/null +++ b/src/app/settings/views/Images/Sources/components/SourcesTable/SourcesTable.test.tsx @@ -0,0 +1,200 @@ +import userEvent from "@testing-library/user-event"; +import { describe } from "vitest"; + +import SourcesTable from "./SourcesTable"; + +import DeleteSource from "@/app/settings/views/Images/Sources/components/DeleteSource"; +import EditSource from "@/app/settings/views/Images/Sources/components/EditSource"; +import { ConfigNames } from "@/app/store/config/types"; +import * as factory from "@/testing/factories"; +import { configurationsResolvers } from "@/testing/resolvers/configurations"; +import { imageSourceResolvers } from "@/testing/resolvers/imageSources"; +import { imageResolvers } from "@/testing/resolvers/images"; +import { + mockIsPending, + mockSidePanel, + renderWithProviders, + screen, + setupMockServer, + waitFor, + waitForLoading, +} from "@/testing/utils"; + +const mockServer = setupMockServer( + imageSourceResolvers.listImageSources.handler(), + imageSourceResolvers.getImageSource.handler(), + imageSourceResolvers.fetchImageSource.handler(), + imageSourceResolvers.createImageSource.handler(), + imageSourceResolvers.updateImageSource.handler(), + imageSourceResolvers.deleteImageSource.handler(), + imageResolvers.listSelectionStatuses.handler(), + imageResolvers.listCustomImageStatuses.handler(), + configurationsResolvers.getConfiguration.handler({ + name: ConfigNames.BOOT_IMAGES_AUTO_IMPORT, + value: true, + }), + configurationsResolvers.setConfiguration.handler() +); +const { mockOpen } = await mockSidePanel(); + +describe("SourcesTable", () => { + describe("display", () => { + it("displays a loading component if sources are loading", async () => { + mockIsPending(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + }); + + it("displays a message when rendering an empty list", async () => { + mockServer.use( + imageSourceResolvers.listImageSources.handler({ items: [], total: 0 }) + ); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("No sources found.")).toBeInTheDocument(); + }); + }); + + it("displays the columns correctly", () => { + renderWithProviders(); + + [ + "Name", + "Source URL", + "Priority", + "Signed with GPG key", + "Action", + ].forEach((column) => { + expect( + screen.getByRole("columnheader", { + name: new RegExp(`^${column}`, "i"), + }) + ).toBeInTheDocument(); + }); + }); + + it("displays the row actions correctly", async () => { + mockServer.use( + imageSourceResolvers.listImageSources.handler({ + items: [ + factory.imageSourceFactory.build({ id: 1 }), + factory.imageSourceFactory.build({ + id: 2, + url: "http://custom.image.source/stable/", + }), + ], + total: 1, + }) + ); + + renderWithProviders(); + await waitForLoading(); + + const rowActions = screen.getAllByRole("button", { + name: "Toggle menu", + }); + expect(rowActions.length).toBe(2); + + // Default source + await userEvent.click(rowActions[0]); + expect( + screen.getByRole("button", { name: "Edit source..." }) + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Delete source..." }) + ).not.toBeInTheDocument(); + // TODO: add enable/disable checks when backend is ready + await userEvent.click(rowActions[0]); + + // Custom source + await userEvent.click(rowActions[1]); + expect( + screen.getByRole("button", { name: "Edit source..." }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Delete source..." }) + ).toBeInTheDocument(); + }); + }); + + describe("actions", () => { + it("opens edit source side panel form", async () => { + mockServer.use( + imageSourceResolvers.listImageSources.handler({ + items: [factory.imageSourceFactory.build({ id: 1 })], + total: 1, + }) + ); + + renderWithProviders(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Toggle menu" }) + ).toBeInTheDocument(); + }); + await userEvent.click( + screen.getByRole("button", { name: "Toggle menu" }) + ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Edit source..." }) + ).toBeInTheDocument(); + }); + await userEvent.click( + screen.getByRole("button", { name: "Edit source..." }) + ); + + expect(mockOpen).toHaveBeenCalledWith({ + component: EditSource, + title: "Edit default source", + props: { id: 1, isDefault: true }, + }); + }); + + it("opens delete source side panel form", async () => { + mockServer.use( + imageSourceResolvers.listImageSources.handler({ + items: [ + factory.imageSourceFactory.build({ + id: 1, + url: "http://custom.image.source/stable/", + }), + ], + total: 1, + }) + ); + + renderWithProviders(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Toggle menu" }) + ).toBeInTheDocument(); + }); + await userEvent.click( + screen.getByRole("button", { name: "Toggle menu" }) + ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Delete source..." }) + ).toBeInTheDocument(); + }); + await userEvent.click( + screen.getByRole("button", { name: "Delete source..." }) + ); + + expect(mockOpen).toHaveBeenCalledWith({ + component: DeleteSource, + title: "Delete custom source", + props: { id: 1 }, + }); + }); + }); +}); diff --git a/src/app/settings/views/Images/Sources/components/SourcesTable/SourcesTable.tsx b/src/app/settings/views/Images/Sources/components/SourcesTable/SourcesTable.tsx new file mode 100644 index 0000000000..2469f8f62d --- /dev/null +++ b/src/app/settings/views/Images/Sources/components/SourcesTable/SourcesTable.tsx @@ -0,0 +1,119 @@ +import type { ReactElement } from "react"; +import { useMemo } from "react"; + +import { GenericTable } from "@canonical/maas-react-components"; +import { Notification as NotificationBanner } from "@canonical/react-components"; + +import { useGetConfiguration } from "@/app/api/query/configurations"; +import { useImageSources } from "@/app/api/query/imageSources"; +import { + useCustomImageStatuses, + useSelectionStatuses, +} from "@/app/api/query/images"; +import usePagination from "@/app/base/hooks/usePagination/usePagination"; +import { MAAS_IO_URLS } from "@/app/images/constants"; +import { BootResourceSourceType } from "@/app/images/types"; +import type { ImageSource } from "@/app/settings/views/Images/Sources/Sources"; +import useSourcesTableColumns, { + filterCells, + filterHeaders, +} from "@/app/settings/views/Images/Sources/components/SourcesTable/useSourcesTableColumns/useSourcesTableColumns"; +import { ConfigNames } from "@/app/store/config/types"; + +import "./_index.scss"; + +const getSourceType = (url: string): BootResourceSourceType => { + const isMaasIo = + new RegExp(MAAS_IO_URLS.stable).test(url) || + new RegExp(MAAS_IO_URLS.candidate).test(url); + return isMaasIo + ? BootResourceSourceType.MAAS_IO + : BootResourceSourceType.CUSTOM; +}; + +const SourcesTable = (): ReactElement => { + const { page, debouncedPage, size, handlePageSizeChange, setPage } = + usePagination(); + + const sources = useImageSources({ + query: { page: debouncedPage, size }, + }); + const importConfig = useGetConfiguration({ + path: { name: ConfigNames.BOOT_IMAGES_AUTO_IMPORT }, + }); + + const { data: selectionStatuses, error: selectionStatusesError } = + useSelectionStatuses(); + const { data: customImageStatuses, error: customImageStatusesError } = + useCustomImageStatuses(); + + const loading = sources.isPending || importConfig.isPending; + + const canChangeSource = + !!selectionStatuses && + !!customImageStatuses && + [...selectionStatuses.items, ...customImageStatuses.items].every( + (s) => s.status !== "Downloading" && s.update_status !== "Downloading" + ); + + const errors = + sources.error || + selectionStatusesError || + customImageStatusesError || + importConfig.error; + + const columns = useSourcesTableColumns({ canChangeSource }); + + const data = useMemo((): ImageSource[] => { + if (!sources.data) { + return []; + } + return sources.data?.items.map((item) => ({ + ...item, + type: getSourceType(item.url), + })); + }, [sources.data]); + + return ( + <> + {!canChangeSource && ( + + Image import is in progress, cannot change source settings. + + )} + {errors && ( + + {errors.details?.length ? errors.details[0].message : errors.message} + + )} + + + ); +}; + +export default SourcesTable; diff --git a/src/app/settings/views/Images/Sources/components/SourcesTable/_index.scss b/src/app/settings/views/Images/Sources/components/SourcesTable/_index.scss new file mode 100644 index 0000000000..6c8649237a --- /dev/null +++ b/src/app/settings/views/Images/Sources/components/SourcesTable/_index.scss @@ -0,0 +1,21 @@ +.p-generic-table.sources-table { + tr { + .name { + width: 20%; + } + + .priority { + width: 15%; + text-align: right; + } + + .signed { + width: 20%; + text-align: center; + } + + .actions { + width: 20%; + } + } +} \ No newline at end of file diff --git a/src/app/settings/views/Images/Sources/components/SourcesTable/index.ts b/src/app/settings/views/Images/Sources/components/SourcesTable/index.ts new file mode 100644 index 0000000000..6b25085170 --- /dev/null +++ b/src/app/settings/views/Images/Sources/components/SourcesTable/index.ts @@ -0,0 +1 @@ +export { default } from "./SourcesTable"; diff --git a/src/app/settings/views/Images/Sources/components/SourcesTable/useSourcesTableColumns/useSourcesTableColumns.tsx b/src/app/settings/views/Images/Sources/components/SourcesTable/useSourcesTableColumns/useSourcesTableColumns.tsx new file mode 100644 index 0000000000..ed736549d0 --- /dev/null +++ b/src/app/settings/views/Images/Sources/components/SourcesTable/useSourcesTableColumns/useSourcesTableColumns.tsx @@ -0,0 +1,192 @@ +import { useMemo } from "react"; + +import { ContextualMenu, Icon, Tooltip } from "@canonical/react-components"; +import type { Column, ColumnDef, Header, Row } from "@tanstack/react-table"; +import pluralize from "pluralize"; + +import { useSidePanel } from "@/app/base/side-panel-context"; +import { MAAS_IO_URLS } from "@/app/images/constants"; +import { BootResourceSourceType } from "@/app/images/types"; +import type { ImageSource } from "@/app/settings/views/Images/Sources/Sources"; +import DeleteSource from "@/app/settings/views/Images/Sources/components/DeleteSource"; +import EditSource from "@/app/settings/views/Images/Sources/components/EditSource"; + +type SourcesColumnDef = ColumnDef>; + +export const filterCells = ( + row: Row, + column: Column +): boolean => { + if (row.getIsGrouped()) { + return ["type"].includes(column.id); + } else { + return !["type"].includes(column.id); + } +}; + +export const filterHeaders = (header: Header): boolean => + header.column.id !== "type"; + +const useSourcesTableColumns = ({ + canChangeSource, +}: { + canChangeSource: boolean; +}): SourcesColumnDef[] => { + const { openSidePanel } = useSidePanel(); + + return useMemo( + () => + [ + { + id: "type", + accessorKey: "type", + cell: ({ row }: { row: Row }) => { + return ( +
+
+ + {row.original.type === BootResourceSourceType.MAAS_IO + ? "MAAS Images" + : "Custom Images"} + +
+ + {pluralize("source", row.getLeafRows().length ?? 0, true)} + +
+ ); + }, + }, + // TODO: add the disabled source styling and tooltip + { + id: "name", + accessorKey: "name", + enableSorting: true, + header: "Name", + cell: ({ + row: { + original: { type, url }, + }, + }: { + row: Row; + }) => { + if (type === BootResourceSourceType.MAAS_IO) { + return new RegExp(MAAS_IO_URLS.stable).test(url) + ? "MAAS Stable" + : "MAAS Candidate"; + } + // TODO: implement the proper name return when available + return "—"; + }, + }, + { + id: "url", + accessorKey: "url", + enableSorting: true, + header: "Source URL", + }, + { + id: "priority", + accessorKey: "priority", + enableSorting: true, + header: () => { + return ( + <> + Priority + + + + + ); + }, + }, + { + id: "signed", + accessorKey: "signed", + enableSorting: true, + header: "Signed with GPG key", + cell: ({ + row: { + original: { keyring_filename, keyring_data }, + }, + }) => + keyring_filename?.length || keyring_data?.length ? ( + + ) : ( + + ), + }, + { + id: "actions", + accessorKey: "id", + enableSorting: false, + header: "Actions", + cell: ({ row: { original } }) => { + return ( + { + openSidePanel({ + component: EditSource, + title: `Edit ${original.type === BootResourceSourceType.MAAS_IO ? "default" : "custom"} source`, + props: { + id: original.id, + isDefault: + original.type === BootResourceSourceType.MAAS_IO, + }, + }); + }, + }, + original.type === BootResourceSourceType.CUSTOM && { + children: "Delete source...", + onClick: () => { + openSidePanel({ + component: DeleteSource, + title: "Delete custom source", + props: { id: original.id }, + }); + }, + }, + // TODO: insert enable/disable items for type === MAAS_IO when backend is ready + // original.enabled + // ? { + // children: "Disable source...", + // onClick: () => { + // openSidePanel({ + // component: DisableSource, + // title: "Disable default source", + // props: { id: original.id }, + // }); + // }, + // } + // : { + // children: "Enable source...", + // onClick: () => { + // openSidePanel({ + // component: EnableSource, + // title: "Enable default source", + // props: { id: original.id }, + // }); + // }, + // }, + ]} + toggleAppearance="base" + toggleClassName="row-menu-toggle u-no-margin--bottom" + toggleDisabled={!canChangeSource} + /> + ); + }, + }, + ] as SourcesColumnDef[], + [canChangeSource, openSidePanel] + ); +}; + +export default useSourcesTableColumns; diff --git a/src/app/settings/views/Images/Sources/constants.ts b/src/app/settings/views/Images/Sources/constants.ts new file mode 100644 index 0000000000..53452d7fbb --- /dev/null +++ b/src/app/settings/views/Images/Sources/constants.ts @@ -0,0 +1,8 @@ +export enum Labels { + Custom = "Custom", + Name = "Name", + Url = "URL", + KeyringFilename = "Keyring filename", + KeyringData = "Keyring data", + Priority = "Priority", +} diff --git a/src/app/settings/views/Images/Sources/index.ts b/src/app/settings/views/Images/Sources/index.ts new file mode 100644 index 0000000000..28e471c594 --- /dev/null +++ b/src/app/settings/views/Images/Sources/index.ts @@ -0,0 +1 @@ +export { default } from "./Sources"; diff --git a/src/app/settings/views/Images/Synchronization/Synchronization.test.tsx b/src/app/settings/views/Images/Synchronization/Synchronization.test.tsx new file mode 100644 index 0000000000..1f90dbd92c --- /dev/null +++ b/src/app/settings/views/Images/Synchronization/Synchronization.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; + +import Synchronization from "@/app/settings/views/Images/Synchronization/Synchronization"; +import { ConfigNames } from "@/app/store/config/types"; +import { configurationsResolvers } from "@/testing/resolvers/configurations"; +import { + renderWithProviders, + screen, + setupMockServer, + userEvent, + waitFor, + waitForLoading, +} from "@/testing/utils"; + +const mockServer = setupMockServer( + configurationsResolvers.getConfiguration.handler({ + name: ConfigNames.BOOT_IMAGES_AUTO_IMPORT, + value: true, + }), + configurationsResolvers.setConfiguration.handler() +); + +describe("Synchronization", () => { + it("calls setConfiguration when saving the auto sync switch", async () => { + renderWithProviders(); + await waitForLoading(); + await userEvent.click( + screen.getByRole("checkbox", { name: /Automatically sync images/i }) + ); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); + }); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(configurationsResolvers.setConfiguration.resolved).toBe(true); + }); + + it("displays error messages when import fails", async () => { + mockServer.use( + configurationsResolvers.getConfiguration.error({ + code: 400, + message: "Uh oh!", + }) + ); + renderWithProviders(); + await waitForLoading(); + await waitFor(() => { + expect(screen.getByText(/Uh oh!/i)).toBeInTheDocument(); + }); + }); + + it("displays error messages when save fails", async () => { + mockServer.use( + configurationsResolvers.setConfiguration.error({ + code: 403, + message: "Uh oh!", + }) + ); + renderWithProviders(); + await waitForLoading(); + await userEvent.click( + screen.getByRole("checkbox", { name: /Automatically sync images/i }) + ); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); + }); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { + expect(screen.getByText(/Uh oh!/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/app/settings/views/Images/Synchronization/Synchronization.tsx b/src/app/settings/views/Images/Synchronization/Synchronization.tsx new file mode 100644 index 0000000000..14eccfe933 --- /dev/null +++ b/src/app/settings/views/Images/Synchronization/Synchronization.tsx @@ -0,0 +1,118 @@ +import type { ReactElement } from "react"; + +import { ContentSection } from "@canonical/maas-react-components"; +import { + Col, + Notification as NotificationBanner, + Row, + Spinner, +} from "@canonical/react-components"; +import * as Yup from "yup"; + +import { + useGetConfiguration, + useSetConfiguration, +} from "@/app/api/query/configurations"; +import FormikField from "@/app/base/components/FormikField"; +import FormikForm from "@/app/base/components/FormikForm"; +import PageContent from "@/app/base/components/PageContent"; +import { ConfigNames } from "@/app/store/config/types"; + +const SynchronizationSchema = Yup.object() + .shape({ + autoSync: Yup.boolean().required(), + }) + .defined(); + +type SynchronizationValues = { + autoSync: boolean; + syncInterval?: number; +}; + +const Synchronization = (): ReactElement => { + const importConfig = useGetConfiguration({ + path: { name: ConfigNames.BOOT_IMAGES_AUTO_IMPORT }, + }); + const configETag = importConfig.data?.headers?.get("ETag"); + const autoImport = (importConfig.data?.value as boolean) ?? false; + const updateConfig = useSetConfiguration(); + + const initialValues: SynchronizationValues = { + autoSync: autoImport, + syncInterval: 60, + }; + + const onSubmit = (values: SynchronizationValues) => { + updateConfig.mutate({ + headers: { + ETag: configETag, + }, + body: { + value: values.autoSync, + }, + path: { name: ConfigNames.BOOT_IMAGES_AUTO_IMPORT }, + }); + }; + + return ( + + + + Synchronization + + + {importConfig.isPending && } + + + {importConfig.isError && ( + + {importConfig.error.message} + + )} + {importConfig.isSuccess && importConfig.data && ( + + {() => { + return ( + <> + + {/*TODO: uncomment when synchronization interval is available as a global configuration*/} + {/*{values.autoSync ? (*/} + {/* */} + {/*) : null}*/} + + ); + }} + + )} + + + + + + ); +}; + +export default Synchronization; diff --git a/src/app/settings/views/Images/Synchronization/index.ts b/src/app/settings/views/Images/Synchronization/index.ts new file mode 100644 index 0000000000..12b1df9a4a --- /dev/null +++ b/src/app/settings/views/Images/Synchronization/index.ts @@ -0,0 +1 @@ +export { default } from "./Synchronization"; diff --git a/src/router.tsx b/src/router.tsx index 068681b499..9a78f6b3c5 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -22,7 +22,6 @@ import Deploy from "@/app/settings/views/Configuration/Deploy"; import General from "@/app/settings/views/Configuration/General"; import KernelParameters from "@/app/settings/views/Configuration/KernelParameters"; import DhcpList from "@/app/settings/views/Dhcp/DhcpList"; -import ChangeSource from "@/app/settings/views/Images/ChangeSource"; import ThirdPartyDrivers from "@/app/settings/views/Images/ThirdPartyDrivers"; import VMWare from "@/app/settings/views/Images/VMWare"; import Windows from "@/app/settings/views/Images/Windows"; @@ -80,6 +79,7 @@ const FabricDetails = lazy( const FabricsList = lazy( () => import("@/app/networks/views/Fabrics/views/FabricsList") ); +const Sources = lazy(() => import("@/app/settings/views/Images/Sources")); const SpaceDetails = lazy( () => import("@/app/networks/views/Spaces/views/SpaceDetails") ); @@ -92,6 +92,9 @@ const SubnetDetails = lazy( const SubnetsList = lazy( () => import("@/app/networks/views/Subnets/views/SubnetsList") ); +const Synchronization = lazy( + () => import("@/app/settings/views/Images/Synchronization") +); const VLANDetails = lazy( () => import("@/app/networks/views/VLANs/views/VLANDetails") ); @@ -801,12 +804,23 @@ export const router = createBrowserRouter( }, { path: getRelativeRoute( - urls.settings.images.source, + urls.settings.images.sources, + urls.settings.index + ), + element: ( + + + + ), + }, + { + path: getRelativeRoute( + urls.settings.images.sync, urls.settings.index ), element: ( - + ), }, diff --git a/src/testing/factories/index.ts b/src/testing/factories/index.ts index bb0b68f17d..c4f486af7f 100644 --- a/src/testing/factories/index.ts +++ b/src/testing/factories/index.ts @@ -20,6 +20,7 @@ export { fetchedAt, generalState, generatedCertificateState, + installTypeState, hweKernelsState, knownArchitecturesState, knownBootArchitecturesState, From 09d7bf7197803c9e3c5248a7d07208d32e9a974f Mon Sep 17 00:00:00 2001 From: Ahmet Can Buyukyilmaz Date: Mon, 4 May 2026 11:45:58 +0300 Subject: [PATCH 02/10] feat(settings): Sync interval configuration MAASENG-6315 (#6008) Co-authored-by: Katarzyna Rumanowska Co-authored-by: Neh Joshi Co-authored-by: Nick De Villiers Co-authored-by: maas-lander <115650013+maas-lander@users.noreply.github.com> Co-authored-by: maas-lander --- .env | 2 +- .github/workflows/accessibility.yml | 4 + .github/workflows/cypress.yml | 4 + .github/workflows/sitespeed.yml | 4 + cypress/e2e/with-users/base/footer.spec.ts | 29 - .../e2e/with-users/base/navigation.spec.ts | 146 - .../e2e/with-users/base/side-panel.spec.ts | 39 - .../with-users/controllers/details.spec.ts | 89 - .../e2e/with-users/controllers/list.spec.ts | 12 - .../with-users/dashboard/dashboard.spec.ts | 14 - cypress/e2e/with-users/devices/list.spec.ts | 45 - cypress/e2e/with-users/domains/list.spec.ts | 12 - .../features/base/navigation_admin.feature | 60 +- .../base/navigation_non_admin.feature | 4 +- .../features/base/side-panel.feature | 12 +- .../features/devices/devicesAdd.feature | 11 + .../features/machines/machines_add.feature | 12 +- .../settingsConfigurationDeploy.feature | 7 + .../settingsConfigurationGeneral.feature | 12 + .../settingsConfigurationGeneralTheme.feature | 34 + ...tingsConfigurationKernelParameters.feature | 15 + cypress/e2e/with-users/images/list.spec.ts | 12 - cypress/e2e/with-users/intro/intro.spec.ts | 38 - cypress/e2e/with-users/kvm/list.spec.ts | 12 - cypress/e2e/with-users/login/login.spec.ts | 85 - .../e2e/with-users/machines/actions.spec.ts | 181 -- cypress/e2e/with-users/machines/add.spec.ts | 30 - .../e2e/with-users/machines/details.spec.ts | 102 - cypress/e2e/with-users/machines/list.spec.ts | 183 -- cypress/e2e/with-users/networks/add.spec.ts | 112 - .../with-users/networks/staticroutes.spec.ts | 93 - .../e2e/with-users/networks/subnets.spec.ts | 67 - cypress/e2e/with-users/pools/list.spec.ts | 14 - .../e2e/with-users/preferences/base.spec.ts | 12 - cypress/e2e/with-users/settings/base.spec.ts | 12 - .../settings/configuration/deploy.spec.ts | 19 - .../settings/configuration/general.spec.ts | 85 - .../configuration/kernel-parameters.spec.ts | 30 - .../e2e/with-users/settings/headings.spec.ts | 117 - .../settings/security/session-timeout.spec.ts | 23 - .../e2e/with-users/settings/users/add.spec.ts | 25 - .../with-users/settings/users/list.spec.ts | 14 - cypress/e2e/with-users/zones/list.spec.ts | 14 - .../base/navigation_admin.steps.ts | 7 - .../step_definitions/base/side-panel.steps.ts | 12 +- .../step_definitions/common/actions.steps.ts | 8 + .../step_definitions/common/layout.steps.ts | 4 + .../devices/devicesAdd.steps.ts | 14 + .../step_definitions/login/login.steps.ts | 4 - .../machines/machines_add.steps.ts | 10 +- .../machines/machines_list.steps.ts | 4 - .../settingsConfigurationDeploy.steps.ts | 15 + .../settingsConfigurationGeneral.steps.ts | 40 + ...ingsConfigurationKernelParameters.steps.ts | 35 + ...> settingsSecuritySessionTimeout.steps.ts} | 0 docs/component-standards/testing.md | 1 + package.json | 6 +- scripts/check-cucumber-steps.ts | 411 +++ src/app/api/query/pools.ts | 40 +- src/app/api/query/racks.ts | 28 +- .../apiclient/@tanstack/react-query.gen.ts | 355 ++- src/app/apiclient/client.gen.ts | 6 +- src/app/apiclient/sdk.gen.ts | 305 ++- src/app/apiclient/types.gen.ts | 2380 +++++++++-------- .../CertificateDownload.tsx | 4 + .../base/components/CopyButton/CopyButton.tsx | 1 + .../NodeTestDetails/NodeTestDetails.test.tsx | 26 + .../node/NodeTestDetails/NodeTestDetails.tsx | 57 +- .../useNodeTestDetailsTableColumns.tsx | 85 + src/app/kvm/components/AddLxd/AddLxd.tsx | 3 + .../AuthenticationForm/AuthenticationForm.tsx | 6 +- .../LXDClusterHostsTable.tsx | 4 +- .../AddBondForm/AddBondForm.tsx | 14 +- .../EditBondForm/EditBondForm.test.tsx | 63 +- .../EditBondForm/EditBondForm.tsx | 30 +- .../InterfaceFormTable.test.tsx | 24 +- .../InterfaceFormTable/InterfaceFormTable.tsx | 259 +- .../InterfaceFormTable/_index.scss | 13 + .../useInterfaceFormTableColumns.tsx | 162 ++ .../ConfigureDHCP/ConfigureDHCP.test.tsx | 4 +- .../DHCPReservedRanges.test.tsx | 382 ++- .../DHCPReservedRanges/DHCPReservedRanges.tsx | 209 +- .../useDHCPReservedRangesColumns/index.ts | 2 + .../useDHCPReservedRangesColumns.tsx | 123 + .../components/DeletePool/DeletePool.tsx | 4 +- .../usePoolsTableColumns.tsx | 8 +- .../components/RacksTable/RacksTable.tsx | 16 +- .../useRacksTableColumns.tsx | 12 +- .../Synchronization/Synchronization.test.tsx | 45 + .../Synchronization/Synchronization.tsx | 45 +- src/app/store/config/types/enum.ts | 1 + src/testing/factories/racks.ts | 29 +- src/testing/factories/resourcepool.ts | 4 +- src/testing/resolvers/pools.ts | 4 +- src/testing/resolvers/racks.ts | 11 +- 95 files changed, 3630 insertions(+), 3552 deletions(-) delete mode 100644 cypress/e2e/with-users/base/footer.spec.ts delete mode 100644 cypress/e2e/with-users/base/navigation.spec.ts delete mode 100644 cypress/e2e/with-users/base/side-panel.spec.ts delete mode 100644 cypress/e2e/with-users/controllers/details.spec.ts delete mode 100644 cypress/e2e/with-users/controllers/list.spec.ts delete mode 100644 cypress/e2e/with-users/dashboard/dashboard.spec.ts delete mode 100644 cypress/e2e/with-users/devices/list.spec.ts delete mode 100644 cypress/e2e/with-users/domains/list.spec.ts create mode 100644 cypress/e2e/with-users/features/devices/devicesAdd.feature create mode 100644 cypress/e2e/with-users/features/settings/configuration/settingsConfigurationDeploy.feature create mode 100644 cypress/e2e/with-users/features/settings/configuration/settingsConfigurationGeneral.feature create mode 100644 cypress/e2e/with-users/features/settings/configuration/settingsConfigurationGeneralTheme.feature create mode 100644 cypress/e2e/with-users/features/settings/configuration/settingsConfigurationKernelParameters.feature delete mode 100644 cypress/e2e/with-users/images/list.spec.ts delete mode 100644 cypress/e2e/with-users/intro/intro.spec.ts delete mode 100644 cypress/e2e/with-users/kvm/list.spec.ts delete mode 100644 cypress/e2e/with-users/login/login.spec.ts delete mode 100644 cypress/e2e/with-users/machines/actions.spec.ts delete mode 100644 cypress/e2e/with-users/machines/add.spec.ts delete mode 100644 cypress/e2e/with-users/machines/details.spec.ts delete mode 100644 cypress/e2e/with-users/machines/list.spec.ts delete mode 100644 cypress/e2e/with-users/networks/add.spec.ts delete mode 100644 cypress/e2e/with-users/networks/staticroutes.spec.ts delete mode 100644 cypress/e2e/with-users/networks/subnets.spec.ts delete mode 100644 cypress/e2e/with-users/pools/list.spec.ts delete mode 100644 cypress/e2e/with-users/preferences/base.spec.ts delete mode 100644 cypress/e2e/with-users/settings/base.spec.ts delete mode 100644 cypress/e2e/with-users/settings/configuration/deploy.spec.ts delete mode 100644 cypress/e2e/with-users/settings/configuration/general.spec.ts delete mode 100644 cypress/e2e/with-users/settings/configuration/kernel-parameters.spec.ts delete mode 100644 cypress/e2e/with-users/settings/headings.spec.ts delete mode 100644 cypress/e2e/with-users/settings/security/session-timeout.spec.ts delete mode 100644 cypress/e2e/with-users/settings/users/add.spec.ts delete mode 100644 cypress/e2e/with-users/settings/users/list.spec.ts delete mode 100644 cypress/e2e/with-users/zones/list.spec.ts create mode 100644 cypress/support/step_definitions/devices/devicesAdd.steps.ts create mode 100644 cypress/support/step_definitions/settings/configuration/settingsConfigurationDeploy.steps.ts create mode 100644 cypress/support/step_definitions/settings/configuration/settingsConfigurationGeneral.steps.ts create mode 100644 cypress/support/step_definitions/settings/configuration/settingsConfigurationKernelParameters.steps.ts rename cypress/support/step_definitions/settings/security/{settingsSecuritySessionTimeout.ts => settingsSecuritySessionTimeout.steps.ts} (100%) create mode 100644 scripts/check-cucumber-steps.ts create mode 100644 src/app/base/components/node/NodeTestDetails/useNodeTestDetailsTableColumns/useNodeTestDetailsTableColumns.tsx create mode 100644 src/app/machines/views/MachineDetails/MachineNetwork/InterfaceFormTable/_index.scss create mode 100644 src/app/machines/views/MachineDetails/MachineNetwork/InterfaceFormTable/useInterfaceFormTableColumns/useInterfaceFormTableColumns.tsx create mode 100644 src/app/networks/views/VLANs/views/VLANDetails/components/ConfigureDHCP/DHCPReservedRanges/useDHCPReservedRangesColumns/index.ts create mode 100644 src/app/networks/views/VLANs/views/VLANDetails/components/ConfigureDHCP/DHCPReservedRanges/useDHCPReservedRangesColumns/useDHCPReservedRangesColumns.tsx diff --git a/.env b/.env index 4111116634..abb9b59f2d 100644 --- a/.env +++ b/.env @@ -6,7 +6,7 @@ VITE_BASENAME="/r" VITE_APP_BASENAME=${BASENAME} VITE_APP_VITE_BASENAME=${VITE_BASENAME} VITE_APP_WEBSOCKET_DEBUG=false -VITE_APP_USABILLA_ID=fd6cf482fbbb +VITE_APP_USABILLA_ID=458e22506f77 VITE_APP_MAAS_URL=${MAAS_URL} # Feature flags diff --git a/.github/workflows/accessibility.yml b/.github/workflows/accessibility.yml index 1e0d39ecb1..76de084d4f 100644 --- a/.github/workflows/accessibility.yml +++ b/.github/workflows/accessibility.yml @@ -14,6 +14,10 @@ jobs: - uses: actions/checkout@main - name: Get branch name uses: nelonoel/branch-name@v1.0.1 + - name: Install snapd and core snaps + run: | + sudo snap install snapd + sudo snap install core26 --candidate - name: Setup MAAS uses: canonical/setup-maas@main with: diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index 45807a3e8b..73f5a07f8e 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -23,6 +23,10 @@ jobs: - uses: actions/checkout@main - name: Get branch name uses: nelonoel/branch-name@v1.0.1 + - name: Install snapd and core snaps + run: | + sudo snap install snapd + sudo snap install core26 --candidate - name: Setup MAAS uses: canonical/setup-maas@main with: diff --git a/.github/workflows/sitespeed.yml b/.github/workflows/sitespeed.yml index 11fe0b055d..d23fbca11c 100644 --- a/.github/workflows/sitespeed.yml +++ b/.github/workflows/sitespeed.yml @@ -15,6 +15,10 @@ jobs: MAAS_URL: http://localhost:5240 steps: - uses: actions/checkout@main + - name: Install snapd and core snaps + run: | + sudo snap install snapd + sudo snap install core26 --candidate - name: Setup MAAS uses: canonical/setup-maas@main with: diff --git a/cypress/e2e/with-users/base/footer.spec.ts b/cypress/e2e/with-users/base/footer.spec.ts deleted file mode 100644 index 62e800e0cf..0000000000 --- a/cypress/e2e/with-users/base/footer.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { generateMAASURL } from "../../utils"; - -declare global { - interface Window { - usabilla_live: (type: string, trigger: string) => void; - lightningjs: () => void; - } -} - -context("Footer", () => { - beforeEach(() => { - cy.login(); - cy.visit(generateMAASURL("/")); - }); - - it("navigates to the local documentation", () => { - cy.findByRole("link", { name: /local documentation/i }) - .should("have.attr", "href") - .and("include", "/MAAS/docs/"); - }); - - it("has a link to legal", () => { - cy.findByRole("link", { - name: /legal information/i, - }) - .should("have.attr", "href") - .and("include", "https://www.ubuntu.com/legal"); - }); -}); diff --git a/cypress/e2e/with-users/base/navigation.spec.ts b/cypress/e2e/with-users/base/navigation.spec.ts deleted file mode 100644 index 417138db2b..0000000000 --- a/cypress/e2e/with-users/base/navigation.spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { generateMAASURL } from "../../utils"; - -const expectCollapsedNavigation = () => { - cy.getMainNavigation().invoke("width").should("equal", 64); - cy.getMainNavigation().within(() => - cy.findByRole("link", { name: /machines/i }).should("not.exist") - ); -}; -const expectExpandedNavigation = () => { - cy.getMainNavigation().invoke("width").should("equal", 240); - cy.getMainNavigation().within(() => - cy.findByRole("link", { name: /machines/i }).should("exist") - ); -}; - -context("Navigation - non-admin", () => { - beforeEach(() => { - cy.loginNonAdmin(); - cy.visit(generateMAASURL("/")); - }); - - it("navigates to machines when clicking on the logo", () => { - cy.getMainNavigation().within(() => - cy.findByRole("link", { name: "Homepage" }).click() - ); - cy.location("pathname").should("eq", generateMAASURL("/machines")); - cy.get(".p-side-navigation__item.is-selected a").contains("Machines"); - }); -}); - -context("Navigation - admin - collapse", () => { - beforeEach(() => { - cy.login(); - // Need the window to be wide enough so that menu items aren't hidden under - // the hardware menu. - cy.viewport("macbook-13"); - cy.visit(generateMAASURL("/")); - }); - - it("expands and collapses the side navigation using a keyboard shortcut", () => { - cy.viewport("ipad-mini"); - cy.waitForPageToLoad(); - expectCollapsedNavigation(); - cy.get("body").type("["); - expectExpandedNavigation(); - cy.get("body").type("["); - expectCollapsedNavigation(); - }); - - it("ignores the keyboard shortcut when modifier key is pressed", () => { - cy.viewport("ipad-mini"); - cy.waitForPageToLoad(); - expectCollapsedNavigation(); - // ctrl + [ is often used as a shortcut for going back in browsers - cy.get("body").type("{ctrl}["); - expectCollapsedNavigation(); - }); - - it("expands and collapses the side navigation on click of a button", () => { - cy.viewport("ipad-mini"); - cy.waitForPageToLoad(); - expectCollapsedNavigation(); - cy.findByRole("button", { name: /expand main navigation/ }).click(); - expectExpandedNavigation(); - cy.findByRole("button", { name: /collapse main navigation/ }).click(); - expectCollapsedNavigation(); - }); - - it("opens and closes the menu on mobile", () => { - cy.viewport("iphone-8"); - cy.getMainNavigation().should("not.be.visible"); - cy.findByRole("banner", { name: "navigation" }).within(() => - cy.findByRole("button", { name: "Menu" }).click() - ); - cy.getMainNavigation() - .should("be.visible") - .within(() => cy.findByRole("button", { name: /close menu/i }).click()); - cy.getMainNavigation().should("not.be.visible"); - }); - - it("automatically closes the menu on mobile when a link is clicked", () => { - cy.viewport("iphone-8"); - cy.getMainNavigation().should("not.be.visible"); - cy.findByRole("banner", { name: "navigation" }).within(() => - cy.findByRole("button", { name: "Menu" }).click() - ); - cy.getMainNavigation() - .should("be.visible") - .within(() => cy.findByRole("link", { name: /devices/i }).click()); - cy.getMainNavigation().should("not.be.visible"); - }); -}); - -context("Navigation - admin", () => { - beforeEach(() => { - cy.login(); - // Need the window to be wide enough so that menu items aren't hidden under - // the hardware menu. - cy.viewport("macbook-13"); - cy.visit(generateMAASURL("/")); - // set side navigation to expanded - cy.expandMainNavigation(); - }); - - const expected = [ - { destinationUrl: "/machines", linkLabel: "Machines" }, - { destinationUrl: "/devices", linkLabel: "Devices" }, - { destinationUrl: "/controllers", linkLabel: "Controllers" }, - { destinationUrl: "/kvm/lxd", linkLabel: "LXD" }, - { destinationUrl: "/images", linkLabel: "Images" }, - { destinationUrl: "/domains", linkLabel: "DNS" }, - { destinationUrl: "/networks/subnets", linkLabel: "Networks" }, - { - destinationUrl: "/settings/configuration/general", - linkLabel: "Settings", - }, - { - destinationUrl: "/account/prefs/details", - linkLabel: Cypress.env("username"), - }, - { destinationUrl: "/zones", linkLabel: "AZs" }, - ]; - - it("navigates to machines when clicking on the logo", () => { - cy.waitForPageToLoad(); - cy.getMainNavigation().within(() => - cy.findByRole("link", { name: "Homepage" }).click() - ); - cy.location("pathname").should("eq", generateMAASURL("/machines")); - }); - - expected.forEach(({ destinationUrl, linkLabel }) => { - it(`navigates to ${destinationUrl} and highlights ${linkLabel} link`, () => { - cy.waitForPageToLoad(); - cy.getMainNavigation().within(() => - cy.findByRole("link", { name: linkLabel }).click() - ); - cy.location("pathname").should("eq", generateMAASURL(destinationUrl)); - cy.get(".p-side-navigation__item.is-selected a").contains(linkLabel); - cy.findAllByRole("link", { - current: "page", - name: linkLabel, - }).should("exist"); - }); - }); -}); diff --git a/cypress/e2e/with-users/base/side-panel.spec.ts b/cypress/e2e/with-users/base/side-panel.spec.ts deleted file mode 100644 index 6c6b1b6824..0000000000 --- a/cypress/e2e/with-users/base/side-panel.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { generateMAASURL } from "../../utils"; - -context("Side panel", () => { - beforeEach(() => { - cy.login(); - cy.expandMainNavigation(); - cy.visit(generateMAASURL("/devices")); - // open side panel - cy.findByRole("button", { name: /Add device/i }).click(); - }); - - it("closes the side panel on ESC key press", () => { - cy.get("#aside-panel").should("be.visible"); - cy.findByRole("complementary", { name: "Add device" }).should("be.visible"); - cy.get("body").type("{esc}"); - cy.findByRole("complementary", { name: "Add device" }).should("not.exist"); - cy.get("#aside-panel").should("not.be.visible"); - }); - - it("closes the side panel when navigating to a different page", () => { - cy.get("#aside-panel").should("be.visible"); - cy.findByRole("heading", { name: "Add device" }).should("be.visible"); - cy.visit(generateMAASURL("/machines")); - cy.get("#aside-panel").should("not.be.visible"); - cy.waitForPageToLoad(); - cy.visit(generateMAASURL("/controllers")); - cy.findByRole("button", { name: "Add rack controller" }).click(); - cy.findByRole("complementary", { name: "Add controller" }).should( - "be.visible" - ); - cy.getMainNavigation().within(() => - cy.findByRole("link", { name: "LXD" }).click() - ); - cy.waitForPageToLoad(); - cy.findByRole("complementary", { name: "Add controller" }).should( - "not.exist" - ); - }); -}); diff --git a/cypress/e2e/with-users/controllers/details.spec.ts b/cypress/e2e/with-users/controllers/details.spec.ts deleted file mode 100644 index 0919defa01..0000000000 --- a/cypress/e2e/with-users/controllers/details.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { generateId, generateMAASURL } from "../../utils"; - -context("Controller details", () => { - beforeEach(() => { - cy.login(); - cy.visit(generateMAASURL("/controllers")); - - // navigate to controller details page of the first controller - cy.findByRole("grid", { name: /controllers list/i }).within(() => - cy.findAllByRole("link").first().click() - ); - cy.waitForPageToLoad(); - cy.findByText(/Summary/).should("exist"); - }); - - it("can add a tag to the controller", () => { - const tagName = `tag-${generateId()}`; - - // can add a tag to the controller - cy.findByRole("link", { - name: /Configuration/, - }).click(); - cy.findAllByRole("button", { - name: /Edit/, - }) - .first() - .click(); - - cy.findByRole("form", { name: /Controller configuration/ }).should("exist"); - cy.get("input[placeholder='Create or remove tags']").type( - `${tagName}{enter}` - ); - cy.findByRole("button", { name: /Create and add to tag changes/ }).click(); - cy.findByRole("button", { name: /Save changes/ }).click(); - - cy.findByRole("link", { name: /Summary/ }).click(); - cy.findByTestId("machine-tags").contains(tagName); - - // displays the controller listing page filtered by tag on click of the tag name - cy.findByRole("link", { - name: /Configuration/, - }).click(); - cy.findByRole("link", { - name: tagName, - }).click(); - - // displays the correct tag in the searchbox - cy.findByRole("searchbox", { name: /Search/ }).should( - "have.value", - `tags:(=${tagName})` - ); - - // displays the correct number of controllers - cy.findByRole("grid", { name: "controllers list" }).within(() => { - cy.get("tbody tr").should("have.length", 1); - }); - }); - - it("lists valid actions on the controller details page", () => { - cy.findByRole("button", { name: "Take action" }).click(); - - [/Set zone/i, /Delete/i].forEach((name) => - cy - .findByRole("button", { - name, - }) - .should("exist") - ); - [/Deploy/i].forEach((name) => - cy - .findByRole("button", { - name, - }) - .should("not.exist") - ); - }); - - it("displays controller commissioning details", () => { - cy.findByRole("link", { name: "Commissioning" }).click(); - cy.findByRole("grid").within(() => { - cy.get("tbody tr") - .first() - .within(() => { - cy.findByTestId("details-link").click(); - }); - }); - cy.findByRole("heading", { name: /details/i }).should("exist"); - }); -}); diff --git a/cypress/e2e/with-users/controllers/list.spec.ts b/cypress/e2e/with-users/controllers/list.spec.ts deleted file mode 100644 index 24a7c0af32..0000000000 --- a/cypress/e2e/with-users/controllers/list.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { generateMAASURL } from "../../utils"; - -context("Controller listing", () => { - beforeEach(() => { - cy.login(); - cy.visit(generateMAASURL("/controllers")); - }); - - it("renders the correct heading", () => { - cy.findByRole("heading", { level: 1 }).contains("Controllers"); - }); -}); diff --git a/cypress/e2e/with-users/dashboard/dashboard.spec.ts b/cypress/e2e/with-users/dashboard/dashboard.spec.ts deleted file mode 100644 index 549cdd00d7..0000000000 --- a/cypress/e2e/with-users/dashboard/dashboard.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { generateMAASURL } from "../../utils"; - -context("Network Discovery", () => { - beforeEach(() => { - cy.login(); - cy.visit(generateMAASURL("/network-discovery")); - }); - - it("renders the correct heading", () => { - cy.get("[data-testid='main-toolbar-heading']").contains( - "Network discovery" - ); - }); -}); diff --git a/cypress/e2e/with-users/devices/list.spec.ts b/cypress/e2e/with-users/devices/list.spec.ts deleted file mode 100644 index 2103fa90ce..0000000000 --- a/cypress/e2e/with-users/devices/list.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { generateMAASURL, generateMac } from "../../utils"; - -context("Device listing", () => { - beforeEach(() => { - cy.login(); - cy.visit(generateMAASURL("/devices")); - }); - - it("renders the correct heading", () => { - cy.get("[data-testid='section-header-title']").contains("Devices"); - }); - - it("can add a tag to the device", () => { - // can add a device - cy.findByRole("button", { name: /Add device/ }).click(); - const mac = generateMac(); - cy.findByLabelText(/Device name/).type("cypress-device"); - cy.get("input[placeholder='00:00:00:00:00:00']").type(mac); - cy.findByRole("button", { name: /Save device/ }).click(); - - // can view device details on click of device details link - cy.findByRole("link", { name: /cypress-device/ }).click(); - - cy.findByRole("link", { - name: /Configuration/, - }).click(); - cy.findByRole("button", { - name: /Edit/, - }).click(); - cy.get("input[placeholder='Create or remove tags']").type("device-tag"); - - cy.findByRole("button", { - name: /Create tag "device-tag"/, - }).click(); - - cy.findByRole("button", { - name: /Create and add to tag changes/, - }).click(); - - cy.findByRole("button", { name: /Save changes/ }).click(); - - cy.findByRole("link", { name: /Summary/ }).click(); - cy.findByText(/device-tag/).should("exist"); - }); -}); diff --git a/cypress/e2e/with-users/domains/list.spec.ts b/cypress/e2e/with-users/domains/list.spec.ts deleted file mode 100644 index 59337ac98b..0000000000 --- a/cypress/e2e/with-users/domains/list.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { generateMAASURL } from "../../utils"; - -context("DNS", () => { - beforeEach(() => { - cy.login(); - cy.visit(generateMAASURL("/domains")); - }); - - it("renders the correct heading", () => { - cy.get("[data-testid='main-toolbar-heading']").contains("DNS"); - }); -}); diff --git a/cypress/e2e/with-users/features/base/navigation_admin.feature b/cypress/e2e/with-users/features/base/navigation_admin.feature index af5a503b27..3cfe42a688 100644 --- a/cypress/e2e/with-users/features/base/navigation_admin.feature +++ b/cypress/e2e/with-users/features/base/navigation_admin.feature @@ -1,36 +1,36 @@ Feature: Navigation - admin -Background: - Given the user is logged in - And the viewport is "macbook-13" - And the user navigates to the home page - And the user clicks the button matching "expand main navigation" - And the page is loaded + Background: + Given the user is logged in + And the viewport is "macbook-13" + And the user navigates to the home page + And the user clicks the button matching "expand main navigation" + And the page is loaded -Scenario: Clicking the logo navigates to Machines - When the user clicks the "Homepage" link in the main navigation - Then the pathname should equal "/machines" + Scenario: Clicking the logo navigates to Machines + When the user clicks the "Homepage" link in the main navigation + Then the pathname should equal "/machines" -Scenario: Navigating to user preferences via the username link - When the user clicks the username link in the main navigation - Then the pathname should equal "/account/prefs/details" - And the username navigation item should be selected - And the username link should have current page state + Scenario: Navigating to user preferences via the username link + When the user clicks the username link in the main navigation + Then the pathname should equal "/account/prefs/details" + And the username navigation item should be selected + And the username link should have current page state -Scenario Outline: Navigating via the main navigation highlights the active link - When the user clicks the "