From 16a8d2d529995a482feb5235c3c5e607776aecf5 Mon Sep 17 00:00:00 2001 From: Ethan Shaw Date: Fri, 27 Mar 2026 18:50:35 +0000 Subject: [PATCH 01/42] add navigation --- src/components/layout/PageMain.module.scss | 7 ++ src/components/layout/PageMain.tsx | 4 +- src/libs/routes/profiles.ts | 2 - src/libs/routes/repositories.ts | 20 +++- .../[single]/tabs/info/ProfileLink/helpers.ts | 2 +- .../profiles/repository-profiles/index.ts | 1 - .../dashboard/repositories/RepositoryPage.tsx | 11 +- .../repositories/apt-sources/index.ts | 1 - .../dashboard/repositories/gpg-keys/index.ts | 1 - .../LocalRepositoriesPage.tsx} | 0 .../repositories/local-repositories/index.ts | 1 + ...{DistributionsPage.tsx => MirrorsPage.tsx} | 0 .../dashboard/repositories/mirrors/index.ts | 2 +- .../PublicationTargetsPage.tsx} | 39 +++---- .../repositories/publication-targets/index.ts | 1 + .../publications/PublicationsPage.tsx | 85 ++++++++++++++ .../repositories/publications/index.ts | 1 + .../RepositoryProfilesPage.tsx | 0 .../repository-profiles}/index.ts | 0 src/routes/DashboardRoutes.tsx | 62 +++++++--- src/routes/elements.tsx | 27 +++-- .../dashboard/DashboardTemplate.module.scss | 11 -- src/templates/dashboard/DashboardTemplate.tsx | 18 +-- .../NavigationExpandable.tsx | 16 +++ .../NavigationRoute/NavigationRoute.tsx | 9 +- .../dashboard/Navigation/constants.ts | 23 +++- src/templates/dashboard/Navigation/types.d.ts | 1 + .../SecondaryNavigation.module.scss | 4 + .../SecondaryNavigation.tsx | 106 +++++++++++------- 29 files changed, 304 insertions(+), 151 deletions(-) delete mode 100644 src/pages/dashboard/profiles/repository-profiles/index.ts delete mode 100644 src/pages/dashboard/repositories/apt-sources/index.ts delete mode 100644 src/pages/dashboard/repositories/gpg-keys/index.ts rename src/pages/dashboard/repositories/{apt-sources/APTSourcesPage.tsx => local-repositories/LocalRepositoriesPage.tsx} (100%) create mode 100644 src/pages/dashboard/repositories/local-repositories/index.ts rename src/pages/dashboard/repositories/mirrors/{DistributionsPage.tsx => MirrorsPage.tsx} (100%) rename src/pages/dashboard/repositories/{gpg-keys/GPGKeysPage.tsx => publication-targets/PublicationTargetsPage.tsx} (68%) create mode 100644 src/pages/dashboard/repositories/publication-targets/index.ts create mode 100644 src/pages/dashboard/repositories/publications/PublicationsPage.tsx create mode 100644 src/pages/dashboard/repositories/publications/index.ts rename src/pages/dashboard/{profiles/repository-profiles/RepositoryProfilesPage => repositories/repository-profiles}/RepositoryProfilesPage.tsx (100%) rename src/pages/dashboard/{profiles/repository-profiles/RepositoryProfilesPage => repositories/repository-profiles}/index.ts (100%) diff --git a/src/components/layout/PageMain.module.scss b/src/components/layout/PageMain.module.scss index d6da64625..eb0da96ee 100644 --- a/src/components/layout/PageMain.module.scss +++ b/src/components/layout/PageMain.module.scss @@ -2,3 +2,10 @@ display: flex; flex-direction: column; } + +.pageContent { + display: flex; + flex-direction: column; + min-width: 0; + width: 100%; +} diff --git a/src/components/layout/PageMain.tsx b/src/components/layout/PageMain.tsx index 50f79dfcf..aff7220ba 100644 --- a/src/components/layout/PageMain.tsx +++ b/src/components/layout/PageMain.tsx @@ -8,7 +8,9 @@ interface PageMainProps { const PageMain: FC = ({ children }) => { return ( -
{children}
+
+
{children}
+
); }; diff --git a/src/libs/routes/profiles.ts b/src/libs/routes/profiles.ts index 97d821ffa..71cdd014b 100644 --- a/src/libs/routes/profiles.ts +++ b/src/libs/routes/profiles.ts @@ -5,7 +5,6 @@ export const PROFILES_PATHS = { package: "package", reboot: "reboot", removal: "removal", - repository: "repository", security: "security", upgrade: "upgrade", wsl: "wsl", @@ -20,7 +19,6 @@ export const PROFILES_ROUTES = { package: createRoute(buildProfilePath(PROFILES_PATHS.package)), reboot: createRoute(buildProfilePath(PROFILES_PATHS.reboot)), removal: createRoute(buildProfilePath(PROFILES_PATHS.removal)), - repository: createRoute(buildProfilePath(PROFILES_PATHS.repository)), security: createRoute(buildProfilePath(PROFILES_PATHS.security)), upgrade: createRoute(buildProfilePath(PROFILES_PATHS.upgrade)), wsl: createRoute(buildProfilePath(PROFILES_PATHS.wsl)), diff --git a/src/libs/routes/repositories.ts b/src/libs/routes/repositories.ts index becad66c0..609ece6ca 100644 --- a/src/libs/routes/repositories.ts +++ b/src/libs/routes/repositories.ts @@ -3,8 +3,10 @@ import { createRoute, createPathBuilder } from "./_helpers"; export const REPOSITORIES_PATHS = { root: "repositories", mirrors: "mirrors", - gpgKeys: "gpg-keys", - aptSources: "apt-sources", + localRepositories: "local-repositories", + publications: "publications", + publicationTargets: "publication-targets", + repositoryProfiles: "repository-profiles", } as const; const base = `/${REPOSITORIES_PATHS.root}`; @@ -14,6 +16,16 @@ const buildRepositoryPath = createPathBuilder(base); export const REPOSITORIES_ROUTES = { root: createRoute(base), mirrors: createRoute(buildRepositoryPath(REPOSITORIES_PATHS.mirrors)), - gpgKeys: createRoute(buildRepositoryPath(REPOSITORIES_PATHS.gpgKeys)), - aptSources: createRoute(buildRepositoryPath(REPOSITORIES_PATHS.aptSources)), + localRepositories: createRoute( + buildRepositoryPath(REPOSITORIES_PATHS.localRepositories), + ), + publications: createRoute( + buildRepositoryPath(REPOSITORIES_PATHS.publications), + ), + publicationTargets: createRoute( + buildRepositoryPath(REPOSITORIES_PATHS.publicationTargets), + ), + repositoryProfiles: createRoute( + buildRepositoryPath(REPOSITORIES_PATHS.repositoryProfiles), + ), } as const; diff --git a/src/pages/dashboard/instances/[single]/tabs/info/ProfileLink/helpers.ts b/src/pages/dashboard/instances/[single]/tabs/info/ProfileLink/helpers.ts index f69aaee84..6494f0d78 100644 --- a/src/pages/dashboard/instances/[single]/tabs/info/ProfileLink/helpers.ts +++ b/src/pages/dashboard/instances/[single]/tabs/info/ProfileLink/helpers.ts @@ -19,7 +19,7 @@ export const getTo = (profile: Profile) => { profile: profile.id.toString() || "", }); case "repository": - return ROUTES.profiles.repository({ + return ROUTES.repositories.repositoryProfiles({ search: profile.title || "", }); case "script": diff --git a/src/pages/dashboard/profiles/repository-profiles/index.ts b/src/pages/dashboard/profiles/repository-profiles/index.ts deleted file mode 100644 index f4449b9c8..000000000 --- a/src/pages/dashboard/profiles/repository-profiles/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./RepositoryProfilesPage"; diff --git a/src/pages/dashboard/repositories/RepositoryPage.tsx b/src/pages/dashboard/repositories/RepositoryPage.tsx index 9adf21a8e..b9be62c03 100644 --- a/src/pages/dashboard/repositories/RepositoryPage.tsx +++ b/src/pages/dashboard/repositories/RepositoryPage.tsx @@ -1,16 +1,9 @@ import { ROUTES } from "@/libs/routes"; import type { FC } from "react"; -import { useEffect } from "react"; -import { useNavigate } from "react-router"; +import { Navigate } from "react-router"; const RepositoryPage: FC = () => { - const navigate = useNavigate(); - - useEffect(() => { - navigate(ROUTES.repositories.mirrors(), { replace: true }); - }, []); - - return null; + return ; }; export default RepositoryPage; diff --git a/src/pages/dashboard/repositories/apt-sources/index.ts b/src/pages/dashboard/repositories/apt-sources/index.ts deleted file mode 100644 index bb8bfa5c9..000000000 --- a/src/pages/dashboard/repositories/apt-sources/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./APTSourcesPage"; diff --git a/src/pages/dashboard/repositories/gpg-keys/index.ts b/src/pages/dashboard/repositories/gpg-keys/index.ts deleted file mode 100644 index d74d95669..000000000 --- a/src/pages/dashboard/repositories/gpg-keys/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./GPGKeysPage"; diff --git a/src/pages/dashboard/repositories/apt-sources/APTSourcesPage.tsx b/src/pages/dashboard/repositories/local-repositories/LocalRepositoriesPage.tsx similarity index 100% rename from src/pages/dashboard/repositories/apt-sources/APTSourcesPage.tsx rename to src/pages/dashboard/repositories/local-repositories/LocalRepositoriesPage.tsx diff --git a/src/pages/dashboard/repositories/local-repositories/index.ts b/src/pages/dashboard/repositories/local-repositories/index.ts new file mode 100644 index 000000000..a783fbe49 --- /dev/null +++ b/src/pages/dashboard/repositories/local-repositories/index.ts @@ -0,0 +1 @@ +export { default } from "./LocalRepositoriesPage"; diff --git a/src/pages/dashboard/repositories/mirrors/DistributionsPage.tsx b/src/pages/dashboard/repositories/mirrors/MirrorsPage.tsx similarity index 100% rename from src/pages/dashboard/repositories/mirrors/DistributionsPage.tsx rename to src/pages/dashboard/repositories/mirrors/MirrorsPage.tsx diff --git a/src/pages/dashboard/repositories/mirrors/index.ts b/src/pages/dashboard/repositories/mirrors/index.ts index fda48713c..ee21e658d 100644 --- a/src/pages/dashboard/repositories/mirrors/index.ts +++ b/src/pages/dashboard/repositories/mirrors/index.ts @@ -1 +1 @@ -export { default } from "./DistributionsPage"; +export { default } from "./MirrorsPage"; diff --git a/src/pages/dashboard/repositories/gpg-keys/GPGKeysPage.tsx b/src/pages/dashboard/repositories/publication-targets/PublicationTargetsPage.tsx similarity index 68% rename from src/pages/dashboard/repositories/gpg-keys/GPGKeysPage.tsx rename to src/pages/dashboard/repositories/publication-targets/PublicationTargetsPage.tsx index ddcad7f5b..d861a67a3 100644 --- a/src/pages/dashboard/repositories/gpg-keys/GPGKeysPage.tsx +++ b/src/pages/dashboard/repositories/publication-targets/PublicationTargetsPage.tsx @@ -3,31 +3,28 @@ import LoadingState from "@/components/layout/LoadingState"; import PageContent from "@/components/layout/PageContent"; import PageHeader from "@/components/layout/PageHeader"; import PageMain from "@/components/layout/PageMain"; -import { GPGKeysList, useGPGKeys } from "@/features/gpg-keys"; +import { APTSourcesList, useGetAPTSources } from "@/features/apt-sources"; import useSidePanel from "@/hooks/useSidePanel"; import { Button } from "@canonical/react-components"; import type { FC } from "react"; import { lazy, Suspense } from "react"; -const NewGPGKeyForm = lazy(async () => - import("@/features/gpg-keys").then((module) => ({ - default: module.NewGPGKeyForm, +const NewAPTSourceForm = lazy(async () => + import("@/features/apt-sources").then((module) => ({ + default: module.NewAPTSourceForm, })), ); -const GPGKeysPage: FC = () => { +const APTSourcesPage: FC = () => { const { setSidePanelContent } = useSidePanel(); - const { getGPGKeysQuery } = useGPGKeys(); - - const { data, isLoading } = getGPGKeysQuery(); - - const items = data?.data ?? []; + const { aptSources: items, isGettingAPTSources: isLoading } = + useGetAPTSources(); const handleOpen = () => { setSidePanelContent( - "Import GPG key", + "Add APT source", }> - + , ); }; @@ -35,16 +32,15 @@ const GPGKeysPage: FC = () => { return ( - Import key + Add APT source , ]} /> @@ -52,19 +48,19 @@ const GPGKeysPage: FC = () => { {isLoading && } {!isLoading && items.length === 0 && (

- You haven't added any GPG keys yet. + You haven’t added any APT sources yet.

- How to manage GPG keys in Landscape + How to manage APT sources in Landscape } @@ -74,17 +70,16 @@ const GPGKeysPage: FC = () => { key="table-add-new-mirror" onClick={handleOpen} type="button" - aria-label="Import GPG key" > - Import key + Add APT source , ]} /> )} - {!isLoading && items.length > 0 && } + {!isLoading && items.length > 0 && }
); }; -export default GPGKeysPage; +export default APTSourcesPage; diff --git a/src/pages/dashboard/repositories/publication-targets/index.ts b/src/pages/dashboard/repositories/publication-targets/index.ts new file mode 100644 index 000000000..50b96c735 --- /dev/null +++ b/src/pages/dashboard/repositories/publication-targets/index.ts @@ -0,0 +1 @@ +export { default } from "./PublicationTargetsPage"; diff --git a/src/pages/dashboard/repositories/publications/PublicationsPage.tsx b/src/pages/dashboard/repositories/publications/PublicationsPage.tsx new file mode 100644 index 000000000..d861a67a3 --- /dev/null +++ b/src/pages/dashboard/repositories/publications/PublicationsPage.tsx @@ -0,0 +1,85 @@ +import EmptyState from "@/components/layout/EmptyState"; +import LoadingState from "@/components/layout/LoadingState"; +import PageContent from "@/components/layout/PageContent"; +import PageHeader from "@/components/layout/PageHeader"; +import PageMain from "@/components/layout/PageMain"; +import { APTSourcesList, useGetAPTSources } from "@/features/apt-sources"; +import useSidePanel from "@/hooks/useSidePanel"; +import { Button } from "@canonical/react-components"; +import type { FC } from "react"; +import { lazy, Suspense } from "react"; + +const NewAPTSourceForm = lazy(async () => + import("@/features/apt-sources").then((module) => ({ + default: module.NewAPTSourceForm, + })), +); + +const APTSourcesPage: FC = () => { + const { setSidePanelContent } = useSidePanel(); + const { aptSources: items, isGettingAPTSources: isLoading } = + useGetAPTSources(); + + const handleOpen = () => { + setSidePanelContent( + "Add APT source", + }> + + , + ); + }; + + return ( + + + Add APT source + , + ]} + /> + + {isLoading && } + {!isLoading && items.length === 0 && ( + +

+ You haven’t added any APT sources yet. +

+ + How to manage APT sources in Landscape + + + } + cta={[ + , + ]} + /> + )} + {!isLoading && items.length > 0 && } +
+
+ ); +}; + +export default APTSourcesPage; diff --git a/src/pages/dashboard/repositories/publications/index.ts b/src/pages/dashboard/repositories/publications/index.ts new file mode 100644 index 000000000..9807d653f --- /dev/null +++ b/src/pages/dashboard/repositories/publications/index.ts @@ -0,0 +1 @@ +export { default } from "./PublicationsPage"; diff --git a/src/pages/dashboard/profiles/repository-profiles/RepositoryProfilesPage/RepositoryProfilesPage.tsx b/src/pages/dashboard/repositories/repository-profiles/RepositoryProfilesPage.tsx similarity index 100% rename from src/pages/dashboard/profiles/repository-profiles/RepositoryProfilesPage/RepositoryProfilesPage.tsx rename to src/pages/dashboard/repositories/repository-profiles/RepositoryProfilesPage.tsx diff --git a/src/pages/dashboard/profiles/repository-profiles/RepositoryProfilesPage/index.ts b/src/pages/dashboard/repositories/repository-profiles/index.ts similarity index 100% rename from src/pages/dashboard/profiles/repository-profiles/RepositoryProfilesPage/index.ts rename to src/pages/dashboard/repositories/repository-profiles/index.ts diff --git a/src/routes/DashboardRoutes.tsx b/src/routes/DashboardRoutes.tsx index c6878fed1..59e898a78 100644 --- a/src/routes/DashboardRoutes.tsx +++ b/src/routes/DashboardRoutes.tsx @@ -1,9 +1,12 @@ import { Outlet, Route } from "react-router"; import { PATHS } from "@/libs/routes"; import { AuthGuard } from "@/components/guards/AuthGuard"; -import { SelfHostedGuard } from "@/components/guards/SelfHostedGuard"; import { FeatureGuard } from "@/components/guards/FeatureGuard"; import * as Pages from "@/routes/elements"; +import SecondaryNavigation from "@/templates/dashboard/SecondaryNavigation"; +import { ACCOUNT_SETTINGS } from "@/templates/dashboard/SecondaryNavigation/constants"; +import DarkModeSwitch from "@/templates/dashboard/SecondaryNavigation/components/DarkModeSwitch"; +import { REPOSITORY_SUBMENU } from "@/templates/dashboard/Navigation/constants"; export const DashboardRoutes = ( - } - /> } @@ -75,23 +74,37 @@ export const DashboardRoutes = ( {/* --- Repositories --- */} - + + + + + } + > } + path={PATHS.repositories.mirrors} + element={} /> } + path={PATHS.repositories.localRepositories} + element={} /> - - - - } + path={PATHS.repositories.publications} + element={} + /> + } + /> + } /> @@ -131,7 +144,20 @@ export const DashboardRoutes = ( {/* --- Account --- */} - + + + + + + + } + > } diff --git a/src/routes/elements.tsx b/src/routes/elements.tsx index 71fa3c1b9..48e152b26 100644 --- a/src/routes/elements.tsx +++ b/src/routes/elements.tsx @@ -65,18 +65,6 @@ export const InstancesPage = Loadable( export const SingleInstance = Loadable( lazy(() => import("@/pages/dashboard/instances/[single]")), ); -export const DistributionsPage = Loadable( - lazy(() => import("@/pages/dashboard/repositories/mirrors")), -); -export const RepositoryProfilesPage = Loadable( - lazy(() => import("@/pages/dashboard/profiles/repository-profiles")), -); -export const GPGKeysPage = Loadable( - lazy(() => import("@/pages/dashboard/repositories/gpg-keys")), -); -export const APTSourcesPage = Loadable( - lazy(() => import("@/pages/dashboard/repositories/apt-sources")), -); export const PackageProfilesPage = Loadable( lazy(() => import("@/pages/dashboard/profiles/package-profiles")), ); @@ -95,6 +83,21 @@ export const SecurityProfilesPage = Loadable( export const RebootProfilesPage = Loadable( lazy(() => import("@/pages/dashboard/profiles/reboot-profiles")), ); +export const MirrorsPage = Loadable( + lazy(() => import("@/pages/dashboard/repositories/mirrors")), +); +export const LocalRepositoriesPage = Loadable( + lazy(() => import("@/pages/dashboard/repositories/local-repositories")), +); +export const PublicationsPage = Loadable( + lazy(() => import("@/pages/dashboard/repositories/publications")), +); +export const PublicationTargetsPage = Loadable( + lazy(() => import("@/pages/dashboard/repositories/publication-targets")), +); +export const RepositoryProfilesPage = Loadable( + lazy(() => import("@/pages/dashboard/repositories/repository-profiles")), +); export const AccessGroupsPage = Loadable( lazy(() => import("@/pages/dashboard/settings/access-group")), ); diff --git a/src/templates/dashboard/DashboardTemplate.module.scss b/src/templates/dashboard/DashboardTemplate.module.scss index f75e1eb60..7c3da587b 100644 --- a/src/templates/dashboard/DashboardTemplate.module.scss +++ b/src/templates/dashboard/DashboardTemplate.module.scss @@ -3,14 +3,3 @@ .wrapper { display: flex; } - -.secondaryNavigation { - flex-shrink: 0; -} - -.pageContent { - display: flex; - flex-direction: column; - min-width: 0; - width: 100%; -} diff --git a/src/templates/dashboard/DashboardTemplate.tsx b/src/templates/dashboard/DashboardTemplate.tsx index 2305a7f4e..d6d6c8c2e 100644 --- a/src/templates/dashboard/DashboardTemplate.tsx +++ b/src/templates/dashboard/DashboardTemplate.tsx @@ -2,11 +2,8 @@ import ApplicationIdContext from "@/context/applicationId"; import WelcomePopup from "@/features/welcome-banner"; import classNames from "classnames"; import { useId, type FC, type ReactNode } from "react"; -import { matchPath, useLocation } from "react-router"; -import { useMediaQuery } from "usehooks-ts"; import SidePanelProvider from "../../context/sidePanel"; import classes from "./DashboardTemplate.module.scss"; -import SecondaryNavigation from "./SecondaryNavigation"; import Sidebar from "./Sidebar"; interface DashboardTemplateProps { @@ -14,9 +11,6 @@ interface DashboardTemplateProps { } const DashboardTemplate: FC = ({ children }) => { - const { pathname } = useLocation(); - const hasSecondaryNav = matchPath("/account/*", pathname); - const isLargeScreen = useMediaQuery("(min-width: 620px)"); const applicationId = useId(); return ( @@ -25,17 +19,7 @@ const DashboardTemplate: FC = ({ children }) => {
- {hasSecondaryNav && isLargeScreen && ( -
- -
- )} -
{children}
+ {children}
diff --git a/src/templates/dashboard/Navigation/components/NavigationExpandable/NavigationExpandable.tsx b/src/templates/dashboard/Navigation/components/NavigationExpandable/NavigationExpandable.tsx index bc6e39683..14429a674 100644 --- a/src/templates/dashboard/Navigation/components/NavigationExpandable/NavigationExpandable.tsx +++ b/src/templates/dashboard/Navigation/components/NavigationExpandable/NavigationExpandable.tsx @@ -6,6 +6,9 @@ import { useLocation } from "react-router"; import { getPathToExpand } from "@/templates/dashboard/Navigation/helpers"; import NavigationRoute from "@/templates/dashboard/Navigation/components/NavigationRoute"; import NavigationExpandableParent from "@/templates/dashboard/Navigation/components/NavigationExpandableParent"; +import { useMediaQuery } from "usehooks-ts"; +import classes from "../../Navigation.module.scss"; +import classNames from "classnames"; interface NavigationExpandableProps { readonly item: MenuItem; @@ -14,6 +17,7 @@ interface NavigationExpandableProps { const NavigationExpandable: FC = ({ item }) => { const [expanded, setExpanded] = useState(""); const { pathname } = useLocation(); + const isLargerScreen = useMediaQuery("(min-width: 620px)"); useEffect(() => { const shouldBeExpandedPath = getPathToExpand(pathname); @@ -23,6 +27,18 @@ const NavigationExpandable: FC = ({ item }) => { } }, [pathname]); + if (isLargerScreen && item.secondary) { + return ( + + ); + } + return ( <> = ({ item, current }) => { +const NavigationRoute: FC = ({ + item, + current, + className, +}) => { return ( diff --git a/src/templates/dashboard/Navigation/constants.ts b/src/templates/dashboard/Navigation/constants.ts index 7a827e3c7..189804f96 100644 --- a/src/templates/dashboard/Navigation/constants.ts +++ b/src/templates/dashboard/Navigation/constants.ts @@ -2,7 +2,6 @@ import { ROUTES } from "@/libs/routes"; import type { MenuItem } from "./types"; const PROFILES_SUBMENU: MenuItem[] = [ - { label: "Repository profiles", path: ROUTES.profiles.repository() }, { label: "Package profiles", path: ROUTES.profiles.package() }, { label: "Upgrade profiles", path: ROUTES.profiles.upgrade() }, { label: "Reboot profiles", path: ROUTES.profiles.reboot() }, @@ -19,10 +18,21 @@ const PROFILES_SUBMENU: MenuItem[] = [ }, ]; -const REPOSITORY_SUBMENU: MenuItem[] = [ - { label: "Mirrors", path: ROUTES.repositories.mirrors(), env: "selfHosted" }, - { label: "GPG Keys", path: ROUTES.repositories.gpgKeys() }, - { label: "APT Sources", path: ROUTES.repositories.aptSources() }, +export const REPOSITORY_SUBMENU: MenuItem[] = [ + { label: "Mirrors", path: ROUTES.repositories.mirrors() }, + { + label: "Local repositories", + path: ROUTES.repositories.localRepositories(), + }, + { label: "Publications", path: ROUTES.repositories.publications() }, + { + label: "Publication targets", + path: ROUTES.repositories.publicationTargets(), + }, + { + label: "Repository profiles", + path: ROUTES.repositories.repositoryProfiles(), + }, ]; const SETTINGS_SUBMENU: MenuItem[] = [ @@ -64,9 +74,10 @@ export const MENU_ITEMS: MenuItem[] = [ }, { label: "Repositories", - path: ROUTES.repositories.root(), + path: ROUTES.repositories.mirrors(), icon: "fork", items: REPOSITORY_SUBMENU, + secondary: true, }, { label: "Org. settings", diff --git a/src/templates/dashboard/Navigation/types.d.ts b/src/templates/dashboard/Navigation/types.d.ts index 66d18c745..501fe5a5e 100644 --- a/src/templates/dashboard/Navigation/types.d.ts +++ b/src/templates/dashboard/Navigation/types.d.ts @@ -14,4 +14,5 @@ export interface MenuItem { count: number; isNegative: boolean; }; + secondary?: boolean; } diff --git a/src/templates/dashboard/SecondaryNavigation/SecondaryNavigation.module.scss b/src/templates/dashboard/SecondaryNavigation/SecondaryNavigation.module.scss index 9f13a3140..cb5eda2ca 100644 --- a/src/templates/dashboard/SecondaryNavigation/SecondaryNavigation.module.scss +++ b/src/templates/dashboard/SecondaryNavigation/SecondaryNavigation.module.scss @@ -3,6 +3,10 @@ @import "vanilla-framework/scss/settings_breakpoints"; @import "vanilla-framework/scss/layouts_application"; +.secondaryNavigationDrawer { + flex-shrink: 0; +} + .secondaryNavigation { background-color: $colors--theme--background-alt !important; display: flex; diff --git a/src/templates/dashboard/SecondaryNavigation/SecondaryNavigation.tsx b/src/templates/dashboard/SecondaryNavigation/SecondaryNavigation.tsx index d8ae3ca23..32c997b15 100644 --- a/src/templates/dashboard/SecondaryNavigation/SecondaryNavigation.tsx +++ b/src/templates/dashboard/SecondaryNavigation/SecondaryNavigation.tsx @@ -1,62 +1,84 @@ -import DarkModeSwitch from "@/templates/dashboard/SecondaryNavigation/components/DarkModeSwitch"; import classNames from "classnames"; import { Link, matchPath, useLocation } from "react-router"; import classes from "./SecondaryNavigation.module.scss"; -import { ACCOUNT_SETTINGS } from "./constants"; +import { useMediaQuery } from "usehooks-ts"; +import type { FC, ReactNode } from "react"; +import type { MenuItem } from "../Navigation/types"; -export const SecondaryNavigation = () => { +interface SecondaryNavigationProps { + readonly title: ReactNode; + readonly items: MenuItem[]; + readonly children?: ReactNode; +} + +export const SecondaryNavigation: FC = ({ + title, + items, + children, +}) => { const location = useLocation(); + const isLargeScreen = useMediaQuery("(min-width: 620px)"); + + if (!isLargeScreen) { + return null; + } + return (
- -
- +

+ {title} +

+
    + {items.map((item) => { + const isActive = matchPath(item.path, location.pathname); + return ( +
  • + + + {item.label} + + +
  • + ); + })} +
+ + {children &&
{children}
}
); From 0e59c97fbe99a9b55a4c8e2eb704235569003cd8 Mon Sep 17 00:00:00 2001 From: Marc Bucchieri Date: Tue, 21 Apr 2026 10:45:06 -0700 Subject: [PATCH 02/42] Finish rebase from main; Add self-hosted check to debarchive pages --- src/routes/DashboardRoutes.test.tsx | 93 +++++++++++++++++++ src/routes/DashboardRoutes.tsx | 35 +++++-- .../dashboard/Navigation/constants.ts | 7 +- .../SecondaryNavigation.test.tsx | 26 +++++- 4 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 src/routes/DashboardRoutes.test.tsx diff --git a/src/routes/DashboardRoutes.test.tsx b/src/routes/DashboardRoutes.test.tsx new file mode 100644 index 000000000..80fc52efe --- /dev/null +++ b/src/routes/DashboardRoutes.test.tsx @@ -0,0 +1,93 @@ +import { + Children, + isValidElement, + type ReactElement, + type ReactNode, +} from "react"; +import { Outlet } from "react-router"; +import { describe, expect, it } from "vitest"; +import { AuthGuard } from "@/components/guards/AuthGuard"; +import { FeatureGuard } from "@/components/guards/FeatureGuard"; +import { SelfHostedGuard } from "@/components/guards/SelfHostedGuard"; +import { PATHS } from "@/libs/routes"; +import { DashboardRoutes } from "./DashboardRoutes"; + +interface RouteLikeProps { + children?: ReactNode; + element?: ReactElement; + path?: string; +} + +const isRouteElement = ( + value: unknown, +): value is ReactElement => + isValidElement(value); + +const getRouteChildren = (element: ReactElement) => { + return Children.toArray(element.props.children).filter(isRouteElement); +}; + +const flattenRoutes = ( + routeElement: ReactElement, +): ReactElement[] => { + const directChildren = getRouteChildren(routeElement); + return directChildren.flatMap((child) => [child, ...flattenRoutes(child)]); +}; + +describe("DashboardRoutes", () => { + it("wraps dashboard routes with auth guard and outlet", () => { + const wrapper = (DashboardRoutes as ReactElement).props + .element; + assert(wrapper); + const guardWrapper = wrapper as ReactElement<{ children: ReactElement }>; + expect(guardWrapper.type).toBe(AuthGuard); + + const wrappedChild = guardWrapper.props.children; + expect(wrappedChild.type).toBe(Outlet); + }); + + it("includes key dashboard route paths", () => { + const allRoutes = flattenRoutes( + DashboardRoutes as ReactElement, + ); + const paths = allRoutes.map((route) => route.props.path).filter(Boolean); + + expect(paths).toContain(PATHS.root.root); + expect(paths).toContain(PATHS.overview.root); + expect(paths).toContain(PATHS.instances.root); + expect(paths).toContain(PATHS.instances.single); + expect(paths).toContain(PATHS.account.apiCredentials); + expect(paths).toContain(PATHS.repositories.mirrors); + expect(paths).toContain(PATHS.settings.employees); + }); + + it("uses self-hosted and feature guards for guarded paths", () => { + const allRoutes = flattenRoutes( + DashboardRoutes as ReactElement, + ); + + const mirrorsRoute = allRoutes.find( + (route) => route.props.path === PATHS.repositories.mirrors, + ); + const employeesRoute = allRoutes.find( + (route) => route.props.path === PATHS.settings.employees, + ); + const identityProvidersRoute = allRoutes.find( + (route) => route.props.path === PATHS.settings.identityProviders, + ); + + const wslProfilesRoute = allRoutes.find( + (route) => route.props.path === PATHS.profiles.wsl, + ); + + assert(mirrorsRoute?.props.element); + assert(employeesRoute?.props.element); + assert(identityProvidersRoute?.props.element); + assert(wslProfilesRoute?.props.element); + + expect(mirrorsRoute.props.element.type).toBe(SelfHostedGuard); + expect(employeesRoute.props.element.type).toBe(FeatureGuard); + expect(identityProvidersRoute.props.element.type).toBe(FeatureGuard); + expect(wslProfilesRoute.props.element.type).toBe(FeatureGuard); + }); +}); diff --git a/src/routes/DashboardRoutes.tsx b/src/routes/DashboardRoutes.tsx index 59e898a78..0c61a7648 100644 --- a/src/routes/DashboardRoutes.tsx +++ b/src/routes/DashboardRoutes.tsx @@ -7,6 +7,7 @@ import SecondaryNavigation from "@/templates/dashboard/SecondaryNavigation"; import { ACCOUNT_SETTINGS } from "@/templates/dashboard/SecondaryNavigation/constants"; import DarkModeSwitch from "@/templates/dashboard/SecondaryNavigation/components/DarkModeSwitch"; import { REPOSITORY_SUBMENU } from "@/templates/dashboard/Navigation/constants"; +import { SelfHostedGuard } from "@/components/guards/SelfHostedGuard"; export const DashboardRoutes = ( + - + } > } + element={ + + + + } /> } + element={ + + + + } /> } + element={ + + + + } /> } + element={ + + + + } /> } + element={ + + + + } /> diff --git a/src/templates/dashboard/Navigation/constants.ts b/src/templates/dashboard/Navigation/constants.ts index 189804f96..3ae116d87 100644 --- a/src/templates/dashboard/Navigation/constants.ts +++ b/src/templates/dashboard/Navigation/constants.ts @@ -19,19 +19,21 @@ const PROFILES_SUBMENU: MenuItem[] = [ ]; export const REPOSITORY_SUBMENU: MenuItem[] = [ - { label: "Mirrors", path: ROUTES.repositories.mirrors() }, + { label: "Mirrors", path: ROUTES.repositories.mirrors(), env: "selfHosted" }, { label: "Local repositories", path: ROUTES.repositories.localRepositories(), }, - { label: "Publications", path: ROUTES.repositories.publications() }, + { label: "Publications", path: ROUTES.repositories.publications(), env: "selfHosted" }, { label: "Publication targets", path: ROUTES.repositories.publicationTargets(), + env: "selfHosted" }, { label: "Repository profiles", path: ROUTES.repositories.repositoryProfiles(), + env: "selfHosted" }, ]; @@ -78,6 +80,7 @@ export const MENU_ITEMS: MenuItem[] = [ icon: "fork", items: REPOSITORY_SUBMENU, secondary: true, + env: "selfHosted" }, { label: "Org. settings", diff --git a/src/templates/dashboard/SecondaryNavigation/SecondaryNavigation.test.tsx b/src/templates/dashboard/SecondaryNavigation/SecondaryNavigation.test.tsx index e08d6667d..e126e3b2f 100644 --- a/src/templates/dashboard/SecondaryNavigation/SecondaryNavigation.test.tsx +++ b/src/templates/dashboard/SecondaryNavigation/SecondaryNavigation.test.tsx @@ -1,12 +1,25 @@ import { renderWithProviders } from "@/tests/render"; import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; import SecondaryNavigation from "./SecondaryNavigation"; import { ACCOUNT_SETTINGS } from "./constants"; import { PATHS, ROUTES } from "@/libs/routes"; +// Mock useMediaQuery to simulate large screen +vi.mock("usehooks-ts", async () => { + const actual = await vi.importActual("usehooks-ts"); + return { + ...actual, + useMediaQuery: vi.fn(() => true), // Always return true for large screen + }; +}); + describe("SecondaryNavigation", () => { it("renders correctly", () => { - renderWithProviders(); + renderWithProviders(); expect( screen.getByRole("heading", { name: ACCOUNT_SETTINGS.label }), @@ -22,7 +35,10 @@ describe("SecondaryNavigation", () => { assert(ACCOUNT_SETTINGS.items); renderWithProviders( - , + , {}, ROUTES.account.general(), `/${PATHS.account.root}/${PATHS.account.general}`, @@ -31,10 +47,12 @@ describe("SecondaryNavigation", () => { const activeLink = screen.getByRole("link", { name: ACCOUNT_SETTINGS.items[0].label, }); - expect(activeLink.className).toContain("isActive"); + // CSS Module class names are hashed (e.g., "SecondaryNavigation_isActive__abc123") + // Use regex to match the generated class containing "isActive" + expect(activeLink.className).toMatch(/isActive/); expect( screen.getByRole("link", { name: ACCOUNT_SETTINGS.items[1].label }), - ).not.toHaveClass("isActive"); + ).not.toHaveClass(/isActive/); }); }); From f30ab4d3efab6f44e90f8f67c2ceee7df18bff43 Mon Sep 17 00:00:00 2001 From: Ethan Shaw Date: Fri, 27 Mar 2026 12:09:50 -0700 Subject: [PATCH 03/42] Update dependencies (#529) --- package.json | 18 +- pnpm-lock.yaml | 873 ++++++++++++++++++++++++------------------------- 2 files changed, 445 insertions(+), 446 deletions(-) diff --git a/package.json b/package.json index bc091d5c2..91c36ded0 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,10 @@ "tcm:watch": "pnpm tcm:run --watch" }, "dependencies": { - "@canonical/react-components": "4.0.0", + "@canonical/react-components": "4.0.1", "@monaco-editor/react": "4.7.0", - "@sentry/react": "10.45.0", - "@tanstack/react-query": "5.91.2", + "@sentry/react": "10.46.0", + "@tanstack/react-query": "5.95.2", "axios": "1.13.6", "buffer": "6.0.3", "chart.js": "4.5.1", @@ -36,11 +36,11 @@ "formik": "2.4.9", "moment": "2.30.1", "monaco-editor": "0.55.1", - "path-to-regexp": "8.3.0", + "path-to-regexp": "8.4.0", "react": "19.2.4", "react-chartjs-2": "5.3.1", "react-dom": "19.2.4", - "react-router": "7.13.1", + "react-router": "7.13.2", "usehooks-ts": "3.1.1", "vanilla-framework": "4.46.0", "yup": "1.7.1" @@ -63,18 +63,18 @@ "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.0.1", "globals": "17.4.0", - "jsdom": "29.0.0", + "jsdom": "29.0.1", "msw": "2.4.11", "prettier": "3.8.1", "sass": "1.98.0", "sass-embedded": "1.98.0", - "stylelint": "17.5.0", + "stylelint": "17.6.0", "stylelint-config-standard-scss": "17.0.0", "stylelint-order": "8.1.1", "typed-css-modules": "0.9.1", "typescript": "5.9.3", - "typescript-eslint": "8.57.1", - "vite": "8.0.1", + "typescript-eslint": "8.57.2", + "vite": "8.0.3", "vitest": "4.0.18" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddd7d218e..086fb98bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,17 @@ importers: .: dependencies: '@canonical/react-components': - specifier: 4.0.0 - version: 4.0.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(formik@2.4.9(@types/react@19.2.14)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vanilla-framework@4.46.0(sass@1.98.0)) + specifier: 4.0.1 + version: 4.0.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(formik@2.4.9(@types/react@19.2.14)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vanilla-framework@4.46.0(sass@1.98.0)) '@monaco-editor/react': specifier: 4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@sentry/react': - specifier: 10.45.0 - version: 10.45.0(react@19.2.4) + specifier: 10.46.0 + version: 10.46.0(react@19.2.4) '@tanstack/react-query': - specifier: 5.91.2 - version: 5.91.2(react@19.2.4) + specifier: 5.95.2 + version: 5.95.2(react@19.2.4) axios: specifier: 1.13.6 version: 1.13.6 @@ -45,8 +45,8 @@ importers: specifier: 0.55.1 version: 0.55.1 path-to-regexp: - specifier: 8.3.0 - version: 8.3.0 + specifier: 8.4.0 + version: 8.4.0 react: specifier: 19.2.4 version: 19.2.4 @@ -57,8 +57,8 @@ importers: specifier: 19.2.4 version: 19.2.4(react@19.2.4) react-router: - specifier: 7.13.1 - version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 7.13.2 + version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) usehooks-ts: specifier: 3.1.1 version: 3.1.1(react@19.2.4) @@ -101,10 +101,10 @@ importers: version: 7.7.20 '@vitejs/plugin-react': specifier: 6.0.1 - version: 6.0.1(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1)) + version: 6.0.1(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.4)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1)) '@vitest/coverage-v8': specifier: 4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@24.12.0)(jsdom@29.0.0)(lightningcss@1.32.0)(msw@2.4.11(typescript@5.9.3))(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1)) + version: 4.0.18(vitest@4.0.18(@types/node@24.12.0)(jsdom@29.0.1)(lightningcss@1.32.0)(msw@2.4.11(typescript@5.9.3))(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1)) eslint: specifier: 9.39.4 version: 9.39.4 @@ -121,8 +121,8 @@ importers: specifier: 17.4.0 version: 17.4.0 jsdom: - specifier: 29.0.0 - version: 29.0.0 + specifier: 29.0.1 + version: 29.0.1 msw: specifier: 2.4.11 version: 2.4.11(typescript@5.9.3) @@ -136,14 +136,14 @@ importers: specifier: 1.98.0 version: 1.98.0 stylelint: - specifier: 17.5.0 - version: 17.5.0(typescript@5.9.3) + specifier: 17.6.0 + version: 17.6.0(typescript@5.9.3) stylelint-config-standard-scss: specifier: 17.0.0 - version: 17.0.0(postcss@8.5.8)(stylelint@17.5.0(typescript@5.9.3)) + version: 17.0.0(postcss@8.5.8)(stylelint@17.6.0(typescript@5.9.3)) stylelint-order: specifier: 8.1.1 - version: 8.1.1(stylelint@17.5.0(typescript@5.9.3)) + version: 8.1.1(stylelint@17.6.0(typescript@5.9.3)) typed-css-modules: specifier: 0.9.1 version: 0.9.1 @@ -151,14 +151,14 @@ importers: specifier: 5.9.3 version: 5.9.3 typescript-eslint: - specifier: 8.57.1 - version: 8.57.1(eslint@9.39.4)(typescript@5.9.3) + specifier: 8.57.2 + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vite: - specifier: 8.0.1 - version: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1) + specifier: 8.0.3 + version: 8.0.3(@types/node@24.12.0)(esbuild@0.27.4)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1) vitest: specifier: 4.0.18 - version: 4.0.18(@types/node@24.12.0)(jsdom@29.0.0)(lightningcss@1.32.0)(msw@2.4.11(typescript@5.9.3))(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1) + version: 4.0.18(@types/node@24.12.0)(jsdom@29.0.1)(lightningcss@1.32.0)(msw@2.4.11(typescript@5.9.3))(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1) packages: @@ -172,8 +172,8 @@ packages: resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - '@asamuzakjp/dom-selector@7.0.3': - resolution: {integrity: sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==} + '@asamuzakjp/dom-selector@7.0.4': + resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} '@asamuzakjp/nwsapi@2.3.9': @@ -273,11 +273,11 @@ packages: '@cacheable/memory@2.0.8': resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==} - '@cacheable/utils@2.4.0': - resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==} + '@cacheable/utils@2.4.1': + resolution: {integrity: sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==} - '@canonical/react-components@4.0.0': - resolution: {integrity: sha512-gcBSNW6M7dcKJhfGBn2Bw0jZj0BNYcKWNK12utmmWtzXmzkulgMHywTAd5ZXdI3TCYKxY1zyw5BZNQeYO+TsFA==} + '@canonical/react-components@4.0.1': + resolution: {integrity: sha512-lfD/SoSZQVgttKi4isgkSraowyQNxAodnSWk/Ksmv8A1gO9Oz3dRcPdKnMDfGx3ih8Lb1n+gBZFcDgq+dbmY2g==} peerDependencies: '@types/react': ^18.0.0 || ^19.0.0 '@types/react-dom': ^18.0.0 || ^19.0.0 @@ -389,8 +389,8 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.1': - resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==} + '@csstools/css-syntax-patches-for-csstree@1.1.2': + resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==} peerDependencies: css-tree: ^3.2.1 peerDependenciesMeta: @@ -803,8 +803,8 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@oxc-project/types@0.120.0': - resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==} + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} @@ -903,271 +903,271 @@ packages: engines: {node: '>=18'} hasBin: true - '@rolldown/binding-android-arm64@1.0.0-rc.10': - resolution: {integrity: sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==} + '@rolldown/binding-android-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.10': - resolution: {integrity: sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==} + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.10': - resolution: {integrity: sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==} + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.10': - resolution: {integrity: sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==} + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10': - resolution: {integrity: sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10': - resolution: {integrity: sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.10': - resolution: {integrity: sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==} + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10': - resolution: {integrity: sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==} + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10': - resolution: {integrity: sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==} + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.10': - resolution: {integrity: sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==} + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.10': - resolution: {integrity: sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==} + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.10': - resolution: {integrity: sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==} + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.10': - resolution: {integrity: sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==} + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10': - resolution: {integrity: sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.10': - resolution: {integrity: sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.10': - resolution: {integrity: sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==} + '@rolldown/pluginutils@1.0.0-rc.12': + resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} - '@rollup/rollup-android-arm-eabi@4.59.0': - resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.59.0': - resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.59.0': - resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.59.0': - resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.59.0': - resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.59.0': - resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.59.0': - resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.59.0': - resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.59.0': - resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.59.0': - resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.59.0': - resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.59.0': - resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.59.0': - resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.59.0': - resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.59.0': - resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.59.0': - resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.59.0': - resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.59.0': - resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.59.0': - resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.59.0': - resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.59.0': - resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} cpu: [x64] os: [win32] - '@sentry-internal/browser-utils@10.45.0': - resolution: {integrity: sha512-ZPZpeIarXKScvquGx2AfNKcYiVNDA4wegMmjyGVsTA2JPmP0TrJoO3UybJS6KGDeee8V3I3EfD/ruauMm7jOFQ==} + '@sentry-internal/browser-utils@10.46.0': + resolution: {integrity: sha512-WB1gBT9G13V02ekZ6NpUhoI1aGHV2eNfjEPthkU2bGBvFpQKnstwzjg7waIRGR7cu+YSW2Q6UI6aQLgBeOPD1g==} engines: {node: '>=18'} - '@sentry-internal/feedback@10.45.0': - resolution: {integrity: sha512-vCSurazFVq7RUeYiM5X326jA5gOVrWYD6lYX2fbjBOMcyCEhDnveNxMT62zKkZDyNT/jyD194nz/cjntBUkyWA==} + '@sentry-internal/feedback@10.46.0': + resolution: {integrity: sha512-c4pI/z9nZCQXe9GYEw/hE/YTY9AxGBp8/wgKI+T8zylrN35SGHaXv63szzE1WbI8lacBY8lBF7rstq9bQVCaHw==} engines: {node: '>=18'} - '@sentry-internal/replay-canvas@10.45.0': - resolution: {integrity: sha512-nvq/AocdZTuD7y0KSiWi3gVaY0s5HOFy86mC/v1kDZmT/jsBAzN5LDkk/f1FvsWma1peqQmpUqxvhC+YIW294Q==} + '@sentry-internal/replay-canvas@10.46.0': + resolution: {integrity: sha512-ub314MWUsekVCuoH0/HJbbimlI24SkV745UW2pj9xRbxOAEf1wjkmIzxKrMDbTgJGuEunug02XZVdJFJUzOcDw==} engines: {node: '>=18'} - '@sentry-internal/replay@10.45.0': - resolution: {integrity: sha512-vjosRoGA1bzhVAEO1oce+CsRdd70quzBeo7WvYqpcUnoLe/Rv8qpOMqWX3j26z7XfFHMExWQNQeLxmtYOArvlw==} + '@sentry-internal/replay@10.46.0': + resolution: {integrity: sha512-JBsWeXG6bRbxBFK8GzWymWGOB9QE7Kl57BeF3jzgdHTuHSWZ2mRnAmb1K05T4LU+gVygk6yW0KmdC8Py9Qzg9A==} engines: {node: '>=18'} - '@sentry/browser@10.45.0': - resolution: {integrity: sha512-e/a8UMiQhqqv706McSIcG6XK+AoQf9INthi2pD+giZfNRTzXTdqHzUT5OIO5hg8Am6eF63nDJc+vrYNPhzs51Q==} + '@sentry/browser@10.46.0': + resolution: {integrity: sha512-80DmGlTk5Z2/OxVOzLNxwolMyouuAYKqG8KUcoyintZqHbF6kO1RulI610HmyUt3OagKeBCqt9S7w0VIfCRL+Q==} engines: {node: '>=18'} - '@sentry/core@10.45.0': - resolution: {integrity: sha512-s69UXxvefeQxuZ5nY7/THtTrIEvJxNVCp3ns4kwoCw1qMpgpvn/296WCKVmM7MiwnaAdzEKnAvLAwaxZc2nM7Q==} + '@sentry/core@10.46.0': + resolution: {integrity: sha512-N3fj4zqBQOhXliS1Ne9euqIKuciHCGOJfPGQLwBoW9DNz03jF+NB8+dUKtrJ79YLoftjVgf8nbgwtADK7NR+2Q==} engines: {node: '>=18'} - '@sentry/react@10.45.0': - resolution: {integrity: sha512-jLezuxi4BUIU3raKyAPR5xMbQG/nhwnWmKo5p11NCbLmWzkS+lxoyDTUB4B8TAKZLfdtdkKLOn1S0tFc8vbUHw==} + '@sentry/react@10.46.0': + resolution: {integrity: sha512-Rb1S+9OuUPVwsz7GWnQ6Kgf3azbsseUymIegg3JZHNcW/fM1nPpaljzTBnuineia113DH0pgMBcdrrZDLaosFQ==} engines: {node: '>=18'} peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x @@ -1188,11 +1188,11 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tanstack/query-core@5.91.2': - resolution: {integrity: sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==} + '@tanstack/query-core@5.95.2': + resolution: {integrity: sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==} - '@tanstack/react-query@5.91.2': - resolution: {integrity: sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==} + '@tanstack/react-query@5.95.2': + resolution: {integrity: sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==} peerDependencies: react: ^18 || ^19 @@ -1313,63 +1313,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.57.1': - resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} + '@typescript-eslint/eslint-plugin@8.57.2': + resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.57.1 + '@typescript-eslint/parser': ^8.57.2 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.57.1': - resolution: {integrity: sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==} + '@typescript-eslint/parser@8.57.2': + resolution: {integrity: sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.57.1': - resolution: {integrity: sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==} + '@typescript-eslint/project-service@8.57.2': + resolution: {integrity: sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.57.1': - resolution: {integrity: sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==} + '@typescript-eslint/scope-manager@8.57.2': + resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.57.1': - resolution: {integrity: sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==} + '@typescript-eslint/tsconfig-utils@8.57.2': + resolution: {integrity: sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.57.1': - resolution: {integrity: sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==} + '@typescript-eslint/type-utils@8.57.2': + resolution: {integrity: sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.57.1': - resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==} + '@typescript-eslint/types@8.57.2': + resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.57.1': - resolution: {integrity: sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==} + '@typescript-eslint/typescript-estree@8.57.2': + resolution: {integrity: sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.57.1': - resolution: {integrity: sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==} + '@typescript-eslint/utils@8.57.2': + resolution: {integrity: sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.57.1': - resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==} + '@typescript-eslint/visitor-keys@8.57.2': + resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitejs/plugin-react@6.0.1': @@ -1555,8 +1555,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.9: - resolution: {integrity: sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==} + baseline-browser-mapping@2.10.11: + resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==} engines: {node: '>=6.0.0'} hasBin: true @@ -1571,14 +1571,14 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -1616,8 +1616,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001780: - resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} + caniuse-lite@1.0.30001781: + resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -1819,8 +1819,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.321: - resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==} + electron-to-chromium@1.5.328: + resolution: {integrity: sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2033,8 +2033,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flat-cache@6.1.21: - resolution: {integrity: sha512-2u7cJfSf7Th7NxEk/VzQjnPoglok2YCsevS7TSbJjcDQWJPbqUUnSYtriHSvtnq+fRZHy1s0ugk4ApnQyhPGoQ==} + flat-cache@6.1.22: + resolution: {integrity: sha512-N2dnzVJIphnNsjHcrxGW7DePckJ6haPrSFqpsBUhHYgwtKGVq4JrBGielEGD2fCVnsGm1zlBVZ8wGhkyuetgug==} flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} @@ -2158,8 +2158,8 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} - globby@16.1.1: - resolution: {integrity: sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==} + globby@16.2.0: + resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} engines: {node: '>=20'} globjoin@0.1.4: @@ -2172,8 +2172,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.13.1: - resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==} + graphql@16.13.2: + resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} has-bigints@1.1.0: @@ -2203,8 +2203,8 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hashery@1.5.0: - resolution: {integrity: sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==} + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} engines: {node: '>=20'} hasown@2.0.2: @@ -2538,8 +2538,8 @@ packages: canvas: optional: true - jsdom@29.0.0: - resolution: {integrity: sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==} + jsdom@29.0.1: + resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} peerDependencies: canvas: ^3.0.0 @@ -2949,8 +2949,8 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.0: + resolution: {integrity: sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==} path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} @@ -2962,12 +2962,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pify@4.0.1: @@ -3124,8 +3124,8 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-router@7.13.1: - resolution: {integrity: sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==} + react-router@7.13.2: + resolution: {integrity: sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -3195,13 +3195,13 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown@1.0.0-rc.10: - resolution: {integrity: sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==} + rolldown@1.0.0-rc.12: + resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup@4.59.0: - resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -3554,8 +3554,8 @@ packages: peerDependencies: stylelint: ^16.8.2 || ^17.0.0 - stylelint@17.5.0: - resolution: {integrity: sha512-o/NS6zhsPZFmgUm5tXX4pVNg1XDOZSlucLdf2qow/lVn4JIyzZIQ5b3kad1ugqUj3GSIgr2u5lQw7X8rjqw33g==} + stylelint@17.6.0: + resolution: {integrity: sha512-tokrsMIVAR9vAQ/q3UVEr7S0dGXCi7zkCezPRnS2kqPUulvUh5Vgfwngrk4EoAoW7wnrThqTdnTFN5Ra7CaxIg==} engines: {node: '>=20.19.0'} hasBin: true @@ -3625,15 +3625,15 @@ packages: tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} - tldts-core@7.0.26: - resolution: {integrity: sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==} + tldts-core@7.0.27: + resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} tldts@6.1.86: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true - tldts@7.0.26: - resolution: {integrity: sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==} + tldts@7.0.27: + resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} hasBin: true to-regex-range@5.0.1: @@ -3713,8 +3713,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - typescript-eslint@8.57.1: - resolution: {integrity: sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==} + typescript-eslint@8.57.2: + resolution: {integrity: sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -3735,8 +3735,8 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@7.24.5: - resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==} + undici@7.24.6: + resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==} engines: {node: '>=20.18.1'} unicorn-magic@0.4.0: @@ -3820,8 +3820,8 @@ packages: yaml: optional: true - vite@8.0.1: - resolution: {integrity: sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==} + vite@8.0.3: + resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3980,8 +3980,8 @@ packages: resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} engines: {node: ^20.17.0 || >=22.9.0} - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -4059,7 +4059,7 @@ snapshots: '@csstools/css-tokenizer': 4.0.0 lru-cache: 11.2.7 - '@asamuzakjp/dom-selector@7.0.3': + '@asamuzakjp/dom-selector@7.0.4': dependencies: '@asamuzakjp/nwsapi': 2.3.9 bidi-js: 1.0.3 @@ -4194,17 +4194,17 @@ snapshots: '@cacheable/memory@2.0.8': dependencies: - '@cacheable/utils': 2.4.0 + '@cacheable/utils': 2.4.1 '@keyv/bigmap': 1.3.1(keyv@5.6.0) hookified: 1.15.1 keyv: 5.6.0 - '@cacheable/utils@2.4.0': + '@cacheable/utils@2.4.1': dependencies: - hashery: 1.5.0 + hashery: 1.5.1 keyv: 5.6.0 - '@canonical/react-components@4.0.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(formik@2.4.9(@types/react@19.2.14)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vanilla-framework@4.46.0(sass@1.98.0))': + '@canonical/react-components@4.0.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(formik@2.4.9(@types/react@19.2.14)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vanilla-framework@4.46.0(sass@1.98.0))': dependencies: '@types/jest': 30.0.0 '@types/node': 20.19.30 @@ -4405,7 +4405,7 @@ snapshots: dependencies: '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)': + '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 @@ -4710,7 +4710,7 @@ snapshots: '@keyv/bigmap@1.3.1(keyv@5.6.0)': dependencies: - hashery: 1.5.0 + hashery: 1.5.1 hookified: 1.15.1 keyv: 5.6.0 @@ -4782,7 +4782,7 @@ snapshots: '@open-draft/until@2.1.0': {} - '@oxc-project/types@0.120.0': {} + '@oxc-project/types@0.122.0': {} '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -4828,7 +4828,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: '@parcel/watcher-android-arm64': 2.5.6 '@parcel/watcher-darwin-arm64': 2.5.6 @@ -4852,164 +4852,164 @@ snapshots: dependencies: playwright: 1.58.2 - '@rolldown/binding-android-arm64@1.0.0-rc.10': + '@rolldown/binding-android-arm64@1.0.0-rc.12': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.10': + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.10': + '@rolldown/binding-darwin-x64@1.0.0-rc.12': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.10': + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.10': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10': + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10': + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.10': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.10': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.10': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.10': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': dependencies: '@napi-rs/wasm-runtime': 1.1.1 optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.10': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': optional: true - '@rolldown/pluginutils@1.0.0-rc.10': {} + '@rolldown/pluginutils@1.0.0-rc.12': {} '@rolldown/pluginutils@1.0.0-rc.7': {} - '@rollup/rollup-android-arm-eabi@4.59.0': + '@rollup/rollup-android-arm-eabi@4.60.0': optional: true - '@rollup/rollup-android-arm64@4.59.0': + '@rollup/rollup-android-arm64@4.60.0': optional: true - '@rollup/rollup-darwin-arm64@4.59.0': + '@rollup/rollup-darwin-arm64@4.60.0': optional: true - '@rollup/rollup-darwin-x64@4.59.0': + '@rollup/rollup-darwin-x64@4.60.0': optional: true - '@rollup/rollup-freebsd-arm64@4.59.0': + '@rollup/rollup-freebsd-arm64@4.60.0': optional: true - '@rollup/rollup-freebsd-x64@4.59.0': + '@rollup/rollup-freebsd-x64@4.60.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.59.0': + '@rollup/rollup-linux-arm-musleabihf@4.60.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.59.0': + '@rollup/rollup-linux-arm64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.59.0': + '@rollup/rollup-linux-arm64-musl@4.60.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.59.0': + '@rollup/rollup-linux-loong64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.59.0': + '@rollup/rollup-linux-loong64-musl@4.60.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.59.0': + '@rollup/rollup-linux-ppc64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.59.0': + '@rollup/rollup-linux-ppc64-musl@4.60.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.59.0': + '@rollup/rollup-linux-riscv64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.59.0': + '@rollup/rollup-linux-riscv64-musl@4.60.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.59.0': + '@rollup/rollup-linux-s390x-gnu@4.60.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.59.0': + '@rollup/rollup-linux-x64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-x64-musl@4.59.0': + '@rollup/rollup-linux-x64-musl@4.60.0': optional: true - '@rollup/rollup-openbsd-x64@4.59.0': + '@rollup/rollup-openbsd-x64@4.60.0': optional: true - '@rollup/rollup-openharmony-arm64@4.59.0': + '@rollup/rollup-openharmony-arm64@4.60.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.59.0': + '@rollup/rollup-win32-arm64-msvc@4.60.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.59.0': + '@rollup/rollup-win32-ia32-msvc@4.60.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.59.0': + '@rollup/rollup-win32-x64-gnu@4.60.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.59.0': + '@rollup/rollup-win32-x64-msvc@4.60.0': optional: true - '@sentry-internal/browser-utils@10.45.0': + '@sentry-internal/browser-utils@10.46.0': dependencies: - '@sentry/core': 10.45.0 + '@sentry/core': 10.46.0 - '@sentry-internal/feedback@10.45.0': + '@sentry-internal/feedback@10.46.0': dependencies: - '@sentry/core': 10.45.0 + '@sentry/core': 10.46.0 - '@sentry-internal/replay-canvas@10.45.0': + '@sentry-internal/replay-canvas@10.46.0': dependencies: - '@sentry-internal/replay': 10.45.0 - '@sentry/core': 10.45.0 + '@sentry-internal/replay': 10.46.0 + '@sentry/core': 10.46.0 - '@sentry-internal/replay@10.45.0': + '@sentry-internal/replay@10.46.0': dependencies: - '@sentry-internal/browser-utils': 10.45.0 - '@sentry/core': 10.45.0 + '@sentry-internal/browser-utils': 10.46.0 + '@sentry/core': 10.46.0 - '@sentry/browser@10.45.0': + '@sentry/browser@10.46.0': dependencies: - '@sentry-internal/browser-utils': 10.45.0 - '@sentry-internal/feedback': 10.45.0 - '@sentry-internal/replay': 10.45.0 - '@sentry-internal/replay-canvas': 10.45.0 - '@sentry/core': 10.45.0 + '@sentry-internal/browser-utils': 10.46.0 + '@sentry-internal/feedback': 10.46.0 + '@sentry-internal/replay': 10.46.0 + '@sentry-internal/replay-canvas': 10.46.0 + '@sentry/core': 10.46.0 - '@sentry/core@10.45.0': {} + '@sentry/core@10.46.0': {} - '@sentry/react@10.45.0(react@19.2.4)': + '@sentry/react@10.46.0(react@19.2.4)': dependencies: - '@sentry/browser': 10.45.0 - '@sentry/core': 10.45.0 + '@sentry/browser': 10.46.0 + '@sentry/core': 10.46.0 react: 19.2.4 '@sinclair/typebox@0.34.48': {} @@ -5026,11 +5026,11 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tanstack/query-core@5.91.2': {} + '@tanstack/query-core@5.95.2': {} - '@tanstack/react-query@5.91.2(react@19.2.4)': + '@tanstack/react-query@5.95.2(react@19.2.4)': dependencies: - '@tanstack/query-core': 5.91.2 + '@tanstack/query-core': 5.95.2 react: 19.2.4 '@testing-library/dom@10.4.1': @@ -5160,14 +5160,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.57.1(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.57.1 - '@typescript-eslint/type-utils': 8.57.1(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.1(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.1 + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.2 eslint: 9.39.4 ignore: 7.0.5 natural-compare: 1.4.0 @@ -5176,41 +5176,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.57.1(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.57.1 - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.1 + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.2 debug: 4.4.3 eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.57.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.57.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) - '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.57.1': + '@typescript-eslint/scope-manager@8.57.2': dependencies: - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/visitor-keys': 8.57.1 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 - '@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.57.2(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.57.1(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.57.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.4 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5218,14 +5218,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.57.1': {} + '@typescript-eslint/types@8.57.2': {} - '@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.57.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/visitor-keys': 8.57.1 + '@typescript-eslint/project-service': 8.57.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 debug: 4.4.3 minimatch: 10.2.4 semver: 7.7.4 @@ -5235,28 +5235,28 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.57.1(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/utils@8.57.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@typescript-eslint/scope-manager': 8.57.1 - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.57.1': + '@typescript-eslint/visitor-keys@8.57.2': dependencies: - '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/types': 8.57.2 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.0.1(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1))': + '@vitejs/plugin-react@6.0.1(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.4)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1) + vite: 8.0.3(@types/node@24.12.0)(esbuild@0.27.4)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1) - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@24.12.0)(jsdom@29.0.0)(lightningcss@1.32.0)(msw@2.4.11(typescript@5.9.3))(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@24.12.0)(jsdom@29.0.1)(lightningcss@1.32.0)(msw@2.4.11(typescript@5.9.3))(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -5268,7 +5268,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.1.0 - vitest: 4.0.18(@types/node@24.12.0)(jsdom@29.0.0)(lightningcss@1.32.0)(msw@2.4.11(typescript@5.9.3))(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1) + vitest: 4.0.18(@types/node@24.12.0)(jsdom@29.0.1)(lightningcss@1.32.0)(msw@2.4.11(typescript@5.9.3))(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1) '@vitest/expect@4.0.18': dependencies: @@ -5353,7 +5353,7 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 2.3.2 argparse@1.0.10: dependencies: @@ -5458,7 +5458,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.9: {} + baseline-browser-mapping@2.10.11: {} better-path-resolve@1.0.0: dependencies: @@ -5470,16 +5470,16 @@ snapshots: binary-extensions@2.3.0: {} - brace-expansion@1.1.12: + brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@2.0.3: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.4: + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -5489,9 +5489,9 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.10.9 - caniuse-lite: 1.0.30001780 - electron-to-chromium: 1.5.321 + baseline-browser-mapping: 2.10.11 + caniuse-lite: 1.0.30001781 + electron-to-chromium: 1.5.328 node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -5503,7 +5503,7 @@ snapshots: cacheable@2.3.4: dependencies: '@cacheable/memory': 2.0.8 - '@cacheable/utils': 2.4.0 + '@cacheable/utils': 2.4.1 hookified: 1.15.1 keyv: 5.6.0 qified: 0.9.0 @@ -5529,7 +5529,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001780: {} + caniuse-lite@1.0.30001781: {} chai@6.2.2: {} @@ -5722,7 +5722,7 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.321: {} + electron-to-chromium@1.5.328: {} emoji-regex@8.0.0: {} @@ -6026,13 +6026,13 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 file-entry-cache@11.1.2: dependencies: - flat-cache: 6.1.21 + flat-cache: 6.1.22 file-entry-cache@8.0.0: dependencies: @@ -6057,7 +6057,7 @@ snapshots: flatted: 3.4.2 keyv: 4.5.4 - flat-cache@6.1.21: + flat-cache@6.1.22: dependencies: cacheable: 2.3.4 flatted: 3.4.2 @@ -6206,7 +6206,7 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 - globby@16.1.1: + globby@16.2.0: dependencies: '@sindresorhus/merge-streams': 4.0.0 fast-glob: 3.3.3 @@ -6221,7 +6221,7 @@ snapshots: graceful-fs@4.2.11: {} - graphql@16.13.1: {} + graphql@16.13.2: {} has-bigints@1.1.0: {} @@ -6243,7 +6243,7 @@ snapshots: dependencies: has-symbols: 1.1.0 - hashery@1.5.0: + hashery@1.5.1: dependencies: hookified: 1.15.1 @@ -6547,7 +6547,7 @@ snapshots: '@types/stack-utils': 2.0.3 chalk: 4.1.2 graceful-fs: 4.2.11 - picomatch: 4.0.3 + picomatch: 4.0.4 pretty-format: 30.3.0 slash: 3.0.0 stack-utils: 2.0.6 @@ -6573,7 +6573,7 @@ snapshots: chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 - picomatch: 4.0.3 + picomatch: 4.0.4 jest-util@30.3.0: dependencies: @@ -6582,7 +6582,7 @@ snapshots: chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 - picomatch: 4.0.3 + picomatch: 4.0.4 js-tokens@10.0.0: {} @@ -6617,19 +6617,19 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.19.0 + ws: 8.20.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - jsdom@29.0.0: + jsdom@29.0.1: dependencies: '@asamuzakjp/css-color': 5.0.1 - '@asamuzakjp/dom-selector': 7.0.3 + '@asamuzakjp/dom-selector': 7.0.4 '@bramus/specificity': 2.4.2 - '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1) + '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1) '@exodus/bytes': 1.15.0 css-tree: 3.2.1 data-urls: 7.0.0 @@ -6641,7 +6641,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.24.5 + undici: 7.24.6 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -6806,7 +6806,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.52.0: {} @@ -6818,15 +6818,15 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.4 + brace-expansion: 5.0.5 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.13 minimatch@9.0.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.0.3 minipass@7.1.3: {} @@ -6854,7 +6854,7 @@ snapshots: '@types/cookie': 0.6.0 '@types/statuses': 2.0.6 chalk: 4.1.2 - graphql: 16.13.1 + graphql: 16.13.2 headers-polyfill: 4.0.3 is-node-process: 1.2.0 outvariant: 1.4.3 @@ -7006,7 +7006,7 @@ snapshots: path-to-regexp@6.3.0: {} - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.0: {} path-type@4.0.0: {} @@ -7014,9 +7014,9 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} pify@4.0.1: {} @@ -7148,7 +7148,7 @@ snapshots: react-is@18.3.1: {} - react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-router@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: cookie: 1.1.1 react: 19.2.4 @@ -7171,7 +7171,7 @@ snapshots: readdirp@3.6.0: dependencies: - picomatch: 2.3.1 + picomatch: 2.3.2 readdirp@4.1.2: {} @@ -7221,56 +7221,56 @@ snapshots: reusify@1.1.0: {} - rolldown@1.0.0-rc.10: + rolldown@1.0.0-rc.12: dependencies: - '@oxc-project/types': 0.120.0 - '@rolldown/pluginutils': 1.0.0-rc.10 + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.12 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.10 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.10 - '@rolldown/binding-darwin-x64': 1.0.0-rc.10 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.10 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.10 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.10 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.10 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.10 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.10 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.10 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.10 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.10 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.10 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.10 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.10 - - rollup@4.59.0: + '@rolldown/binding-android-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-x64': 1.0.0-rc.12 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + + rollup@4.60.0: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.59.0 - '@rollup/rollup-android-arm64': 4.59.0 - '@rollup/rollup-darwin-arm64': 4.59.0 - '@rollup/rollup-darwin-x64': 4.59.0 - '@rollup/rollup-freebsd-arm64': 4.59.0 - '@rollup/rollup-freebsd-x64': 4.59.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 - '@rollup/rollup-linux-arm-musleabihf': 4.59.0 - '@rollup/rollup-linux-arm64-gnu': 4.59.0 - '@rollup/rollup-linux-arm64-musl': 4.59.0 - '@rollup/rollup-linux-loong64-gnu': 4.59.0 - '@rollup/rollup-linux-loong64-musl': 4.59.0 - '@rollup/rollup-linux-ppc64-gnu': 4.59.0 - '@rollup/rollup-linux-ppc64-musl': 4.59.0 - '@rollup/rollup-linux-riscv64-gnu': 4.59.0 - '@rollup/rollup-linux-riscv64-musl': 4.59.0 - '@rollup/rollup-linux-s390x-gnu': 4.59.0 - '@rollup/rollup-linux-x64-gnu': 4.59.0 - '@rollup/rollup-linux-x64-musl': 4.59.0 - '@rollup/rollup-openbsd-x64': 4.59.0 - '@rollup/rollup-openharmony-arm64': 4.59.0 - '@rollup/rollup-win32-arm64-msvc': 4.59.0 - '@rollup/rollup-win32-ia32-msvc': 4.59.0 - '@rollup/rollup-win32-x64-gnu': 4.59.0 - '@rollup/rollup-win32-x64-msvc': 4.59.0 + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 fsevents: 2.3.3 rrweb-cssom@0.8.0: {} @@ -7586,39 +7586,39 @@ snapshots: strip-json-comments@3.1.1: {} - stylelint-config-recommended-scss@17.0.0(postcss@8.5.8)(stylelint@17.5.0(typescript@5.9.3)): + stylelint-config-recommended-scss@17.0.0(postcss@8.5.8)(stylelint@17.6.0(typescript@5.9.3)): dependencies: postcss-scss: 4.0.9(postcss@8.5.8) - stylelint: 17.5.0(typescript@5.9.3) - stylelint-config-recommended: 18.0.0(stylelint@17.5.0(typescript@5.9.3)) - stylelint-scss: 7.0.0(stylelint@17.5.0(typescript@5.9.3)) + stylelint: 17.6.0(typescript@5.9.3) + stylelint-config-recommended: 18.0.0(stylelint@17.6.0(typescript@5.9.3)) + stylelint-scss: 7.0.0(stylelint@17.6.0(typescript@5.9.3)) optionalDependencies: postcss: 8.5.8 - stylelint-config-recommended@18.0.0(stylelint@17.5.0(typescript@5.9.3)): + stylelint-config-recommended@18.0.0(stylelint@17.6.0(typescript@5.9.3)): dependencies: - stylelint: 17.5.0(typescript@5.9.3) + stylelint: 17.6.0(typescript@5.9.3) - stylelint-config-standard-scss@17.0.0(postcss@8.5.8)(stylelint@17.5.0(typescript@5.9.3)): + stylelint-config-standard-scss@17.0.0(postcss@8.5.8)(stylelint@17.6.0(typescript@5.9.3)): dependencies: - stylelint: 17.5.0(typescript@5.9.3) - stylelint-config-recommended-scss: 17.0.0(postcss@8.5.8)(stylelint@17.5.0(typescript@5.9.3)) - stylelint-config-standard: 40.0.0(stylelint@17.5.0(typescript@5.9.3)) + stylelint: 17.6.0(typescript@5.9.3) + stylelint-config-recommended-scss: 17.0.0(postcss@8.5.8)(stylelint@17.6.0(typescript@5.9.3)) + stylelint-config-standard: 40.0.0(stylelint@17.6.0(typescript@5.9.3)) optionalDependencies: postcss: 8.5.8 - stylelint-config-standard@40.0.0(stylelint@17.5.0(typescript@5.9.3)): + stylelint-config-standard@40.0.0(stylelint@17.6.0(typescript@5.9.3)): dependencies: - stylelint: 17.5.0(typescript@5.9.3) - stylelint-config-recommended: 18.0.0(stylelint@17.5.0(typescript@5.9.3)) + stylelint: 17.6.0(typescript@5.9.3) + stylelint-config-recommended: 18.0.0(stylelint@17.6.0(typescript@5.9.3)) - stylelint-order@8.1.1(stylelint@17.5.0(typescript@5.9.3)): + stylelint-order@8.1.1(stylelint@17.6.0(typescript@5.9.3)): dependencies: postcss: 8.5.8 postcss-sorting: 10.0.0(postcss@8.5.8) - stylelint: 17.5.0(typescript@5.9.3) + stylelint: 17.6.0(typescript@5.9.3) - stylelint-scss@7.0.0(stylelint@17.5.0(typescript@5.9.3)): + stylelint-scss@7.0.0(stylelint@17.6.0(typescript@5.9.3)): dependencies: css-tree: 3.2.1 is-plain-object: 5.0.0 @@ -7628,13 +7628,13 @@ snapshots: postcss-resolve-nested-selector: 0.1.6 postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - stylelint: 17.5.0(typescript@5.9.3) + stylelint: 17.6.0(typescript@5.9.3) - stylelint@17.5.0(typescript@5.9.3): + stylelint@17.6.0(typescript@5.9.3): dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1) + '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1) '@csstools/css-tokenizer': 4.0.0 '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.1) @@ -7648,12 +7648,11 @@ snapshots: fastest-levenshtein: 1.0.16 file-entry-cache: 11.1.2 global-modules: 2.0.0 - globby: 16.1.1 + globby: 16.2.0 globjoin: 0.1.4 html-tags: 5.1.0 ignore: 7.0.5 import-meta-resolve: 4.2.0 - imurmurhash: 0.1.4 is-plain-object: 5.0.0 mathml-tag-names: 4.0.0 meow: 14.1.0 @@ -7720,22 +7719,22 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinyrainbow@3.1.0: {} tldts-core@6.1.86: {} - tldts-core@7.0.26: {} + tldts-core@7.0.27: {} tldts@6.1.86: dependencies: tldts-core: 6.1.86 - tldts@7.0.26: + tldts@7.0.27: dependencies: - tldts-core: 7.0.26 + tldts-core: 7.0.27 to-regex-range@5.0.1: dependencies: @@ -7756,7 +7755,7 @@ snapshots: tough-cookie@6.0.1: dependencies: - tldts: 7.0.26 + tldts: 7.0.27 tr46@5.1.1: dependencies: @@ -7833,12 +7832,12 @@ snapshots: postcss-modules-values: 4.0.0(postcss@8.5.8) yargs: 17.7.2 - typescript-eslint@8.57.1(eslint@9.39.4)(typescript@5.9.3): + typescript-eslint@8.57.2(eslint@9.39.4)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.57.1(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: @@ -7857,7 +7856,7 @@ snapshots: undici-types@7.16.0: {} - undici@7.24.5: {} + undici@7.24.6: {} unicorn-magic@0.4.0: {} @@ -7896,10 +7895,10 @@ snapshots: vite@7.3.1(@types/node@24.12.0)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1): dependencies: esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 postcss: 8.5.8 - rollup: 4.59.0 + rollup: 4.60.0 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 24.12.0 @@ -7909,12 +7908,12 @@ snapshots: sass-embedded: 1.98.0 yaml: 2.8.1 - vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1): + vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.4)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1): dependencies: lightningcss: 1.32.0 - picomatch: 4.0.3 + picomatch: 4.0.4 postcss: 8.5.8 - rolldown: 1.0.0-rc.10 + rolldown: 1.0.0-rc.12 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 24.12.0 @@ -7924,7 +7923,7 @@ snapshots: sass-embedded: 1.98.0 yaml: 2.8.1 - vitest@4.0.18(@types/node@24.12.0)(jsdom@29.0.0)(lightningcss@1.32.0)(msw@2.4.11(typescript@5.9.3))(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1): + vitest@4.0.18(@types/node@24.12.0)(jsdom@29.0.1)(lightningcss@1.32.0)(msw@2.4.11(typescript@5.9.3))(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(msw@2.4.11(typescript@5.9.3))(vite@7.3.1(@types/node@24.12.0)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.1)) @@ -7938,7 +7937,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 1.0.4 @@ -7948,7 +7947,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.0 - jsdom: 29.0.0 + jsdom: 29.0.1 transitivePeerDependencies: - jiti - less @@ -8069,7 +8068,7 @@ snapshots: dependencies: signal-exit: 4.1.0 - ws@8.19.0: {} + ws@8.20.0: {} xml-name-validator@5.0.0: {} From 1f8061f1ca941f3fac3290ae9aeae4b71e330938 Mon Sep 17 00:00:00 2001 From: Rubin Aga <66167934+rubinaga@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:11:18 +0200 Subject: [PATCH 04/42] fix: computer alert always showing Online in single instance view (#532) ## Summary This PR covers [this Jira ticket](https://warthogs.atlassian.net/browse/LNDENG-4039) The backend was never providing an alerts array on the individual `GET /computers/:id` request. So the UI was always showing `Online` status for machines. The change was mainly backend related. Now I added a `with_alerts` flag to the endpoint to fetch that information. ## Release Impact According to the [Landscape Server Release Cycle](https://docs.google.com/document/d/1sKAp5IvArpfArhMNojFwKOHm9LEdHKB4Et6tu1_-0GY/edit?tab=t.0), this change will target the following release cycle: - **Target Branch**: `dev` / `main` (Beta) - **Version Impact**: - [x] Patch (Fix) - [ ] Minor (Feature) - [ ] Major (Breaking) ## Checklist - [x] **Changeset Added**: I have run `pnpm changeset` and committed the resulting `.md` file. - [x] **UI Verified**: I have verified the changes locally. - [x] **Linting**: No linting errors are present (especially in `scripts/`). ## Versioning Reminder > [!IMPORTANT] > This repository now uses **CalVer** ($YY.0M.Point.Patch$). > Please ensure your changeset description is clear, as it will be automatically added to the `CHANGELOG.md` upon merging to `main`. --- .changeset/itchy-bikes-give.md | 5 +++++ src/features/instances/api/useGetInstance.ts | 1 + .../SingleInstanceContainer/SingleInstanceContainer.tsx | 1 + src/tests/mocks/instance.ts | 5 +++++ 4 files changed, 12 insertions(+) create mode 100644 .changeset/itchy-bikes-give.md diff --git a/.changeset/itchy-bikes-give.md b/.changeset/itchy-bikes-give.md new file mode 100644 index 000000000..a073a4f92 --- /dev/null +++ b/.changeset/itchy-bikes-give.md @@ -0,0 +1,5 @@ +--- +"landscape-ui": patch +--- + +Fix computer alert always showing as Online in detailed instance view diff --git a/src/features/instances/api/useGetInstance.ts b/src/features/instances/api/useGetInstance.ts index 5fe1701a4..b4a671092 100644 --- a/src/features/instances/api/useGetInstance.ts +++ b/src/features/instances/api/useGetInstance.ts @@ -12,6 +12,7 @@ export interface GetInstanceParams { with_hardware?: boolean; with_network?: boolean; with_profiles?: boolean; + with_alerts?: boolean; } export const useGetInstance = ( diff --git a/src/pages/dashboard/instances/[single]/SingleInstanceContainer/SingleInstanceContainer.tsx b/src/pages/dashboard/instances/[single]/SingleInstanceContainer/SingleInstanceContainer.tsx index 50330b154..da50f98be 100644 --- a/src/pages/dashboard/instances/[single]/SingleInstanceContainer/SingleInstanceContainer.tsx +++ b/src/pages/dashboard/instances/[single]/SingleInstanceContainer/SingleInstanceContainer.tsx @@ -64,6 +64,7 @@ const SingleInstanceContainer: FC = () => { with_annotations: true, with_grouped_hardware: true, with_profiles: true, + with_alerts: true, }, { enabled: isInstanceQueryEnabled( diff --git a/src/tests/mocks/instance.ts b/src/tests/mocks/instance.ts index b246a135c..71e12922a 100644 --- a/src/tests/mocks/instance.ts +++ b/src/tests/mocks/instance.ts @@ -39,6 +39,11 @@ export const ubuntuInstance: Instance = { summary: "", severity: "info", }, + { + type: "EsmDisabledAlert", + summary: "ESM disabled", + severity: "warning", + }, ], distribution_info: { code_name: "lucid", From c407afa81039a4fec281a2780c38cfe9ced125db Mon Sep 17 00:00:00 2001 From: Rubin Aga <66167934+rubinaga@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:40:33 +0200 Subject: [PATCH 05/42] feat: improve agent instructions (#522) ## Summary Added `AGENTS.md` file to replace `copilot-instructions`. This file should be compatible with different agents, and explain our project in more detail so we can use agents to contribute faster in our codebase. ## Release Impact According to the [Landscape Server Release Cycle](https://docs.google.com/document/d/1sKAp5IvArpfArhMNojFwKOHm9LEdHKB4Et6tu1_-0GY/edit?tab=t.0), this change will target the following release cycle: - **Target Branch**: `dev` / `main` (Beta) - **Version Impact**: - [ ] Patch (Fix) - [ ] Minor (Feature) - [ ] Major (Breaking) ## Checklist - [ ] **Changeset Added**: I have run `pnpm changeset` and committed the resulting `.md` file. - [ ] **UI Verified**: I have verified the changes locally. - [ ] **Linting**: No linting errors are present (especially in `scripts/`). ## Versioning Reminder > [!IMPORTANT] > This repository now uses **CalVer** ($YY.0M.Point.Patch$). > Please ensure your changeset description is clear, as it will be automatically added to the `CHANGELOG.md` upon merging to `main`. --- .github/copilot-instructions.md | 465 -------------------------------- AGENTS.md | 53 ++++ README.md | 17 +- docs/API.md | 283 +++++++++++++++++++ docs/ARCHITECTURE.md | 88 ++++++ docs/FRONTEND.md | 164 +++++++++++ docs/index.md | 25 ++ docs/testing/coverage.md | 153 +++++++++++ docs/testing/e2e.md | 241 +++++++++++++++++ docs/testing/index.md | 22 ++ docs/testing/unit.md | 354 ++++++++++++++++++++++++ docs/verification/index.md | 66 +++++ vitest.config.ts | 6 + 13 files changed, 1470 insertions(+), 467 deletions(-) delete mode 100644 .github/copilot-instructions.md create mode 100644 AGENTS.md create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/FRONTEND.md create mode 100644 docs/index.md create mode 100644 docs/testing/coverage.md create mode 100644 docs/testing/e2e.md create mode 100644 docs/testing/index.md create mode 100644 docs/testing/unit.md create mode 100644 docs/verification/index.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index d837bdaee..000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,465 +0,0 @@ -# Landscape UI – Copilot Instructions - -## Purpose - -This file defines project context, build conventions, and review constraints for **GitHub Copilot** and related AI coding assistants. -Copilot must align with these patterns when proposing or reviewing code. - ---- - -## Repository Overview - -**Landscape UI** is Canonical’s next-generation web interface for managing Ubuntu systems. -Frontend only — communicates with Landscape API through authenticated REST endpoints. - -**Stack** - -- React 19 + TypeScript (strict mode) -- Vite 7 (build/dev server) -- PNPM for dependency management -- React Query (@tanstack/react-query) for server state -- React Router v7 for routing -- Formik + Yup for forms -- Axios (via FetchProvider) -- Vanilla Framework + @canonical/react-components for styling -- Vitest / Playwright / MSW / ESLint for quality control - ---- - -## Repository Structure - -``` -src/ - ├─ app/ # Entry point, global layout, routing - ├─ features// # Feature folders (API + components + tests) - │ ├─ api/ # React Query hooks - │ ├─ components/ # Feature components - │ ├─ types/ # Local types/interfaces - │ ├─ constants.ts - │ ├─ helpers.ts - │ └─ index.ts - ├─ context/ # Global React contexts - ├─ libs/ # Shared logic (routes, tables, utils) - ├─ components/ # Shared UI elements - ├─ hooks/ # Cross-feature custom hooks - ├─ tests/ # Test utilities and mocks - └─ styles/ # SCSS modules and Vanilla overrides -``` - -**Alias root:** `@/` → `src/` - ---- - -## Build and Run - -```bash -pnpm install --frozen-lockfile -cp .env.local.example .env.local -pnpm dev # Vite dev server (5173) -pnpm build # Lint + typecheck + Vite build -pnpm vitest # Unit/integration tests -pnpm test # Playwright E2E -``` - ---- - -## Architectural Invariants - -Copilot must **not** propose changes that violate these rules. - -| Area | Rule | -| ----------------- | ------------------------------------------------------------------------ | -| **Imports** | Use `@/` alias only. No deep imports into `features/*/*`. | -| **API calls** | Must go through React Query hooks in each feature’s `api/`. | -| **State** | Local state: React hooks. Server state: React Query. No Redux/MobX. | -| **Forms** | Always use Formik + Yup. | -| **Styling** | Vanilla Framework classes for layout; component styles use SCSS modules. | -| **Testing** | Use Vitest + React Testing Library. Mock network via MSW. | -| **Auth** | Only `useAuth()` / `FetchProvider` manage authentication. | -| **Routing** | Routes defined in `src/libs/routes`. Type-safe generation only. | -| **Feature Flags** | Access via `useAuth().isFeatureEnabled(key)`. | -| **Build Output** | Generated `dist/` is gitignored. Never edit manually. | - ---- - -## Copilot Behavior Rules - -### 1. Code Generation - -Copilot should: - -- Use **existing hooks/components** before suggesting new ones. -- Prefer **composition** over duplication. -- Propose typed React Query hooks that adapt the raw API response into a simple, usable object (e.g., return `{ scripts, count, isLoading }` instead of the raw data object), matching the example pattern. -- Generate **async/await** API calls wrapped in `try/catch`. -- Always infer `type` imports (`import type`) for TS interfaces. -- Respect ESLint and Prettier configs from root. - -### 2. Review Feedback Expectations - -When analyzing PRs, Copilot should: - -- Check for architectural invariant violations above. -- Warn if new code bypasses providers (`useAuth`, `FetchProvider`, `NotifyProvider`). -- Verify consistent naming (`PascalCase` for components, `camelCase` for helpers). -- Flag untested UI or logic. -- Ensure proper semantic HTML (` + ); + }; + return ( <> = ({ instance }) => { /> - ) : ( - - ) - } + value={getProfilesValue()} type="truncated" /> {getFeatures(instance).employees && ( @@ -599,63 +547,11 @@ const InfoPanel: FC = ({ instance }) => { {isRestartModalOpen && ( - handleFormSubmit("reboot")} - > - handleFormSubmit("reboot")} noValidate> - - -

This will restart "{instance.title}" instance.

- -
+ )} {isShutDownModalOpen && ( - handleFormSubmit("shutdown")} - > -
handleFormSubmit("shutdown")} noValidate> - - -

This will shut down "{instance.title}" instance.

- -
+ )} {disassociateModalOpen && ( diff --git a/src/pages/dashboard/instances/[single]/tabs/info/InfoPanel/constants.ts b/src/pages/dashboard/instances/[single]/tabs/info/InfoPanel/constants.ts deleted file mode 100644 index 79c1f6bd9..000000000 --- a/src/pages/dashboard/instances/[single]/tabs/info/InfoPanel/constants.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ModalConfirmationFormProps } from "./types"; -import * as Yup from "yup"; -import moment from "moment"; - -export const INITIAL_VALUES: ModalConfirmationFormProps = { - deliver_after: "", - deliverImmediately: true, - action: null, -}; - -export const VALIDATION_SCHEMA = Yup.object().shape({ - deliver_after: Yup.string().when("deliverImmediately", { - is: false, - then: (schema) => - schema - .required("This field is required") - .test({ - message: "Invalid date", - test: (value) => moment(value).isValid(), - }) - .test({ - message: "Date must be in the future", - test: (value) => moment(value).isAfter(), - }), - }), - deliverImmediately: Yup.boolean(), - action: Yup.mixed().oneOf(["shutdown", "reboot"]).required(), -}); From f5bcbba6ab63c411c72e83397c307079aef8c57e Mon Sep 17 00:00:00 2001 From: Rubin Aga <66167934+rubinaga@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:54:14 +0200 Subject: [PATCH 09/42] fix: adjust z-index values for side panel and dropdown elements (#538) ## Summary This PR is related to [this Jira ticket](https://warthogs.atlassian.net/browse/LNDENG-4050) Additionally it fixes this UI bug that is not shown in that ticket: image ## Release Impact According to the [Landscape Server Release Cycle](https://docs.google.com/document/d/1sKAp5IvArpfArhMNojFwKOHm9LEdHKB4Et6tu1_-0GY/edit?tab=t.0), this change will target the following release cycle: - **Target Branch**: `dev` / `main` (Beta) - **Version Impact**: - [x] Patch (Fix) - [ ] Minor (Feature) - [ ] Major (Breaking) ## Checklist - [x] **Changeset Added**: I have run `pnpm changeset` and committed the resulting `.md` file. - [x] **UI Verified**: I have verified the changes locally. - [x] **Linting**: No linting errors are present (especially in `scripts/`). ## Versioning Reminder > [!IMPORTANT] > This repository now uses **CalVer** ($YY.0M.Point.Patch$). > Please ensure your changeset description is clear, as it will be automatically added to the `CHANGELOG.md` upon merging to `main`. --- .changeset/soft-lemons-train.md | 5 +++++ src/components/layout/SidePanel/SidePanel.module.scss | 1 + .../SearchBoxWithSavedSearches.module.scss | 1 + src/styles/partials/elevation.scss | 1 + src/styles/partials/layout.scss | 2 +- 5 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .changeset/soft-lemons-train.md diff --git a/.changeset/soft-lemons-train.md b/.changeset/soft-lemons-train.md new file mode 100644 index 000000000..8a311d728 --- /dev/null +++ b/.changeset/soft-lemons-train.md @@ -0,0 +1,5 @@ +--- +"landscape-ui": patch +--- + +Fix bug causing saved searches dropdown to appear above wide sidepanels and modals diff --git a/src/components/layout/SidePanel/SidePanel.module.scss b/src/components/layout/SidePanel/SidePanel.module.scss index 1ccd6e99a..8979a5283 100644 --- a/src/components/layout/SidePanel/SidePanel.module.scss +++ b/src/components/layout/SidePanel/SidePanel.module.scss @@ -10,6 +10,7 @@ flex-direction: column; overflow: visible; padding-bottom: 0; + z-index: var(--z-sidepanel); } .medium { diff --git a/src/features/saved-searches/components/SearchBoxWithSavedSearches/SearchBoxWithSavedSearches.module.scss b/src/features/saved-searches/components/SearchBoxWithSavedSearches/SearchBoxWithSavedSearches.module.scss index 44ac4b9d9..19c0439b8 100644 --- a/src/features/saved-searches/components/SearchBoxWithSavedSearches/SearchBoxWithSavedSearches.module.scss +++ b/src/features/saved-searches/components/SearchBoxWithSavedSearches/SearchBoxWithSavedSearches.module.scss @@ -8,6 +8,7 @@ & + :global(.p-search-and-filter__panel) { background-color: $colors--theme--background-default; + z-index: var(--z-page-dropdown); } } diff --git a/src/styles/partials/elevation.scss b/src/styles/partials/elevation.scss index 809adef05..2db17991a 100644 --- a/src/styles/partials/elevation.scss +++ b/src/styles/partials/elevation.scss @@ -6,6 +6,7 @@ --z-table-corner: 250; --z-sticky: 300; --z-application: 350; + --z-page-dropdown: 375; --z-sidepanel: 400; --z-navigation: 450; --z-dropdown: 500; diff --git a/src/styles/partials/layout.scss b/src/styles/partials/layout.scss index 0e52ad776..df135b6aa 100644 --- a/src/styles/partials/layout.scss +++ b/src/styles/partials/layout.scss @@ -19,7 +19,7 @@ } .l-application & { - z-index: var(--z-application); + z-index: var(--z-sidepanel); } } From 0dbe79d5faaabac08d212251e218813c5357892b Mon Sep 17 00:00:00 2001 From: Rubin Aga <66167934+rubinaga@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:55:13 +0200 Subject: [PATCH 10/42] refactor: replace spinners with inline loading states across components (#539) ## Summary This item is related to [this Jira ticket](https://warthogs.atlassian.net/browse/LNDENG-4081). Moving all loading elements to use `LoadingState` (with inline or without it) makes it easier to maintain in the codebase. This is just a refactor and doesnt introduce new behavior ## Release Impact According to the [Landscape Server Release Cycle](https://docs.google.com/document/d/1sKAp5IvArpfArhMNojFwKOHm9LEdHKB4Et6tu1_-0GY/edit?tab=t.0), this change will target the following release cycle: - **Target Branch**: `dev` / `main` (Beta) - **Version Impact**: - [ ] Patch (Fix) - [ ] Minor (Feature) - [ ] Major (Breaking) ## Checklist - [ ] **Changeset Added**: I have run `pnpm changeset` and committed the resulting `.md` file. - [x] **UI Verified**: I have verified the changes locally. - [x] **Linting**: No linting errors are present (especially in `scripts/`). ## Versioning Reminder > [!IMPORTANT] > This repository now uses **CalVer** ($YY.0M.Point.Patch$). > Please ensure your changeset description is clear, as it will be automatically added to the `CHANGELOG.md` upon merging to `main`. --- src/components/layout/LoadingState/LoadingState.tsx | 2 +- src/components/layout/Redirecting/Redirecting.test.tsx | 9 ++------- src/components/layout/Redirecting/Redirecting.tsx | 6 +++--- .../AccessGroupInstanceCountCell.tsx | 9 ++------- .../handle/oidc/OidcAuthPage/OidcAuthPage.module.scss | 6 ++++++ src/pages/auth/handle/oidc/OidcAuthPage/OidcAuthPage.tsx | 6 +++--- .../UbuntuOneAuthPage/UbuntuOneAuthPage.module.scss | 6 ++++++ .../ubuntu-one/UbuntuOneAuthPage/UbuntuOneAuthPage.tsx | 6 +++--- .../instances/[single]/SingleInstanceTabs/helpers.tsx | 5 +++-- 9 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/components/layout/LoadingState/LoadingState.tsx b/src/components/layout/LoadingState/LoadingState.tsx index f4780e634..cf1b5b2ff 100644 --- a/src/components/layout/LoadingState/LoadingState.tsx +++ b/src/components/layout/LoadingState/LoadingState.tsx @@ -17,7 +17,7 @@ const LoadingState: FC = ({ centerOnScreen, inline }) => { ); if (inline) { - return
{spinningElement}
; + return {spinningElement}; } return ( diff --git a/src/components/layout/Redirecting/Redirecting.test.tsx b/src/components/layout/Redirecting/Redirecting.test.tsx index 7e0b1fe54..da0db8a01 100644 --- a/src/components/layout/Redirecting/Redirecting.test.tsx +++ b/src/components/layout/Redirecting/Redirecting.test.tsx @@ -6,12 +6,7 @@ describe("Redirecting", () => { render(); expect(screen.getByRole("status")).toBeInTheDocument(); - - const labelSpans = screen.getAllByText("Redirecting..."); - - expect(labelSpans).toHaveLength(2); - - expect(labelSpans[0]).toBeOffScreen(); - expect(labelSpans[1]).not.toBeOffScreen(); + expect(screen.getByText("Loading...")).toBeOffScreen(); + expect(screen.getByText("Redirecting...")).not.toBeOffScreen(); }); }); diff --git a/src/components/layout/Redirecting/Redirecting.tsx b/src/components/layout/Redirecting/Redirecting.tsx index 2259ec19a..dfb7d962c 100644 --- a/src/components/layout/Redirecting/Redirecting.tsx +++ b/src/components/layout/Redirecting/Redirecting.tsx @@ -1,12 +1,12 @@ +import LoadingState from "../LoadingState"; import type { FC } from "react"; import classes from "./Redirecting.module.scss"; const Redirecting: FC = () => { return (
- - Redirecting... - + + Redirecting...
diff --git a/src/features/access-groups/components/AccessGroupInstanceCountCell/AccessGroupInstanceCountCell.tsx b/src/features/access-groups/components/AccessGroupInstanceCountCell/AccessGroupInstanceCountCell.tsx index c401c7bec..0e29925ea 100644 --- a/src/features/access-groups/components/AccessGroupInstanceCountCell/AccessGroupInstanceCountCell.tsx +++ b/src/features/access-groups/components/AccessGroupInstanceCountCell/AccessGroupInstanceCountCell.tsx @@ -1,6 +1,6 @@ +import LoadingState from "@/components/layout/LoadingState"; import { useGetInstances } from "@/features/instances"; import { pluralizeWithCount } from "@/utils/_helpers"; -import { Spinner } from "@canonical/react-components"; import { type FC } from "react"; import { Link } from "react-router"; import type { AccessGroupWithInstancesCount } from "../../types/AccessGroup"; @@ -21,12 +21,7 @@ const AccessGroupInstanceCountCell: FC = ({ }); if (isGettingInstances) { - return ( - <> - Loading... - - - ); + return ; } if (instancesCount) { diff --git a/src/pages/auth/handle/oidc/OidcAuthPage/OidcAuthPage.module.scss b/src/pages/auth/handle/oidc/OidcAuthPage/OidcAuthPage.module.scss index 0638bbe7a..01ca6971c 100644 --- a/src/pages/auth/handle/oidc/OidcAuthPage/OidcAuthPage.module.scss +++ b/src/pages/auth/handle/oidc/OidcAuthPage/OidcAuthPage.module.scss @@ -1,6 +1,12 @@ +@import "vanilla-framework/scss/settings_spacing"; + .container { align-items: center; display: flex; height: 100vh; justify-content: center; } + +.loading { + margin-right: $sph--large; +} diff --git a/src/pages/auth/handle/oidc/OidcAuthPage/OidcAuthPage.tsx b/src/pages/auth/handle/oidc/OidcAuthPage/OidcAuthPage.tsx index 2e3827d95..ece3e4ff8 100644 --- a/src/pages/auth/handle/oidc/OidcAuthPage/OidcAuthPage.tsx +++ b/src/pages/auth/handle/oidc/OidcAuthPage/OidcAuthPage.tsx @@ -6,6 +6,7 @@ import { GENERIC_DOMAIN, HOMEPAGE_PATH, } from "@/constants"; +import LoadingState from "@/components/layout/LoadingState"; import { useGetOidcAuth } from "@/features/auth"; import useAuth from "@/hooks/useAuth"; import useEnv from "@/hooks/useEnv"; @@ -85,9 +86,8 @@ const OidcAuthPage: FC = () => {
{isLoading ? (
- - Loading... - + + Please wait while your request is being processed...
diff --git a/src/pages/auth/handle/ubuntu-one/UbuntuOneAuthPage/UbuntuOneAuthPage.module.scss b/src/pages/auth/handle/ubuntu-one/UbuntuOneAuthPage/UbuntuOneAuthPage.module.scss index 0638bbe7a..01ca6971c 100644 --- a/src/pages/auth/handle/ubuntu-one/UbuntuOneAuthPage/UbuntuOneAuthPage.module.scss +++ b/src/pages/auth/handle/ubuntu-one/UbuntuOneAuthPage/UbuntuOneAuthPage.module.scss @@ -1,6 +1,12 @@ +@import "vanilla-framework/scss/settings_spacing"; + .container { align-items: center; display: flex; height: 100vh; justify-content: center; } + +.loading { + margin-right: $sph--large; +} diff --git a/src/pages/auth/handle/ubuntu-one/UbuntuOneAuthPage/UbuntuOneAuthPage.tsx b/src/pages/auth/handle/ubuntu-one/UbuntuOneAuthPage/UbuntuOneAuthPage.tsx index 43c94cd65..fc9eab922 100644 --- a/src/pages/auth/handle/ubuntu-one/UbuntuOneAuthPage/UbuntuOneAuthPage.tsx +++ b/src/pages/auth/handle/ubuntu-one/UbuntuOneAuthPage/UbuntuOneAuthPage.tsx @@ -6,6 +6,7 @@ import { GENERIC_DOMAIN, HOMEPAGE_PATH, } from "@/constants"; +import LoadingState from "@/components/layout/LoadingState"; import useAuth from "@/hooks/useAuth"; import useEnv from "@/hooks/useEnv"; import { useGetStandaloneAccount } from "@/features/account-creation"; @@ -74,9 +75,8 @@ const UbuntuOneAuthPage: FC = () => {
{isLoading ? (
- - Loading... - + + Please wait while your request is being processed...
diff --git a/src/pages/dashboard/instances/[single]/SingleInstanceTabs/helpers.tsx b/src/pages/dashboard/instances/[single]/SingleInstanceTabs/helpers.tsx index c64248b54..624808d1c 100644 --- a/src/pages/dashboard/instances/[single]/SingleInstanceTabs/helpers.tsx +++ b/src/pages/dashboard/instances/[single]/SingleInstanceTabs/helpers.tsx @@ -1,7 +1,8 @@ +import LoadingState from "@/components/layout/LoadingState"; import { getFeatures } from "@/features/instances"; import useAuth from "@/hooks/useAuth"; import type { Instance } from "@/types/Instance"; -import { Badge, Spinner } from "@canonical/react-components"; +import { Badge } from "@canonical/react-components"; interface GetTabLabelProps { id: string; @@ -32,7 +33,7 @@ const getTabLabel = ({ return ( <> {label} - + ); } From 59d9619eec2353acf5e197ae702a364d264a625a Mon Sep 17 00:00:00 2001 From: Yurii Vasyliev Date: Tue, 14 Apr 2026 16:43:14 +0200 Subject: [PATCH 11/42] docs: update agents.md --- AGENTS.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 42fc698c0..3f5d5f6a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,11 +43,23 @@ The knowledge base lives under `docs/` plus established root documents such as ` Package manager is `pnpm`. Do not use `npm` or `yarn`. ``` -pnpm dev # start the Vite dev server -pnpm vitest # run unit and component tests (Vitest) -pnpm run lint # run ESLint with auto-fix -pnpm build # production build (lint + tsc + vite build) -pnpm test # run Playwright E2E tests (not unit tests) +pnpm dev # start the Vite dev server +pnpm vitest # run unit and component tests (Vitest) +pnpm vitest MyComponent # run tests matching a name pattern +pnpm vitest --reporter=verbose # run with detailed per-test output +pnpm coverage # run Vitest with coverage report +pnpm run lint # run ESLint with auto-fix +pnpm build # production build (lint + tsc + vite build) +pnpm test # run Playwright E2E tests (not unit tests) +pnpm test:saas # run only SaaS-tagged E2E tests +pnpm test:self-hosted # run only self-hosted-tagged E2E tests +pnpm changeset # create a changeset for changelog (required before merge) ``` Note: `pnpm test` runs Playwright, not Vitest. Use `pnpm vitest` for unit and component test work. + +## Environment Setup + +Copy `.env.local.example` to `.env.local` and fill in values for your local Landscape instance. Required variables include `VITE_API_URL`, `VITE_API_URL_OLD`, and `VITE_ROOT_PATH`. Set `VITE_MSW_ENABLED=true` to use Mock Service Worker for offline development. See `.env.local.example` for the full list. + +Node.js ≥24 is required (`engines` in `package.json`). From e2253b7ee3fcfb694c23505d3539a08395914ba6 Mon Sep 17 00:00:00 2001 From: Marc Bucchieri Date: Tue, 14 Apr 2026 09:39:14 -0700 Subject: [PATCH 12/42] fix: organisation switch updates correctly in the UI (#550) ## Summary Previously, switching the user's organization via the OrganisationSwitch component wouldn't update in the UI until the user navigated to a new page, or refreshed the page. Fixed by keeping authUser in the cache before refetching queries. ## Release Impact According to the [Landscape Server Release Cycle](https://docs.google.com/document/d/1sKAp5IvArpfArhMNojFwKOHm9LEdHKB4Et6tu1_-0GY/edit?tab=t.0), this change will target the following release cycle: - **Target Branch**: `dev` / `main` (Beta) - **Version Impact**: - [X] Patch (Fix) - [ ] Minor (Feature) - [ ] Major (Breaking) ## Checklist - [X] **Changeset Added**: I have run `pnpm changeset` and committed the resulting `.md` file. - [X] **UI Verified**: I have verified the changes locally. - [X] **Linting**: No linting errors are present (especially in `scripts/`). ## Versioning Reminder > [!IMPORTANT] > This repository now uses **CalVer** ($YY.0M.Point.Patch$). > Please ensure your changeset description is clear, as it will be automatically added to the `CHANGELOG.md` upon merging to `main`. --- .changeset/green-icons-beam.md | 5 ++ src/features/auth/hooks/useAuthHandle.ts | 4 +- .../OrganisationSwitch.integration.test.tsx | 46 +++++++++++++++++++ .../OrganisationSwitch.test.tsx | 1 + src/tests/mocks/auth.ts | 8 ++++ src/tests/mocks/user.ts | 6 ++- 6 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 .changeset/green-icons-beam.md create mode 100644 src/templates/dashboard/OrganisationSwitch/OrganisationSwitch.integration.test.tsx diff --git a/.changeset/green-icons-beam.md b/.changeset/green-icons-beam.md new file mode 100644 index 000000000..e5d4869fa --- /dev/null +++ b/.changeset/green-icons-beam.md @@ -0,0 +1,5 @@ +--- +"landscape-ui": patch +--- + +Fix organization switching by more precise cache invalidation diff --git a/src/features/auth/hooks/useAuthHandle.ts b/src/features/auth/hooks/useAuthHandle.ts index a048a8e3c..094efaf0c 100644 --- a/src/features/auth/hooks/useAuthHandle.ts +++ b/src/features/auth/hooks/useAuthHandle.ts @@ -128,7 +128,9 @@ export default function useAuthHandle() { >({ mutationFn: async (params) => authFetch.post("switch-account", params), onSuccess: async () => { - queryClient.removeQueries(); + queryClient.removeQueries({ + predicate: (query) => query.queryKey[0] !== "authUser", + }); return queryClient.refetchQueries(); }, }); diff --git a/src/templates/dashboard/OrganisationSwitch/OrganisationSwitch.integration.test.tsx b/src/templates/dashboard/OrganisationSwitch/OrganisationSwitch.integration.test.tsx new file mode 100644 index 000000000..bef77843b --- /dev/null +++ b/src/templates/dashboard/OrganisationSwitch/OrganisationSwitch.integration.test.tsx @@ -0,0 +1,46 @@ +import { API_URL } from "@/constants"; +import useSidePanel from "@/hooks/useSidePanel"; +import { authResponse } from "@/tests/mocks/auth"; +import { renderWithProviders } from "@/tests/render"; +import server from "@/tests/server"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { beforeEach } from "vitest"; +import OrganisationSwitch from "./OrganisationSwitch"; + +vi.mock("@/hooks/useSidePanel"); +const closeSidePanel = vi.fn(); + +describe("OrganisationSwitch (integration)", () => { + beforeEach(() => { + vi.mocked(useSidePanel, { partial: true }).mockReturnValue({ + closeSidePanel, + }); + + server.use(http.get(`${API_URL}me`, () => HttpResponse.json(authResponse))); + }); + + it("should update the displayed organisation after account switch", async () => { + renderWithProviders(); + + // Wait for the real auth context to fully load from MSW GET /me. + // authResponse has current_account = "test-account". + await waitFor(() => { + expect( + screen.getByRole("combobox", { name: /organization/i }), + ).toHaveValue("test-account"); + }); + + await userEvent.selectOptions( + screen.getByRole("combobox", { name: /organization/i }), + "second-account", + ); + + await waitFor(() => { + expect( + screen.getByRole("combobox", { name: /organization/i }), + ).toHaveValue("second-account"); + }); + }); +}); diff --git a/src/templates/dashboard/OrganisationSwitch/OrganisationSwitch.test.tsx b/src/templates/dashboard/OrganisationSwitch/OrganisationSwitch.test.tsx index be0aeb40c..254634928 100644 --- a/src/templates/dashboard/OrganisationSwitch/OrganisationSwitch.test.tsx +++ b/src/templates/dashboard/OrganisationSwitch/OrganisationSwitch.test.tsx @@ -88,6 +88,7 @@ describe("OrganisationSwitch", () => { expect(select).toHaveValue(defaultAccounts.currentAccount.name); }); + // only tests function call, TODO check UI change after account switch as well it("should change the organisation and close side panel", async () => { await userEvent.selectOptions( screen.getByRole("combobox"), diff --git a/src/tests/mocks/auth.ts b/src/tests/mocks/auth.ts index e620f5371..b2fd26f8c 100644 --- a/src/tests/mocks/auth.ts +++ b/src/tests/mocks/auth.ts @@ -1,6 +1,7 @@ import type { AuthStateResponse, AuthUser } from "@/features/auth"; const testAccount = "test-account"; +const secondAccount = "second-account"; export const authUser: AuthUser = { accounts: [ @@ -11,6 +12,13 @@ export const authUser: AuthUser = { subdomain: null, title: "Test Account", }, + { + classic_dashboard_url: "", + default: false, + name: secondAccount, + subdomain: null, + title: "Second Account", + }, ], current_account: testAccount, email: "example@mail.com", diff --git a/src/tests/mocks/user.ts b/src/tests/mocks/user.ts index ffbcc6a0b..57a502efd 100644 --- a/src/tests/mocks/user.ts +++ b/src/tests/mocks/user.ts @@ -89,13 +89,17 @@ export const users = [ ] as const satisfies User[]; const accountName = "test-account"; +const secondAccountName = "second-account"; const email = "example@mail.com"; export const userDetails: UserDetails = { email: email, name: "Test User", preferred_account: accountName, - accounts: [{ name: accountName, title: "Test Account", roles: ["Test"] }], + accounts: [ + { name: accountName, title: "Test Account", roles: ["Test"] }, + { name: secondAccountName, title: "Second Account", roles: ["Test"] }, + ], allowable_emails: [email], oidc_identities: [], timezone: "America/New_York", From 3378fa666b5911d542a62fc0a4dee8cea2a25322 Mon Sep 17 00:00:00 2001 From: Yurii Vasyliev <113896226+yurii-vasyliev@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:34:30 +0200 Subject: [PATCH 13/42] build: extend release process with point releases (#553) This pull request introduces support for "point" branches, enabling the creation and deployment of pinned beta releases based on specific commits and cherry-picked fixes. The changes update the release workflow, versioning logic, and documentation to clarify how and when to use these point releases. **Release workflow and branching strategy updates:** * The `.github/workflows/release-and-build.yml` workflow now triggers on pushes to `point/**` branches and treats them as beta releases, deploying to the `ppa-build` target. [[1]](diffhunk://#diff-f2835b43bdce313fb566e57a07425b551df5a05cf100af870ab5779713ed3659L5-R5) [[2]](diffhunk://#diff-f2835b43bdce313fb566e57a07425b551df5a05cf100af870ab5779713ed3659L32-R32) * The `scripts/calculate-version.cjs` script is updated to generate beta version numbers for `point/` branches, matching the format used for `main`. **Documentation improvements:** * `RELEASES.md` now documents the new `point/YYYY-MM-DD` branch type, its purpose, and how it fits into the overall branching strategy. * A detailed example workflow ("Example C: Shipping a Pinned Beta") is added to `RELEASES.md`, explaining how to create, use, and retire point branches for targeted beta releases.## Summary Summarize the changes made in this pull request. Include any relevant context or background information that would help reviewers understand the purpose and scope of the changes. ## Release Impact According to the [Landscape Server Release Cycle This pull request introduces support for "point" branches, which enable the creation of pinned beta releases based on specific commits and cherry-picked fixes. The changes update the branching strategy documentation, CI workflow, and version calculation logic to recognize and handle `point/YYYY-MM-DD` branches as beta releases. This allows for more flexible and controlled beta releases without disrupting ongoing development on `main`. **Branching and Release Process Updates:** * Updated the documented branching strategy in `RELEASES.md` to include `point/YYYY-MM-DD` as a new branch type for pinned beta releases, clarifying its purpose and deployment target. * Added a detailed example workflow in `RELEASES.md` for creating and managing point releases, including branch creation, cherry-picking, and cleanup instructions. **CI/CD and Versioning Enhancements:** * Modified the GitHub Actions workflow in `.github/workflows/release-and-build.yml` to trigger on pushes to `point/**` branches, ensuring CI builds are run for point releases. * Updated the build destination logic in the workflow so that builds from `main` or any `point/**` branch are deployed to the `ppa-build` beta channel. * Adjusted the version calculation script (`scripts/calculate-version.cjs`) to treat `point/` branches like `main`, assigning them a beta version suffix.](https://docs.google.com/document/d/1sKAp5IvArpfArhMNojFwKOHm9LEdHKB4Et6tu1_-0GY/edit?tab=t.0), this change will target the following release cycle: - **Target Branch**: `dev` / `main` (Beta) - **Version Impact**: - [ ] Patch (Fix) - [ ] Minor (Feature) - [ ] Major (Breaking) ## Checklist - [ ] **Changeset Added**: I have run `pnpm changeset` and committed the resulting `.md` file. - [ ] **UI Verified**: I have verified the changes locally. - [ ] **Linting**: No linting errors are present (especially in `scripts/`). ## Versioning Reminder > [!IMPORTANT] > This repository now uses **CalVer** ($YY.0M.Point.Patch$). > Please ensure your changeset description is clear, as it will be automatically added to the `CHANGELOG.md` upon merging to `main`. --- .github/workflows/release-and-build.yml | 4 +-- RELEASES.md | 45 +++++++++++++++++++++---- scripts/calculate-version.cjs | 2 +- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release-and-build.yml b/.github/workflows/release-and-build.yml index 50223ff1b..fa284fe7c 100644 --- a/.github/workflows/release-and-build.yml +++ b/.github/workflows/release-and-build.yml @@ -2,7 +2,7 @@ name: Release and PPA Build on: push: - branches: [main, dev, stable, "release/**"] + branches: [main, dev, stable, "release/**", "point/**"] permissions: contents: write @@ -29,7 +29,7 @@ jobs: VERSION=$(node scripts/calculate-version.cjs) echo "version=$VERSION" >> $GITHUB_OUTPUT - if [[ $GITHUB_REF == 'refs/heads/main' ]]; then DEST="ppa-build"; + if [[ $GITHUB_REF == 'refs/heads/main' || $GITHUB_REF == refs/heads/point/* ]]; then DEST="ppa-build"; elif [[ $GITHUB_REF == 'refs/heads/dev' ]]; then DEST="ppa-build-dev"; elif [[ $GITHUB_REF == 'refs/heads/stable' ]]; then DEST="ppa-build-stable"; else DEST="ppa-build-${GITHUB_REF##*/}"; fi diff --git a/RELEASES.md b/RELEASES.md index 27c4fb578..a2f7aa8ca 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -6,12 +6,13 @@ Landscape UI follows the [Landscape Server Release Cycle](https://docs.google.co ## 1. Branching Strategy -| Branch | Release Tier | Logic | -|-----------------|-------------------|--------------------------------------------------------------------------| -| `dev` | **Development** | Internal testing. Deploys to `ppa-build-dev`. | -| `main` | **Beta** | Feature-complete but may have breaking changes. Deploys to `ppa-build`. | -| `stable` | **Latest Stable** | Production-ready with latest features. Updated every 6 months. | -| `release/YY.04` | **LTS** | Mission-critical stability. | +| Branch | Release Tier | Logic | +|---------------------|-------------------|--------------------------------------------------------------------------| +| `dev` | **Development** | Internal testing. Deploys to `ppa-build-dev`. | +| `main` | **Beta** | Feature-complete but may have breaking changes. Deploys to `ppa-build`. | +| `point/YYYY-MM-DD` | **Beta (pinned)** | Cherry-picked beta from a pinned commit. Deploys to `ppa-build`. | +| `stable` | **Latest Stable** | Production-ready with latest features. Updated every 6 months. | +| `release/YY.04` | **LTS** | Mission-critical stability. | --- @@ -85,6 +86,38 @@ Use this workflow if a customer reports a critical bug in an LTS version (e.g., 6. **Push:** Push directly to `release/24.04`. 7. **Result:** The CI detects the LTS branch and generates a **Point Release** (e.g., $24.04.1.15$) for the specific LTS PPA. +--- + +### Example C: Shipping a Pinned Beta (Target: Point Release) + +Use this workflow when `main` has moved ahead with new features or breaking changes, but you need to ship a beta with only specific fixes on top of an older, known-good commit. + +1. **Identify the base commit:** Find the last commit you want to build from (e.g., `07bb3c298`). +2. **Create the point branch:** + ```bash + git checkout -b point/2026-04-14 07bb3c298 + ``` +3. **Cherry-pick the changes you need:** + ```bash + git cherry-pick + git cherry-pick + ``` +4. **Generate Changeset:** Run `pnpm changeset`. +5. **Select Type:** Choose `patch`. +6. **Write Summary:** `Point release with alert fix and MSW refactor.` +7. **Push:** Push to `point/2026-04-14`. +8. **Result:** The CI treats this as a beta and deploys to `ppa-build` (e.g., `26.04.0.63-beta`). + +**Adding more changes later:** Push additional cherry-picks to the same `point/` branch. Each push triggers a new CI run with the next `GITHUB_RUN_NUMBER`. + +**When main catches up:** Delete the point branch once `main` is ready to resume normal beta releases: + +```bash +git push origin --delete point/2026-04-14 +``` + +**Multiple point releases per month:** Use the date suffix to keep them unique (e.g., `point/2026-04-14`, `point/2026-04-28`). + ## 6. Troubleshooting ### Problem: A PR was merged without a Changeset diff --git a/scripts/calculate-version.cjs b/scripts/calculate-version.cjs index 644cd4350..a2fa3e186 100644 --- a/scripts/calculate-version.cjs +++ b/scripts/calculate-version.cjs @@ -11,7 +11,7 @@ function getVersion() { const branch = execSync("git rev-parse --abbrev-ref HEAD").toString().trim(); const buildNum = process.env.GITHUB_RUN_NUMBER || "0"; - if (branch === "main") { + if (branch === "main" || branch.startsWith("point/")) { return `${calVerBase}.0.${buildNum}-beta`; } if (branch === "dev") { From 808e3f509b4385d7a932cf59d77bf0f17c7bf7a9 Mon Sep 17 00:00:00 2001 From: Yurii Vasyliev Date: Tue, 14 Apr 2026 19:46:06 +0200 Subject: [PATCH 14/42] workflows: adjust ppa build commit message --- .github/workflows/release-and-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-and-build.yml b/.github/workflows/release-and-build.yml index fa284fe7c..ec499fbff 100644 --- a/.github/workflows/release-and-build.yml +++ b/.github/workflows/release-and-build.yml @@ -78,4 +78,4 @@ jobs: branch: ${{ steps.calver.outputs.dest_branch }} create_branch: true push_options: "--force" - commit_message: "Build v${{ steps.calver.outputs.version }} [${{ github.sha }}]" + commit_message: "Build v${{ steps.calver.outputs.version }} from ${{ github.ref_name }} [${{ github.sha }}]" From d8c568f27772cdead25e68b8e6cdeaf1d6c8b758 Mon Sep 17 00:00:00 2001 From: Yurii Vasyliev Date: Tue, 14 Apr 2026 20:17:45 +0200 Subject: [PATCH 15/42] workflows: extend CI with building deb package artifact --- .github/workflows/release-and-build.yml | 55 +++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/.github/workflows/release-and-build.yml b/.github/workflows/release-and-build.yml index ec499fbff..98043b355 100644 --- a/.github/workflows/release-and-build.yml +++ b/.github/workflows/release-and-build.yml @@ -11,6 +11,9 @@ permissions: jobs: process-release: runs-on: ubuntu-latest + outputs: + version: ${{ steps.calver.outputs.version }} + dest_branch: ${{ steps.calver.outputs.dest_branch }} steps: - uses: actions/checkout@v5 with: { fetch-depth: 0 } @@ -57,6 +60,13 @@ jobs: VITE_API_URL_OLD: /api/ VITE_ROOT_PATH: /new_dashboard/ + - name: Upload dist artifact + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + retention-days: 5 + - name: Prepare PPA Branch run: | # Create a temporary directory to hold build artifacts @@ -79,3 +89,48 @@ jobs: create_branch: true push_options: "--force" commit_message: "Build v${{ steps.calver.outputs.version }} from ${{ github.ref_name }} [${{ github.sha }}]" + + build-deb: + needs: process-release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + sparse-checkout: debian + + - name: Download dist artifact + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Install packaging tools + run: sudo apt-get update && sudo apt-get install -y debhelper devscripts + + - name: Build .deb package + env: + DEBFULLNAME: Landscape Team + DEBEMAIL: landscape-team@canonical.com + VERSION: ${{ needs.process-release.outputs.version }} + run: | + # Update debian/changelog with the build version + dch --newversion "${VERSION}-0landscape0" \ + --distribution jammy \ + "Release ${VERSION} for jammy" + + # Create the orig tarball from the pre-built dist + cd dist + tar czf "../../landscape-dashboard_${VERSION}.orig.tar.gz" \ + assets favicon.svg index.html + cd .. + + # Build the binary .deb (unsigned) + dpkg-buildpackage -us -uc -b + + - name: Upload .deb artifact + uses: actions/upload-artifact@v4 + with: + name: landscape-dashboard-deb + path: ../landscape-dashboard_*.deb + retention-days: 30 + From 6a64f73836e7d489ff8767d4fbf0310101fc3747 Mon Sep 17 00:00:00 2001 From: Yurii Vasyliev Date: Tue, 14 Apr 2026 20:22:57 +0200 Subject: [PATCH 16/42] workflows: fix build-dev job --- .github/workflows/release-and-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-and-build.yml b/.github/workflows/release-and-build.yml index 98043b355..91f2fadd5 100644 --- a/.github/workflows/release-and-build.yml +++ b/.github/workflows/release-and-build.yml @@ -105,7 +105,7 @@ jobs: path: dist/ - name: Install packaging tools - run: sudo apt-get update && sudo apt-get install -y debhelper devscripts + run: sudo apt-get update && sudo apt-get install -y build-essential debhelper devscripts - name: Build .deb package env: From 090993533c9e6be2f6400c9d1c52b05ee7098608 Mon Sep 17 00:00:00 2001 From: Yurii Vasyliev Date: Wed, 15 Apr 2026 11:07:24 +0200 Subject: [PATCH 17/42] workflows: fix build-dev job, attempt 2 --- .github/workflows/release-and-build.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-and-build.yml b/.github/workflows/release-and-build.yml index 91f2fadd5..01d89f57f 100644 --- a/.github/workflows/release-and-build.yml +++ b/.github/workflows/release-and-build.yml @@ -113,16 +113,20 @@ jobs: DEBEMAIL: landscape-team@canonical.com VERSION: ${{ needs.process-release.outputs.version }} run: | + # Move pre-built files to root level (matching ppa-build layout + # that debian/rules expects: assets/, index.html, favicon.svg at root) + cp -r dist/assets . + cp dist/index.html dist/favicon.svg . + rm -rf dist + # Update debian/changelog with the build version dch --newversion "${VERSION}-0landscape0" \ --distribution jammy \ "Release ${VERSION} for jammy" - # Create the orig tarball from the pre-built dist - cd dist - tar czf "../../landscape-dashboard_${VERSION}.orig.tar.gz" \ + # Create the orig tarball + tar czf "../landscape-dashboard_${VERSION}.orig.tar.gz" \ assets favicon.svg index.html - cd .. # Build the binary .deb (unsigned) dpkg-buildpackage -us -uc -b From 35549b44906fa8dd6d68bb4b0315ddcccb6815b7 Mon Sep 17 00:00:00 2001 From: Yurii Vasyliev Date: Wed, 15 Apr 2026 11:19:14 +0200 Subject: [PATCH 18/42] workflows: fix build-dev job, attempt 3 --- .github/workflows/release-and-build.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-and-build.yml b/.github/workflows/release-and-build.yml index 01d89f57f..4c41ef5d8 100644 --- a/.github/workflows/release-and-build.yml +++ b/.github/workflows/release-and-build.yml @@ -131,10 +131,13 @@ jobs: # Build the binary .deb (unsigned) dpkg-buildpackage -us -uc -b + # Copy .deb into workspace (upload-artifact doesn't allow ../ paths) + cp ../landscape-dashboard_*.deb . + - name: Upload .deb artifact uses: actions/upload-artifact@v4 with: name: landscape-dashboard-deb - path: ../landscape-dashboard_*.deb + path: landscape-dashboard_*.deb retention-days: 30 From ddbc50777d6dca39f9837db44586cd5b635fd99d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:17:06 +0200 Subject: [PATCH 19/42] Version Packages (26.04.0.70-beta) (#544) This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and publish to npm yourself or [setup this action to publish automatically](https://github.com/changesets/action#with-publishing). If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## landscape-ui@26.04.0.70 # Landscape Lifecycle Transition (February 2026) _Note: starting from this point, Landscape UI follows the Landscape Server Release Specification (CalVer YY.MM.Point.Patch)._ * * * # [1.23.0](https://github.com/canonical/landscape-ui/compare/v1.22.0...v1.23.0) (2026-01-08) ### Bug Fixes - add missing ubuntu pro info ([#426](https://github.com/canonical/landscape-ui/issues/426)) ([425b1c7](https://github.com/canonical/landscape-ui/commit/425b1c7496ac5a32a7c8f2c149c63d8a401a8bc1)) - change cve links to ubuntu.com ([#384](https://github.com/canonical/landscape-ui/issues/384)) ([fabf4d0](https://github.com/canonical/landscape-ui/commit/fabf4d029516aaade9096cef992464afec02e290)) - Instance page filters changes ([#432](https://github.com/canonical/landscape-ui/issues/432)) ([57914ed](https://github.com/canonical/landscape-ui/commit/57914ede35259904104f4fc6fd9add1deee67c01)) - prevent submitting login form twice if error occurs ([#438](https://github.com/canonical/landscape-ui/issues/438)) ([5b1fbd9](https://github.com/canonical/landscape-ui/commit/5b1fbd912a8c0190c5cac2aa19c105f07fd09b23)) - remove access group field from edit mode in profiles ([#434](https://github.com/canonical/landscape-ui/issues/434)) ([00ec586](https://github.com/canonical/landscape-ui/commit/00ec5861a3f5a29955b14362e04c8207fea091ff)) - revert TagMultiSelect component ([#424](https://github.com/canonical/landscape-ui/issues/424)) ([c132b71](https://github.com/canonical/landscape-ui/commit/c132b711b4b17f0282dc7abe53ae9e3249f2a602)) - wsl profile remove tags ([#407](https://github.com/canonical/landscape-ui/issues/407)) ([776ab31](https://github.com/canonical/landscape-ui/commit/776ab31b3bdac8a2b23d32c560d99d472d1b5a16)) ### Features - add associated instance links for upgrade and removal profiles ([#404](https://github.com/canonical/landscape-ui/issues/404)) ([e8a92d7](https://github.com/canonical/landscape-ui/commit/e8a92d72170ea67116f739ecb708436a07f1825d)) - add warnings for wsl profiles ([#416](https://github.com/canonical/landscape-ui/issues/416)) ([b2e3efe](https://github.com/canonical/landscape-ui/commit/b2e3efec0ffb4d2ced83d090a4c100a59692cd4a)) - allow wsl profiles to use only_landscape_created ([#401](https://github.com/canonical/landscape-ui/issues/401)) ([99b5ea0](https://github.com/canonical/landscape-ui/commit/99b5ea0b10eebe031c77f2d9cacb5ee8fb57b2dd)) - improve saved searches user experience ([#430](https://github.com/canonical/landscape-ui/issues/430)) ([c63c1a5](https://github.com/canonical/landscape-ui/commit/c63c1a583edd4df42142302ce460bce5a3926dcf)) - rename instance statuses ([#417](https://github.com/canonical/landscape-ui/issues/417)) ([0324c54](https://github.com/canonical/landscape-ui/commit/0324c542eca091c70f3fb01bde00d9161301e2f9)) - update confirmation modal for deleting access group ([#431](https://github.com/canonical/landscape-ui/issues/431)) ([3ac0310](https://github.com/canonical/landscape-ui/commit/3ac03106be0ee94ac93682a20d1929d4d2596e10)) # [1.22.0](https://github.com/canonical/landscape-ui/compare/v1.21.0...v1.22.0) (2025-10-24) ### Bug Fixes - pnpm-lock ([b43af61](https://github.com/canonical/landscape-ui/commit/b43af61d11662bc4c9055f2aee6b6dbb4893d151)) - prevent infinite request loop in security profiles page ([#394](https://github.com/canonical/landscape-ui/issues/394)) ([2be0483](https://github.com/canonical/landscape-ui/commit/2be04834f84b1fdbf61d1b179a5cd997863d4a95)) ### Features - implement pro token attach and detach ([#395](https://github.com/canonical/landscape-ui/issues/395)) ([d3995f9](https://github.com/canonical/landscape-ui/commit/d3995f937eb6431b1464eee70368d2bdb19dc332)) # [1.21.0](https://github.com/canonical/landscape-ui/compare/v1.20.0...v1.21.0) (2025-10-14) ### Bug Fixes - revert ppa build action ([#391](https://github.com/canonical/landscape-ui/issues/391)) ([5e25323](https://github.com/canonical/landscape-ui/commit/5e25323883366f1b9d19643e43ab16333e38baea)) ### Features - implement account creation and invitation page ([#390](https://github.com/canonical/landscape-ui/issues/390)) ([4654a00](https://github.com/canonical/landscape-ui/commit/4654a0068b8376a897c5c4630ed3d6d71b44d58b)) # [1.20.0](https://github.com/canonical/landscape-ui/compare/v1.19.0...v1.20.0) (2025-10-09) ### Bug Fixes - delete wsl children ([#388](https://github.com/canonical/landscape-ui/issues/388)) ([454c770](https://github.com/canonical/landscape-ui/commit/454c770462c684fa2fd572c1525ed635a475c29c)) ### Features - packages tab empty state ([#380](https://github.com/canonical/landscape-ui/issues/380)) ([da17f13](https://github.com/canonical/landscape-ui/commit/da17f13aed705706be3a981f27fba31f9e886af2)) - update feedback link ([a471849](https://github.com/canonical/landscape-ui/commit/a471849d2005a4ba1b4bf90df743731653437cac)) # [1.19.0](https://github.com/canonical/landscape-ui/compare/v1.18.2...v1.19.0) (2025-09-16) ### Features - associated apt sources can be deleted ([#346](https://github.com/canonical/landscape-ui/issues/346)) ([b37dd2b](https://github.com/canonical/landscape-ui/commit/b37dd2b5618d50a11672a610420435e184ce1937)) ## [1.18.2](https://github.com/canonical/landscape-ui/compare/v1.18.1...v1.18.2) (2025-09-12) ### Bug Fixes - instances can run scripts with a timeout ([#352](https://github.com/canonical/landscape-ui/issues/352)) ([895032f](https://github.com/canonical/landscape-ui/commit/895032f6473796264d1107781ed0ea18f8f8e29b)) - wsl profile expandable cells can open individually [LNDENG-3116]([#351](https://github.com/canonical/landscape-ui/issues/351)) ([a86705b](https://github.com/canonical/landscape-ui/commit/a86705b112781b6283bd8910949ee1d37ae6efd5)) ## [1.18.1](https://github.com/canonical/landscape-ui/compare/v1.18.0...v1.18.1) (2025-09-03) ### Bug Fixes - remove autoinstall file field from employees ([#344](https://github.com/canonical/landscape-ui/issues/344)) ([27d6ef5](https://github.com/canonical/landscape-ui/commit/27d6ef56018a32c54230036b016d43a2b55cb507)) # [1.18.0](https://github.com/canonical/landscape-ui/compare/v1.17.0...v1.18.0) (2025-08-29) ### Bug Fixes - disable root_only for instances ([#319](https://github.com/canonical/landscape-ui/issues/319)) ([1d4109e](https://github.com/canonical/landscape-ui/commit/1d4109e12c8c40388ebc5ef8b77ddb2f544619ab)) - submit button in multi select dropdown footer not working ([#327](https://github.com/canonical/landscape-ui/issues/327)) ([364f914](https://github.com/canonical/landscape-ui/commit/364f914f3832f0074b07c50bc207f5b75fe52012)) ### Features - add hostname and tags to instances list ([#333](https://github.com/canonical/landscape-ui/issues/333)) ([3d99f1b](https://github.com/canonical/landscape-ui/commit/3d99f1bafbd5c618481fd17fee27af546e861e3d)) - add profile links [LNDENG-2865]([#309](https://github.com/canonical/landscape-ui/issues/309)) ([8238e8f](https://github.com/canonical/landscape-ui/commit/8238e8ff293963c36b9198be23bed050933ba8ce)) # [1.17.0](https://github.com/canonical/landscape-ui/compare/v1.16.0...v1.17.0) (2025-08-05) ### Bug Fixes - show correct duplicates count along with pending instances ([#308](https://github.com/canonical/landscape-ui/issues/308)) ([239205b](https://github.com/canonical/landscape-ui/commit/239205ba2f0e7bf8e6971029ceca40e4230afaaf)) - show loading state while loading a page for the first time ([#312](https://github.com/canonical/landscape-ui/issues/312)) ([30cd296](https://github.com/canonical/landscape-ui/commit/30cd2969fb72140940a14b66004f5449ed182fc5)) ### Features - compliance checks for WSL profiles ([#285](https://github.com/canonical/landscape-ui/issues/285)) ([bdcaa0f](https://github.com/canonical/landscape-ui/commit/bdcaa0fc4d6214d0c00daf715a5ab8c337b7d086)) - implement attach page ([#300](https://github.com/canonical/landscape-ui/issues/300)) ([170576f](https://github.com/canonical/landscape-ui/commit/170576f45ba3d47ef94f27a9d4fed19b5111300a)) - make authentication experience changes ([#299](https://github.com/canonical/landscape-ui/issues/299)) ([9220dec](https://github.com/canonical/landscape-ui/commit/9220dec72e88f86ade707a65e7ff1863fb0bfd76)) - minor wsl changes ([#318](https://github.com/canonical/landscape-ui/issues/318)) ([ec6040f](https://github.com/canonical/landscape-ui/commit/ec6040feb2a90bc1b6da20f4030d15be3d6f581d)) # [1.16.0](https://github.com/canonical/landscape-ui/compare/v1.15.0...v1.16.0) (2025-07-22) ### Bug Fixes - prevent api credentials page from going beyond the bottom ([dfce7ab](https://github.com/canonical/landscape-ui/commit/dfce7ab0f4926b392f821c0dc4843c00f56d9d48)) - provide correct 'copy_from' param when duplicating a package profile ([99420c8](https://github.com/canonical/landscape-ui/commit/99420c881e9610ef23d2574f8bfff64bb76df850)) - removal profiles and upgrade profiles can be edited [LNDENG-2858]([#303](https://github.com/canonical/landscape-ui/issues/303)) ([771248b](https://github.com/canonical/landscape-ui/commit/771248b198f61945af1ff72592541f77f2b37c3c)) ### Features - use new endpoint for tags modal ([#298](https://github.com/canonical/landscape-ui/issues/298)) ([5e503c1](https://github.com/canonical/landscape-ui/commit/5e503c1055caa97d4698e5db11b76605d8e226ba)) # [1.15.0](https://github.com/canonical/landscape-ui/compare/v1.14.1...v1.15.0) (2025-07-21) ### Bug Fixes - allow copy-and-paste in text confirmation modals ([#302](https://github.com/canonical/landscape-ui/issues/302)) ([5fc76ff](https://github.com/canonical/landscape-ui/commit/5fc76ff6bd75c673adc90280da84f98ce767ea1b)) - remove extra security profile api call ([#296](https://github.com/canonical/landscape-ui/issues/296)) ([5c57469](https://github.com/canonical/landscape-ui/commit/5c574697e75b37b8e02728e90593c71f57045f30)) - show scripts warning ([#293](https://github.com/canonical/landscape-ui/issues/293)) ([3ae8b19](https://github.com/canonical/landscape-ui/commit/3ae8b1964b0ab705ffcade72280912ae72e3e261)) ### Features - add dark mode ([#297](https://github.com/canonical/landscape-ui/issues/297)) ([7b584ef](https://github.com/canonical/landscape-ui/commit/7b584ef12dccb258b224a6dce10ca5058de245c3)) - add disa stig consent banner when enabled ([#295](https://github.com/canonical/landscape-ui/issues/295)) ([455e3eb](https://github.com/canonical/landscape-ui/commit/455e3ebdff6ffefc3ede58c35e538ff9c5400c58)) - add support provider login page ([#294](https://github.com/canonical/landscape-ui/issues/294)) ([527292e](https://github.com/canonical/landscape-ui/commit/527292e671deb911b440acaf00b2d06cf7c730a9)) ## [1.14.1](https://github.com/canonical/landscape-ui/compare/v1.14.0...v1.14.1) (2025-07-01) ### Bug Fixes - show scripts warning ([#293](https://github.com/canonical/landscape-ui/issues/293)) ([6a96a14](https://github.com/canonical/landscape-ui/commit/6a96a141320b2dd95a32a7fe0291ddf11a7336b3)) # [1.14.0](https://github.com/canonical/landscape-dashboard/compare/v1.13.2...v1.14.0) (2025-06-19) ### Bug Fixes - make instance table filters responsive ([#283](https://github.com/canonical/landscape-dashboard/issues/283)) ([9996c1e](https://github.com/canonical/landscape-dashboard/commit/9996c1e5decd8b9f6280095039d2bf834399377c)) - script profile forms can show errors ([#282](https://github.com/canonical/landscape-dashboard/issues/282)) ([f01b673](https://github.com/canonical/landscape-dashboard/commit/f01b67312523a6adfaf0e96c7bd1e985b222a314)) ### Features - add associated instances links for profiles ([#281](https://github.com/canonical/landscape-dashboard/issues/281)) ([2d1be1b](https://github.com/canonical/landscape-dashboard/commit/2d1be1bade6e6c9c0c34167abd3fa174944e9a7a)) ## [1.13.2](https://github.com/canonical/landscape-dashboard/compare/v1.13.1...v1.13.2) (2025-06-16) ### Bug Fixes - add missing toast notifications, add actions to activities panel ([#265](https://github.com/canonical/landscape-dashboard/issues/265)) ([77d2ffa](https://github.com/canonical/landscape-dashboard/commit/77d2ffa8c8ae38dc56d379c7271309d3473181df)) - align custom table filters action functionalities ([#257](https://github.com/canonical/landscape-dashboard/issues/257)) ([91fe46a](https://github.com/canonical/landscape-dashboard/commit/91fe46a20df71741ab2fa873e567d8d5e1952371)) - allow empty array params in legacy API ([4d28411](https://github.com/canonical/landscape-dashboard/commit/4d28411b33985fbcd521bcec0efb58a3b5f928db)) - applying monospacing to table column dates ([#269](https://github.com/canonical/landscape-dashboard/issues/269)) ([1c7b748](https://github.com/canonical/landscape-dashboard/commit/1c7b7480973d03013ffc979aea3ce44f003c92af)) - change instance tabs depending on distributor [LNDENG-2513]([#272](https://github.com/canonical/landscape-dashboard/issues/272)) ([4ec5881](https://github.com/canonical/landscape-dashboard/commit/4ec5881440eabcbafce78ac7c3c681197ceccf34)) - clear selection on filter change ([#271](https://github.com/canonical/landscape-dashboard/issues/271)) ([ab8602b](https://github.com/canonical/landscape-dashboard/commit/ab8602b15337cbf9549c9b90aa45fec797ebb5f4)) - close single filter when clicking any option ([e50c565](https://github.com/canonical/landscape-dashboard/commit/e50c565715bdb3d3c1427361d5939257eb783933)) - disable invalid activity actions ([#248](https://github.com/canonical/landscape-dashboard/issues/248)) ([6ee0fa9](https://github.com/canonical/landscape-dashboard/commit/6ee0fa9b4a31e6cdecef0b681f15e0fdd7216eac)) - invalid livepatch expiry date ([#267](https://github.com/canonical/landscape-dashboard/issues/267)) ([e60eecc](https://github.com/canonical/landscape-dashboard/commit/e60eecc4886ecfc2399ef32d63475cf678e4ad2c)) - last audit's passrate bar ([#266](https://github.com/canonical/landscape-dashboard/issues/266)) ([11a5c72](https://github.com/canonical/landscape-dashboard/commit/11a5c724e94b04e061c4dd5735747b3a97af9f67)) - make tables responsive ([#280](https://github.com/canonical/landscape-dashboard/issues/280)) ([89ea1d9](https://github.com/canonical/landscape-dashboard/commit/89ea1d9db8541220b1435448891b44f2ccbb7a11)) - make tags cell expandable for each profile ([#261](https://github.com/canonical/landscape-dashboard/issues/261)) ([89c8ae1](https://github.com/canonical/landscape-dashboard/commit/89c8ae1eb031b2a8d6237dfe40229aed8d80612d)) - overview buttons ([#255](https://github.com/canonical/landscape-dashboard/issues/255)) ([7925995](https://github.com/canonical/landscape-dashboard/commit/7925995f5e722254984015e68b9d58b2f654c374)) - remove errors when logging in ([#246](https://github.com/canonical/landscape-dashboard/issues/246)) ([0b34ab8](https://github.com/canonical/landscape-dashboard/commit/0b34ab8a1cb2a925c7465aac8342a78dde319d8b)) - responsive issues with button groups, sidebar and overview page ([#277](https://github.com/canonical/landscape-dashboard/issues/277)) ([6eee676](https://github.com/canonical/landscape-dashboard/commit/6eee676be8cd41f67683a822e7ce9d3d210607b9)) - set minimum interval for security profile ([#274](https://github.com/canonical/landscape-dashboard/issues/274)) ([ba8107b](https://github.com/canonical/landscape-dashboard/commit/ba8107b06b72f631d68cc8a565de6d3ffd1d675c)) - show correct status for archived instances ([#247](https://github.com/canonical/landscape-dashboard/issues/247)) ([d9b7251](https://github.com/canonical/landscape-dashboard/commit/d9b7251c257fa015367c6748a9b50648b7ccd6cb)) - show Identity Providers in sidebar when OIDC is available ([4bfa6de](https://github.com/canonical/landscape-dashboard/commit/4bfa6dee96ca1220f7367a031a7bc7d264c95134)) - truncated cell shows count when it shouldn't, adjust truncated info item ([#270](https://github.com/canonical/landscape-dashboard/issues/270)) ([81ce636](https://github.com/canonical/landscape-dashboard/commit/81ce6365de52f8578ba560adaca768394bdb989d)) - wrong ubuntu pro info date showing on non-ubuntu linux instances ([#275](https://github.com/canonical/landscape-dashboard/issues/275)) ([13e091b](https://github.com/canonical/landscape-dashboard/commit/13e091bab316adcb0fc872ad00e090397faf9ba7)) ## [1.13.1](https://github.com/canonical/landscape-dashboard/compare/v1.13.0...v1.13.1) (2025-05-15) ### Bug Fixes - add empty state to instance hardware panel ([55fbf2d](https://github.com/canonical/landscape-dashboard/commit/55fbf2d5cb9d16bf640dc5a964f4b892bb482eb9)) - make forms in modals submittable by pressing enter ([c6e6c96](https://github.com/canonical/landscape-dashboard/commit/c6e6c96e042f4747c91b308045c969ea90ea5c44)) - remove access group select, filter by access group ([#241](https://github.com/canonical/landscape-dashboard/issues/241)) ([cdf185e](https://github.com/canonical/landscape-dashboard/commit/cdf185e8fc9118d6377fd1a21f5356ba7f587ae0)) - restore APT sources for SaaS environment ([da81756](https://github.com/canonical/landscape-dashboard/commit/da81756b1188cdd82662b1c75e83e9a1c27590bd)) - security profile audits ([43eaf7b](https://github.com/canonical/landscape-dashboard/commit/43eaf7b22cda63bb09833188aaff959204446f44)) - security profile time zones and schedule, extra info in requests, add tests ([4679e89](https://github.com/canonical/landscape-dashboard/commit/4679e89786b9a9473876ce341bf428c606a90017)) # [1.13.0](https://github.com/canonical/landscape-dashboard/compare/v1.12.5...v1.13.0) (2025-04-29) ### Bug Fixes - adding contextual menu to Scripts ([#215](https://github.com/canonical/landscape-dashboard/issues/215)) ([967ef4a](https://github.com/canonical/landscape-dashboard/commit/967ef4aa3f59403132d96fd1d7808e23810000f6)) - google auth provider icon ([1ba2a4b](https://github.com/canonical/landscape-dashboard/commit/1ba2a4bb910e17a228d176efba16e5864a2ea70c)) - move security issues table pagination to bottom ([#231](https://github.com/canonical/landscape-dashboard/issues/231)) ([6c608b6](https://github.com/canonical/landscape-dashboard/commit/6c608b6e467788115ef34d13667e5613ebc0623c)) - production bug during login ([51e64ab](https://github.com/canonical/landscape-dashboard/commit/51e64abce6aa8ef8a9d261f219b15cf30665c592)) - redirect on refresh ([#210](https://github.com/canonical/landscape-dashboard/issues/210)) ([ea42ad8](https://github.com/canonical/landscape-dashboard/commit/ea42ad86ceea64828f4a79215dca0ba18f21b4ae)) - upgrade and downgrade kernel forms not scheduling correctly ([79b1f85](https://github.com/canonical/landscape-dashboard/commit/79b1f85b9fdc951e1f97079643f32b5abd9d3bbb)) ### Features - add archived status in instances page ([bca548a](https://github.com/canonical/landscape-dashboard/commit/bca548adb11d3fe690aefad7d5f52270e1757169)) - add employees page; add sanitize action to single instance; add identity issuers ([96a9275](https://github.com/canonical/landscape-dashboard/commit/96a9275859b008743ec318e6f57a0c74660dbc26)) - add reboot profiles ([#102](https://github.com/canonical/landscape-dashboard/issues/102)) ([c437490](https://github.com/canonical/landscape-dashboard/commit/c4374903c7356b8120559d3c26713aecb8ac10f7)) - add security profiles ([a5e77a2](https://github.com/canonical/landscape-dashboard/commit/a5e77a2de26c0bce4106c9c3e5d204cb3a1e34c6)) - implement event based script execution ([9067514](https://github.com/canonical/landscape-dashboard/commit/90675142c11409616ee25c5fd82d9a886f28b542)) - implement feature availability checks to trigger some UI elements visibility ([614e98b](https://github.com/canonical/landscape-dashboard/commit/614e98bdb777e38a6bafbe5d68a0c748feaac998)) - remove sentry plugin ([5d7f700](https://github.com/canonical/landscape-dashboard/commit/5d7f70068665d097b2d74bc54ae395deb8ce4198)) ## [1.12.5](https://github.com/canonical/landscape-dashboard/compare/v1.12.4...v1.12.5) (2025-03-05) ### Bug Fixes - diff pull pocket crash ([#199](https://github.com/canonical/landscape-dashboard/issues/199)) ([6bb4076](https://github.com/canonical/landscape-dashboard/commit/6bb4076ef46875e82b7fc374d43124111e9129e9)) - make apt sources pages available for self-hosted env only ([27ac98f](https://github.com/canonical/landscape-dashboard/commit/27ac98f6ee783f4915cb10f3be10ee8f56fc2572)) - make apt sources pages available for self-hosted env only ([#201](https://github.com/canonical/landscape-dashboard/issues/201)) ([bfb8b30](https://github.com/canonical/landscape-dashboard/commit/bfb8b3008e20bd3184d0af2ad9c4ee60712cd469)) ## [1.12.4](https://github.com/canonical/landscape-dashboard/compare/v1.12.3...v1.12.4) (2025-02-12) ### Bug Fixes - enable instance upgrades button without upgrade count ([#188](https://github.com/canonical/landscape-dashboard/issues/188)) ([0f7a8b9](https://github.com/canonical/landscape-dashboard/commit/0f7a8b9ec13def86a261fc23f300812b9e7fbb21)) - prevent page from crashing when subdomain account is default one ([b74b8a9](https://github.com/canonical/landscape-dashboard/commit/b74b8a90cd75ddde215bffe12b7d2afc515fd381)) ## [1.12.3](https://github.com/canonical/landscape-dashboard/compare/v1.12.2...v1.12.3) (2025-02-06) ### Bug Fixes - display all instance packages ([#185](https://github.com/canonical/landscape-dashboard/issues/185)) ([58789e2](https://github.com/canonical/landscape-dashboard/commit/58789e203458a5cbaeb26ee2cecaafd16d89f07c)) ## [1.12.2](https://github.com/canonical/landscape-dashboard/compare/v1.12.1...v1.12.2) (2025-02-05) ### Bug Fixes - table pagination disappearing briefly when going to next page on instances page ([c92e7db](https://github.com/canonical/landscape-dashboard/commit/c92e7dba6eb488f858965c7e293acedbcd801788)) ## [1.12.1](https://github.com/canonical/landscape-dashboard/compare/v1.12.0...v1.12.1) (2025-02-05) ### Bug Fixes - activities page header help icon misalignment ([#181](https://github.com/canonical/landscape-dashboard/issues/181)) ([ee9c985](https://github.com/canonical/landscape-dashboard/commit/ee9c98544b4de37f198e80503687dd946005d3fc)) - remove instance upgrades column ([#183](https://github.com/canonical/landscape-dashboard/issues/183)) ([807ff6c](https://github.com/canonical/landscape-dashboard/commit/807ff6cd3b844838d300a171bdb9fd0b6970fcbf)) - show features for ubuntu core instances ([#182](https://github.com/canonical/landscape-dashboard/issues/182)) ([c2c156f](https://github.com/canonical/landscape-dashboard/commit/c2c156fd9035dbbf83075343e96612eb37a57dbf)) # [1.12.0](https://github.com/canonical/landscape-dashboard/compare/v1.11.1...v1.12.0) (2025-02-03) ### Bug Fixes - change events days filter to display label, remove events table sorting, change "events log message" to "message" ([7d02a6b](https://github.com/canonical/landscape-dashboard/commit/7d02a6bc7b32067c33ff1c50b31569eacc29af8a)) - disable option to run scripts on windows instances ([#152](https://github.com/canonical/landscape-dashboard/issues/152)) ([e3dae02](https://github.com/canonical/landscape-dashboard/commit/e3dae02be0936d78b2d1f5353ad1bda9b71d4856)) - improve page number input validation ([#175](https://github.com/canonical/landscape-dashboard/issues/175)) ([1272a8a](https://github.com/canonical/landscape-dashboard/commit/1272a8a6ff4b356cc453c975d07e6011221fcaf2)) - modal closing when trying to remove saved search ([cd98b10](https://github.com/canonical/landscape-dashboard/commit/cd98b105a3a7a481cba37b46be9db426e01ec393)) - prevent double redirection while signing in ([e90e0f1](https://github.com/canonical/landscape-dashboard/commit/e90e0f18011100aa0e8de087799a035c2aa86bb6)) - remove '\\r' characters from script code if present ([#167](https://github.com/canonical/landscape-dashboard/issues/167)) ([b4314c4](https://github.com/canonical/landscape-dashboard/commit/b4314c4425689476d2e29e7b6388c2676f8c6922)) ### Features - add ability to assign tags to the selected instances ([#166](https://github.com/canonical/landscape-dashboard/issues/166)) ([c95e517](https://github.com/canonical/landscape-dashboard/commit/c95e517dd26909ba19a9edb20c18d32d6c3d0ae3)) - add error boundaries, connect Sentry service ([a6fd328](https://github.com/canonical/landscape-dashboard/commit/a6fd3287e21a2fba8cdc1f87f3964194e9c77bb8)) - add search chip to all pages with search, add query param validation ([9d77f75](https://github.com/canonical/landscape-dashboard/commit/9d77f75fa8744eeeb5b4fc647b9736039f9f8747)) - add table sorting to url ([15d094f](https://github.com/canonical/landscape-dashboard/commit/15d094fb69b6309980d59d8fb367bd9a821a8264)) ## [1.11.1](https://github.com/canonical/landscape-dashboard/compare/v1.11.0...v1.11.1) (2025-01-07) ### Bug Fixes - update react-components to 1.7.3 ([#153](https://github.com/canonical/landscape-dashboard/issues/153)) ([88b14fd](https://github.com/canonical/landscape-dashboard/commit/88b14fdf969651756c34c7fba6503a77a49afffd)) # [1.11.0](https://github.com/canonical/landscape-dashboard/compare/v1.10.0...v1.11.0) (2024-12-16) ### Bug Fixes - add overview page lazy queries ([4375ebd](https://github.com/canonical/landscape-dashboard/commit/4375ebd33692ee736d5069ee76023c58c3a799ab)) - broken link in alerts notification page ([2b9504f](https://github.com/canonical/landscape-dashboard/commit/2b9504f02ed4f4ab21ad038af087d0011be2e5ea)) - change HTTP method to change organisation preferences ([868a812](https://github.com/canonical/landscape-dashboard/commit/868a812f9f35ceedfc7052b242cb5bd3dedd808f)) - remove access group dropdown from edit package profile form ([#155](https://github.com/canonical/landscape-dashboard/issues/155)) ([053e5b4](https://github.com/canonical/landscape-dashboard/commit/053e5b444cca5e6fb583e35bdc1045656e832067)) - show empty cell in activity table when activity has no related instance ([23ae288](https://github.com/canonical/landscape-dashboard/commit/23ae2888d8d17b3c3e6a174796fb4b3aaef5ecf6)) - unify empty state in profiles empty state ([5845267](https://github.com/canonical/landscape-dashboard/commit/584526757f52a7051a4a398290127e2bcdcb8796)) ### Features - add instances column to access groups ([#137](https://github.com/canonical/landscape-dashboard/issues/137)) ([9632989](https://github.com/canonical/landscape-dashboard/commit/96329896fb79bda873e5773b94afa3c77b7e8021)) - change event log page filter ([#139](https://github.com/canonical/landscape-dashboard/issues/139)) ([ace8ab6](https://github.com/canonical/landscape-dashboard/commit/ace8ab602c4cde7952a9f5108fedd11f75c2df37)) - hide 'View report' button on instances page ([2d762ad](https://github.com/canonical/landscape-dashboard/commit/2d762adeca680e30cb8974db5700af70da83a690)) - show organisation label instead of switch if user has only one ([#147](https://github.com/canonical/landscape-dashboard/issues/147)) ([0539efa](https://github.com/canonical/landscape-dashboard/commit/0539efac3190a831a2646d95211bbf7ff0d3f6cb)) # [1.10.0](https://github.com/canonical/landscape-dashboard/compare/v1.9.1...v1.10.0) (2024-12-05) ### Bug Fixes - add loading state during first render ([5928f61](https://github.com/canonical/landscape-dashboard/commit/5928f61bb7357617d28a1fee40e8861142464351)) - instance distribution info can be null ([#142](https://github.com/canonical/landscape-dashboard/issues/142)) ([8536044](https://github.com/canonical/landscape-dashboard/commit/853604488bbce947b7ef80f25a56797066f19a9b)) ### Features - add search as a filter chip ([e6c118c](https://github.com/canonical/landscape-dashboard/commit/e6c118c501630f7149adadf27fcdd496e77d3f67)) - add welcome popup and info badge into sidebar ([#144](https://github.com/canonical/landscape-dashboard/issues/144)) ([0115e73](https://github.com/canonical/landscape-dashboard/commit/0115e7377459d6d7a5549ce29645dcd9a4bd281c)) ## [1.9.1](https://github.com/canonical/landscape-dashboard/compare/v1.9.0...v1.9.1) (2024-12-02) ### Bug Fixes - add component to render while redirecting ([89c8640](https://github.com/canonical/landscape-dashboard/commit/89c86409d42070dfdd61f44c519eee88e3d12f1a)) # [1.9.0](https://github.com/canonical/landscape-dashboard/compare/v1.8.1...v1.9.0) (2024-11-25) ### Bug Fixes - change instance column filter ([f81e6b8](https://github.com/canonical/landscape-dashboard/commit/f81e6b822e6b40f11938b35fd57753eb241b5a0e)) - encode redirection path URL param ([2f31515](https://github.com/canonical/landscape-dashboard/commit/2f31515cd4b22bc0fb17cf567fe63e7c3683072f)) ### Features - add ability to assign access group for multiple instances ([#138](https://github.com/canonical/landscape-dashboard/issues/138)) ([a899f6d](https://github.com/canonical/landscape-dashboard/commit/a899f6d0a43007cff190d5e1fe63d46f3660f03e)) - add activities page filters ([#132](https://github.com/canonical/landscape-dashboard/issues/132)) ([e38c721](https://github.com/canonical/landscape-dashboard/commit/e38c721efadd136f300cc77e6bf4bf2a4161f6d3)) - add version number to sidebar ([7de1392](https://github.com/canonical/landscape-dashboard/commit/7de1392de85b148a16af0fa68a2c4b071395e6d0)) ## [1.8.1](https://github.com/canonical/landscape-dashboard/compare/v1.8.0...v1.8.1) (2024-11-20) ### Bug Fixes - add default supported provider ([aaece65](https://github.com/canonical/landscape-dashboard/commit/aaece65773c0337076ff610dd59460513b31fa2a)) - add environment context loading state ([62263fd](https://github.com/canonical/landscape-dashboard/commit/62263fd4a3b2d5ba53b6573bfa954bf1b7704550)) - kernel tab page crash for machines with no cve fixes, improve livepatch coverage info text ([c87d1d8](https://github.com/canonical/landscape-dashboard/commit/c87d1d8c85d4cea5dba573554e17cdfeb3cf83f9)) # [1.8.0](https://github.com/canonical/landscape-dashboard/compare/v1.7.6...v1.8.0) (2024-11-12) ### Features - enable repository profiles, GPG keys and APT sources for SaaS ([b8ff2b1](https://github.com/canonical/landscape-dashboard/commit/b8ff2b1f6bbdc79d091ac73cdd4e91dc540ebef3)) ## [1.7.6](https://github.com/canonical/landscape-dashboard/compare/v1.7.5...v1.7.6) (2024-11-08) ### Bug Fixes - get instance OS from distribution info property ([cb4eedb](https://github.com/canonical/landscape-dashboard/commit/cb4eedb302a403d6d670bd174d2ffb0398d443da)) - show wsl profiles for self hosted only ([#130](https://github.com/canonical/landscape-dashboard/issues/130)) ([8bbf5f6](https://github.com/canonical/landscape-dashboard/commit/8bbf5f69f838bdfe2429ca5b687a1749adbf7ee5)) ## [1.7.5](https://github.com/canonical/landscape-dashboard/compare/v1.7.4...v1.7.5) (2024-11-05) ### Bug Fixes - update support link ([2d36c12](https://github.com/canonical/landscape-dashboard/commit/2d36c12c9fc4268497fc84d420a92b63b0671e96)) ## [1.7.4](https://github.com/canonical/landscape-dashboard/compare/v1.7.3...v1.7.4) (2024-11-01) ### Bug Fixes - handle request params for the old fetch if user is not presented ([c021434](https://github.com/canonical/landscape-dashboard/commit/c02143427304f77febad9cbacf65329aefac68ee)) ## [1.7.3](https://github.com/canonical/landscape-dashboard/compare/v1.7.2...v1.7.3) (2024-10-31) ### Bug Fixes - change link component import in alert notifications ([#125](https://github.com/canonical/landscape-dashboard/issues/125)) ([13726aa](https://github.com/canonical/landscape-dashboard/commit/13726aa8d2903e09a96cdaafffb156a81642769c)) ## [1.7.2](https://github.com/canonical/landscape-dashboard/compare/v1.7.1...v1.7.2) (2024-10-30) ### Bug Fixes - available package version not showing in package install form ([#124](https://github.com/canonical/landscape-dashboard/issues/124)) ([092fee0](https://github.com/canonical/landscape-dashboard/commit/092fee0abbc1cb9feb8d5383ab17417a84522b69)) - show 'no login methods' only when appropriate ([a1d4417](https://github.com/canonical/landscape-dashboard/commit/a1d4417bbfdf1638b0f914cd679d992640346341)) ## [1.7.1](https://github.com/canonical/landscape-dashboard/compare/v1.7.0...v1.7.1) (2024-10-29) ### Bug Fixes - allow selecting public GPG keys when adding a new APT source ([2b6a979](https://github.com/canonical/landscape-dashboard/commit/2b6a979c5c083263b240de023cf446d7be366494)) - kernel panel error when kernel status is undefined ([#123](https://github.com/canonical/landscape-dashboard/issues/123)) ([5ee63b4](https://github.com/canonical/landscape-dashboard/commit/5ee63b47410cb7e3d9fc1c5da9e6e8320f320c7b)) # [1.7.0](https://github.com/canonical/landscape-dashboard/compare/v1.6.0...v1.7.0) (2024-10-24) ### Bug Fixes - add Kernel tab title and border above table section ([#122](https://github.com/canonical/landscape-dashboard/issues/122)) ([9bffb93](https://github.com/canonical/landscape-dashboard/commit/9bffb93b60f64659df9379f9049db38000fe444d)) ### Features - add endpoint to get auth state using http cookie, drop local storage using to store auth user ([eebf9db](https://github.com/canonical/landscape-dashboard/commit/eebf9dbb4895523ccbb2899077f39b4f0479fd11)) # [1.6.0](https://github.com/canonical/landscape-dashboard/compare/v1.5.0...v1.6.0) (2024-10-23) ### Bug Fixes - change sign in form identity validation ([24f60ab](https://github.com/canonical/landscape-dashboard/commit/24f60aba52067c128ed631611afd91e543440fd0)) - handle trailing slash in root path, change default page redirect after signing in ([e8ac8e3](https://github.com/canonical/landscape-dashboard/commit/e8ac8e309cab6334f3f1d238cc1e0ab10079f484)) ### Features - add badge to Kernel tab ([#120](https://github.com/canonical/landscape-dashboard/issues/120)) ([103b45d](https://github.com/canonical/landscape-dashboard/commit/103b45dc54f88571262c0796981cedec1b8dd5c1)) - add standalone OIDC ([00ddfd6](https://github.com/canonical/landscape-dashboard/commit/00ddfd6ed3387332488e229350f3d90648593006)) # [1.5.0](https://github.com/canonical/landscape-dashboard/compare/v1.4.3...v1.5.0) (2024-10-15) ### Bug Fixes - remove sign in form email validation, rename 'email' field into 'identity' ([9e529f9](https://github.com/canonical/landscape-dashboard/commit/9e529f975fb2da2f28caab6a13a48fdf74d43ddc)) ### Features - add availability zones to instances; change instance filters layout ([#104](https://github.com/canonical/landscape-dashboard/issues/104)) ([b2a8e1d](https://github.com/canonical/landscape-dashboard/commit/b2a8e1d3c36dac9bdb3ba9ea459d6b8b2c01625e)) - add dev PPA build to build workflow ([#109](https://github.com/canonical/landscape-dashboard/issues/109)) ([75af5f9](https://github.com/canonical/landscape-dashboard/commit/75af5f95c7b00670827a4f7f443bccad757b01fb)) - add Kernel tab in single instance view ([5244f93](https://github.com/canonical/landscape-dashboard/commit/5244f93b72dc91a48d9e98af6ee7e1261c70b3ed)) ## [1.4.3](https://github.com/canonical/landscape-dashboard/compare/v1.4.2...v1.4.3) (2024-10-10) ### Bug Fixes - close modal window after accepting or rejecting pending instances, style: change auth template styles ([e96df23](https://github.com/canonical/landscape-dashboard/commit/e96df232e078a08d2ed94b81b6f8063d31a8b2ab)) ## [1.4.2](https://github.com/canonical/landscape-dashboard/compare/v1.4.1...v1.4.2) (2024-10-08) ### Bug Fixes - repository profile showing for saas version ([9f8e205](https://github.com/canonical/landscape-dashboard/commit/9f8e205d7f2f2977dedcf556a165b395165183f4)) ## [1.4.1](https://github.com/canonical/landscape-dashboard/compare/v1.4.0...v1.4.1) (2024-10-08) ### Bug Fixes - secondary navigation not appearing for account settings ([846215a](https://github.com/canonical/landscape-dashboard/commit/846215ae426fd49baff64a76d574fbe61860491d)) # [1.4.0](https://github.com/canonical/landscape-dashboard/compare/v1.3.0...v1.4.0) (2024-10-07) ### Features - add custom SSO providers: Okta and Ubuntu One ([03437ce](https://github.com/canonical/landscape-dashboard/commit/03437ce7bac8e055f848062251b19884f39a2930)) # [1.3.0](https://github.com/canonical/landscape-dashboard/compare/v1.2.1...v1.3.0) (2024-10-03) ### Bug Fixes - use default icon for unknown alert type ([901e6b7](https://github.com/canonical/landscape-dashboard/commit/901e6b775b3c06cb77ad70455b575354b6921d5b)) ### Features - add new child instance alert ([5b2342b](https://github.com/canonical/landscape-dashboard/commit/5b2342b4b2004da55fdf1036a0dbbeef5e06c03a)) ## [1.2.1](https://github.com/canonical/landscape-dashboard/compare/v1.2.0...v1.2.1) (2024-09-27) ### Bug Fixes - bug causing switching organisations not to work ([89728bd](https://github.com/canonical/landscape-dashboard/commit/89728bdf2df276f4a7b6604f751b856e74fa0820)) # [1.2.0](https://github.com/canonical/landscape-dashboard/compare/v1.1.0...v1.2.0) (2024-09-23) ### Bug Fixes - show "Reboot recommended" instance status correctly ([4c12910](https://github.com/canonical/landscape-dashboard/commit/4c12910eecf1e424e4b8565055b7699f9f138c84)) ### Features - implement WSL profiles page ([#108](https://github.com/canonical/landscape-dashboard/issues/108)) ([9336864](https://github.com/canonical/landscape-dashboard/commit/93368642f6b2e7226dff23a2a68e353a7378a2c5)) # [1.1.0](https://github.com/canonical/landscape-dashboard/compare/v1.0.0...v1.1.0) (2024-09-10) ### Features - add changelog ([54cfcb2](https://github.com/canonical/landscape-dashboard/commit/54cfcb2b07b2dfae22ab07724c16ba7878a6924d)) [LNDENG-3116]: https://warthogs.atlassian.net/browse/LNDENG-3116?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3b78c0954..d0fc4fa15 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "landscape-ui", "private": true, - "version": "26.04.0.47", + "version": "26.04.0.70", "type": "module", "engines": { "node": ">=24" From 132adf0ead470cc2b9780b0ab2dfad1c2ee25958 Mon Sep 17 00:00:00 2001 From: Rubin Aga <66167934+rubinaga@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:57:23 +0200 Subject: [PATCH 20/42] feat: add missing buttons to script details panel (#511) ## Summary More information on this PR can be found in [this Jira ticket](https://warthogs.atlassian.net/browse/LNDENG-4066) Additional [Jira ticket 3880 covered here](https://warthogs.atlassian.net/browse/LNDENG-3880) Other than the changes requested there the following were also added: - Fixed long sidepanel title causing sidepanel to be horizontally scrollable - Fixed bug causing side navigation bar to be visible (not hidden behind the modal) when script modals were opened - Added `tags` column to "Run script" preview modal, before it showed just "Instance" column: image - Changed modal pagination to new `ModalTablePagination` component ## Release Impact According to the [Landscape Server Release Cycle](https://docs.google.com/document/d/1sKAp5IvArpfArhMNojFwKOHm9LEdHKB4Et6tu1_-0GY/edit?tab=t.0), this change will target the following release cycle: - **Target Branch**: `dev` / `main` (Beta) - **Version Impact**: - [x] Patch (Fix) - [ ] Minor (Feature) - [ ] Major (Breaking) ## Checklist - [x] **Changeset Added**: I have run `pnpm changeset` and committed the resulting `.md` file. - [x] **UI Verified**: I have verified the changes locally. - [x] **Linting**: No linting errors are present (especially in `scripts/`). ## Versioning Reminder > [!IMPORTANT] > This repository now uses **CalVer** ($YY.0M.Point.Patch$). > Please ensure your changeset description is clear, as it will be automatically added to the `CHANGELOG.md` upon merging to `main`. --- .changeset/thick-zoos-watch.md | 8 + .../MultiSelectField.module.scss | 20 +- .../MultiSelectField/MultiSelectField.tsx | 85 +++++--- .../SidePanelFormButtons.tsx | 3 + .../layout/SearchHelpPopup.module.scss | 2 +- src/components/layout/SearchHelpPopup.tsx | 2 +- src/context/SidePanelProvider.module.scss | 4 + src/context/sidePanel.tsx | 4 +- .../scripts/api/useCreateScriptAttachment.ts | 2 +- .../api/useGetSingleScriptAttachment.ts | 6 +- .../AttachmentFile/AttachmentFile.test.tsx | 52 ++++- .../AttachmentFile/AttachmentFile.tsx | 32 +-- .../EditScriptConfirmationModal.test.tsx | 111 ++++++++++ .../EditScriptConfirmationModal.tsx | 113 ++++++++++ .../EditScriptConfirmationModal/index.ts | 1 + .../EditScriptForm/EditScriptForm.module.scss | 7 - .../EditScriptForm/EditScriptForm.test.tsx | 47 +++- .../EditScriptForm/EditScriptForm.tsx | 142 +++--------- .../RunScriptForm/RunScriptForm.test.tsx | 186 +++++++++++++++- .../RunScriptForm/RunScriptForm.tsx | 202 ++++++++++++++---- .../components/RunScriptForm/constants.ts | 6 +- .../scripts/components/RunScriptForm/types.ts | 1 + .../RunScriptFormInstanceList.test.tsx | 131 ++++++++++++ .../RunScriptFormInstanceList.tsx | 104 +++++++-- .../RunScriptFormInstanceList/helpers.ts | 7 + .../ScriptDetails/ScriptDetails.test.tsx | 48 ++++- .../ScriptDetails/ScriptDetails.tsx | 117 ++++++++-- src/features/scripts/helpers.ts | 2 +- .../api/useGetSecurityProfileAuditDownload.ts | 7 +- .../SecurityProfileDetailsSidePanel.tsx | 4 +- .../SecurityProfileDownloadAuditForm.tsx | 4 +- .../SecurityProfilesContainer.tsx | 4 +- src/features/security-profiles/helpers.tsx | 25 ++- .../hooks/useSecurityProfileDownload.tsx | 133 ++++++++++++ .../hooks/useSecurityProfileDownloadAudit.tsx | 34 --- src/tests/mocks/script.ts | 15 ++ src/tests/server/handlers/script.ts | 80 ++++++- 37 files changed, 1429 insertions(+), 322 deletions(-) create mode 100644 .changeset/thick-zoos-watch.md create mode 100644 src/features/scripts/components/EditScriptConfirmationModal/EditScriptConfirmationModal.test.tsx create mode 100644 src/features/scripts/components/EditScriptConfirmationModal/EditScriptConfirmationModal.tsx create mode 100644 src/features/scripts/components/EditScriptConfirmationModal/index.ts delete mode 100644 src/features/scripts/components/EditScriptForm/EditScriptForm.module.scss create mode 100644 src/features/scripts/components/RunScriptFormInstanceList/RunScriptFormInstanceList.test.tsx create mode 100644 src/features/scripts/components/RunScriptFormInstanceList/helpers.ts create mode 100644 src/features/security-profiles/hooks/useSecurityProfileDownload.tsx delete mode 100644 src/features/security-profiles/hooks/useSecurityProfileDownloadAudit.tsx diff --git a/.changeset/thick-zoos-watch.md b/.changeset/thick-zoos-watch.md new file mode 100644 index 000000000..ca8b2e0da --- /dev/null +++ b/.changeset/thick-zoos-watch.md @@ -0,0 +1,8 @@ +--- +"landscape-ui": patch +--- + +- Add missing buttons to script details panel +- Add ability to edit a script before running +- Fix large script attachments not being uploaded properly +- Fix audit and tailor security profile files not being downloaded properly diff --git a/src/components/form/MultiSelectField/MultiSelectField.module.scss b/src/components/form/MultiSelectField/MultiSelectField.module.scss index dab9740b8..25347122b 100644 --- a/src/components/form/MultiSelectField/MultiSelectField.module.scss +++ b/src/components/form/MultiSelectField/MultiSelectField.module.scss @@ -19,8 +19,22 @@ } } } -} -.errorMessage { - margin-top: $spv--medium; + &:global(.is-caution) { + :global(.multi-select__select-button) { + border-bottom-color: $color-caution; + + &:not(:hover, [aria-expanded="true"]) { + background-color: $color-caution-background; + } + + &:hover:not([aria-expanded="true"]) { + background-color: $color-caution-background--hover; + } + + &[aria-expanded="true"] { + background-color: $color-caution-background--focus; + } + } + } } diff --git a/src/components/form/MultiSelectField/MultiSelectField.tsx b/src/components/form/MultiSelectField/MultiSelectField.tsx index 6a151a247..31b4f1f9c 100644 --- a/src/components/form/MultiSelectField/MultiSelectField.tsx +++ b/src/components/form/MultiSelectField/MultiSelectField.tsx @@ -1,5 +1,5 @@ import type { FC, RefObject, ReactNode, Ref } from "react"; -import { useRef } from "react"; +import { useRef, useEffect } from "react"; import type { MultiSelectProps } from "@canonical/react-components"; import { MultiSelect } from "@canonical/react-components"; import classNames from "classnames"; @@ -11,6 +11,9 @@ interface MultiSelectFieldProps extends Omit { readonly innerRef?: Ref; readonly label?: string; readonly labelClassName?: string; + readonly onOpen?: () => void; + readonly onClose?: () => void; + readonly warning?: ReactNode; } const MultiSelectField: FC = ({ @@ -24,16 +27,57 @@ const MultiSelectField: FC = ({ items, label, labelClassName, + onOpen, + onClose, required, + warning, ...otherProps }) => { - const controlRef = useRef(null); - const dropdownIdRef = useRef(""); + const containerRef = useRef(null); + + // Keep the latest callbacks in refs so the MutationObserver always + // calls the most recent version without needing to re-observe. + const onOpenRef = useRef(onOpen); + const onCloseRef = useRef(onClose); + useEffect(() => { + onOpenRef.current = onOpen; + onCloseRef.current = onClose; + }); + + // Watch aria-expanded on the Canonical control element. This is the only + // reliable signal for open/close — the dropdown is portalled so focus/blur + // events on the wrapper are unreliable. + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + // MultiSelect renders its button synchronously, so it is in the DOM + // by the time this effect runs (after first paint). + const control = container.querySelector("[aria-expanded]"); + if (!control) return; + + const observer = new MutationObserver(() => { + if (control.getAttribute("aria-expanded") === "true") { + onOpenRef.current?.(); + } else { + onCloseRef.current?.(); + } + }); + + observer.observe(control, { + attributes: true, + attributeFilter: ["aria-expanded"], + }); + + return () => { + observer.disconnect(); + }; + }, []); const containerRefCallback = (node: HTMLDivElement | null) => { - if (!node) { - return; - } + (containerRef as RefObject).current = node; + + if (!node) return; if (innerRef) { if (typeof innerRef === "function") { @@ -42,22 +86,6 @@ const MultiSelectField: FC = ({ (innerRef as RefObject).current = node; } } - - if (!node.closest(".l-aside")) { - return; - } - - const button = node.querySelector( - ".multi-select__select-button", - ); - const input = node.querySelector(".p-search-box__input"); - - ( - controlRef as RefObject - ).current = button || input; - - dropdownIdRef.current = - controlRef.current?.getAttribute("aria-controls") || ""; }; const footer = error ? ( @@ -80,6 +108,7 @@ const MultiSelectField: FC = ({ className={classNames( "p-form-validation p-form__group", { "is-error": !!error }, + { "is-caution": !!warning }, classes.container, className, )} @@ -108,15 +137,15 @@ const MultiSelectField: FC = ({ {...otherProps} /> {error && ( -

+

{error}

)} + {warning && ( +

+ {warning} +

+ )}
); }; diff --git a/src/components/form/SidePanelFormButtons/SidePanelFormButtons.tsx b/src/components/form/SidePanelFormButtons/SidePanelFormButtons.tsx index fc57173c3..bb6ff3fd9 100644 --- a/src/components/form/SidePanelFormButtons/SidePanelFormButtons.tsx +++ b/src/components/form/SidePanelFormButtons/SidePanelFormButtons.tsx @@ -10,6 +10,7 @@ interface SidePanelFormButtonsProps { readonly submitButtonAriaLabel?: string; readonly submitButtonLoading?: boolean; readonly secondaryActionButtonTitle?: ReactNode; + readonly secondaryActionButtonDisabled?: boolean; readonly secondaryActionButtonSubmit?: ( event: SyntheticEvent, ) => Promise | void; @@ -29,6 +30,7 @@ const SidePanelFormButtons: FC = ({ submitButtonText, submitButtonAriaLabel, secondaryActionButtonTitle, + secondaryActionButtonDisabled, secondaryActionButtonSubmit, onBackButtonPress, onCancel, @@ -67,6 +69,7 @@ const SidePanelFormButtons: FC = ({ type="button" className="u-no-margin--bottom" onClick={secondaryActionButtonSubmit} + disabled={secondaryActionButtonDisabled} > <>{secondaryActionButtonTitle} diff --git a/src/components/layout/SearchHelpPopup.module.scss b/src/components/layout/SearchHelpPopup.module.scss index a48f95325..7e9181a43 100644 --- a/src/components/layout/SearchHelpPopup.module.scss +++ b/src/components/layout/SearchHelpPopup.module.scss @@ -2,7 +2,7 @@ width: 30%; } -:global(.p-modal) { +.modal { table { width: 60rem; } diff --git a/src/components/layout/SearchHelpPopup.tsx b/src/components/layout/SearchHelpPopup.tsx index b35922e41..785d4d45f 100644 --- a/src/components/layout/SearchHelpPopup.tsx +++ b/src/components/layout/SearchHelpPopup.tsx @@ -32,7 +32,7 @@ const SearchHelpPopup: FC = ({ open, onClose, data }) => { return ( open && ( - +

Available search terms for use in the search box. If multiple search terms are separated by OR, any of the conditions will match. diff --git a/src/context/SidePanelProvider.module.scss b/src/context/SidePanelProvider.module.scss index bd6960472..193b8583d 100644 --- a/src/context/SidePanelProvider.module.scss +++ b/src/context/SidePanelProvider.module.scss @@ -14,6 +14,10 @@ column-gap: $sph--large; } +.title { + overflow-wrap: anywhere; +} + .outerDiv { display: flex; flex-grow: 1; diff --git a/src/context/sidePanel.tsx b/src/context/sidePanel.tsx index 3d56fe38d..e715c2b34 100644 --- a/src/context/sidePanel.tsx +++ b/src/context/sidePanel.tsx @@ -111,7 +111,9 @@ const SidePanelProvider: FC = ({ children }) => { {open && ( <>

-

{title}

+

+ {title} +

{titleLabel}

diff --git a/src/features/scripts/api/useCreateScriptAttachment.ts b/src/features/scripts/api/useCreateScriptAttachment.ts index b0e6ee111..3448f92ce 100644 --- a/src/features/scripts/api/useCreateScriptAttachment.ts +++ b/src/features/scripts/api/useCreateScriptAttachment.ts @@ -20,7 +20,7 @@ export const useCreateScriptAttachment = () => { >({ mutationKey: ["scripts", "createAttachment"], mutationFn: async (params) => - authFetch.get("CreateScriptAttachment", { params }), + authFetch.post("CreateScriptAttachment", params), onSuccess: async () => queryClient.invalidateQueries({ queryKey: ["scripts"] }), }); diff --git a/src/features/scripts/api/useGetSingleScriptAttachment.ts b/src/features/scripts/api/useGetSingleScriptAttachment.ts index 68fca52fc..9b62642de 100644 --- a/src/features/scripts/api/useGetSingleScriptAttachment.ts +++ b/src/features/scripts/api/useGetSingleScriptAttachment.ts @@ -20,10 +20,12 @@ export const useGetSingleScriptAttachment = ( data: response, isLoading, refetch, - } = useQuery, AxiosError>({ + } = useQuery, AxiosError>({ queryKey: ["scripts", "attachment", attachmentId], queryFn: async () => - authFetch.get(`scripts/${scriptId}/attachments/${attachmentId}`), + authFetch.get(`scripts/${scriptId}/attachments/${attachmentId}`, { + responseType: "blob", + }), ...config, }); diff --git a/src/features/scripts/components/AttachmentFile/AttachmentFile.test.tsx b/src/features/scripts/components/AttachmentFile/AttachmentFile.test.tsx index e1f9e9b3b..d8872e6ec 100644 --- a/src/features/scripts/components/AttachmentFile/AttachmentFile.test.tsx +++ b/src/features/scripts/components/AttachmentFile/AttachmentFile.test.tsx @@ -1,9 +1,23 @@ +import { setEndpointStatus } from "@/tests/controllers/controller"; import { renderWithProviders } from "@/tests/render"; -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; -import { describe, it } from "vitest"; +import { beforeEach, describe, it, vi } from "vitest"; import AttachmentFile from "./AttachmentFile"; +const createObjectURLMock = vi.fn((_object: Blob | MediaSource) => "blob:test"); +const revokeObjectURLMock = vi.fn(); + +Object.defineProperty(URL, "createObjectURL", { + writable: true, + value: createObjectURLMock, +}); +Object.defineProperty(URL, "revokeObjectURL", { + writable: true, + value: revokeObjectURLMock, +}); + const baseProps: ComponentProps = { attachmentId: 1, filename: "test.txt", @@ -21,6 +35,11 @@ const propsWithInitialAttachmentDelete: ComponentProps = }; describe("AttachmentFile", () => { + beforeEach(() => { + setEndpointStatus("default"); + vi.clearAllMocks(); + }); + it("should display attachment file with script id prop", async () => { renderWithProviders(); @@ -54,4 +73,33 @@ describe("AttachmentFile", () => { }); expect(deleteButton).toBeInTheDocument(); }); + + it("downloads blob attachments and revokes object URL", async () => { + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); + const user = userEvent.setup(); + + renderWithProviders(); + await user.click( + screen.getByRole("button", { + name: `Download ${propsWithScriptId.filename}`, + }), + ); + + await waitFor(() => { + expect(createObjectURLMock).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:test"); + }); + + const blobToDownload = createObjectURLMock.mock.calls[0]?.[0]; + expect(blobToDownload).toBeInstanceOf(Blob); + if (!(blobToDownload instanceof Blob)) { + throw new Error("Expected downloaded data to be a Blob."); + } + expect(blobToDownload.type).toContain("text/plain"); + + clickSpy.mockRestore(); + }); }); diff --git a/src/features/scripts/components/AttachmentFile/AttachmentFile.tsx b/src/features/scripts/components/AttachmentFile/AttachmentFile.tsx index 9e429268e..42a55d5f0 100644 --- a/src/features/scripts/components/AttachmentFile/AttachmentFile.tsx +++ b/src/features/scripts/components/AttachmentFile/AttachmentFile.tsx @@ -1,6 +1,7 @@ import { Button, Icon, ICONS, Tooltip } from "@canonical/react-components"; import classNames from "classnames"; import type { FC } from "react"; +import useDebug from "@/hooks/useDebug"; import { useGetSingleScriptAttachment } from "../../api"; import classes from "./AttachmentFile.module.scss"; @@ -19,26 +20,31 @@ const AttachmentFile: FC = ({ scriptId, className, }) => { + const debug = useDebug(); + const { isScriptAttachmentsLoading, refetch } = useGetSingleScriptAttachment( { attachmentId, scriptId: scriptId || 0 }, { enabled: false }, ); const handleDownload = async () => { - const { data } = await refetch(); - if (!data) { - return; - } + try { + const { data } = await refetch(); + if (!data) { + throw new Error("Could not download attachment."); + } - const blob = new Blob([data.data], { type: "application/octet-stream" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); + const url = URL.createObjectURL(data.data); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (error) { + debug(error); + } }; return ( diff --git a/src/features/scripts/components/EditScriptConfirmationModal/EditScriptConfirmationModal.test.tsx b/src/features/scripts/components/EditScriptConfirmationModal/EditScriptConfirmationModal.test.tsx new file mode 100644 index 000000000..7c051179a --- /dev/null +++ b/src/features/scripts/components/EditScriptConfirmationModal/EditScriptConfirmationModal.test.tsx @@ -0,0 +1,111 @@ +import { setEndpointStatus } from "@/tests/controllers/controller"; +import { scripts } from "@/tests/mocks/script"; +import { renderWithProviders } from "@/tests/render"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ComponentProps } from "react"; +import { describe, expect, it, vi } from "vitest"; +import EditScriptConfirmationModal from "./EditScriptConfirmationModal"; + +const [scriptWithProfiles] = scripts; + +type Props = ComponentProps; + +const defaultProps: Props = { + script: scriptWithProfiles, + confirmButtonLabel: "Submit new version", + isConfirming: false, + onConfirm: vi.fn(), + onClose: vi.fn(), +}; + +describe("EditScriptConfirmationModal", () => { + const user = userEvent.setup(); + + it("should display the modal title with the script name", async () => { + renderWithProviders(); + + expect( + await screen.findByText( + `Submit new version of "${scriptWithProfiles.title}"`, + ), + ).toBeInTheDocument(); + }); + + it("should show the confirm button with the provided label", async () => { + renderWithProviders( + , + ); + + expect( + await screen.findByRole("button", { name: "Submit and run" }), + ).toBeInTheDocument(); + }); + + it("should show a generic message when there are no associated profiles", async () => { + setEndpointStatus({ status: "empty", path: "script-profiles" }); + + renderWithProviders(); + + expect( + await screen.findByText( + /All future script runs will be done using the latest version of the code\./i, + ), + ).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); + + it("should show the affected profiles table when there are associated profiles", async () => { + renderWithProviders(); + + expect( + await screen.findByText( + /Submitting these changes will affect the following profiles/i, + ), + ).toBeInTheDocument(); + expect(screen.getByRole("table")).toBeInTheDocument(); + expect( + screen.getByText(scriptWithProfiles.script_profiles[0].title), + ).toBeInTheDocument(); + }); + + it("should call onConfirm when the confirm button is clicked", async () => { + const onConfirm = vi.fn(); + + renderWithProviders( + , + ); + + await user.click( + await screen.findByRole("button", { name: /submit new version/i }), + ); + + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it("should call onClose when the cancel button is clicked", async () => { + const onClose = vi.fn(); + + renderWithProviders( + , + ); + + await user.click(await screen.findByRole("button", { name: /cancel/i })); + + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("should disable the confirm button when isConfirming is true", async () => { + renderWithProviders( + , + ); + + const confirmButton = await screen.findByRole("button", { + name: /waiting for action to complete/i, + }); + expect(confirmButton).toHaveAttribute("aria-disabled", "true"); + }); +}); diff --git a/src/features/scripts/components/EditScriptConfirmationModal/EditScriptConfirmationModal.tsx b/src/features/scripts/components/EditScriptConfirmationModal/EditScriptConfirmationModal.tsx new file mode 100644 index 000000000..cd8ea5593 --- /dev/null +++ b/src/features/scripts/components/EditScriptConfirmationModal/EditScriptConfirmationModal.tsx @@ -0,0 +1,113 @@ +import LoadingState from "@/components/layout/LoadingState"; +import NoData from "@/components/layout/NoData"; +import { ROUTES } from "@/libs/routes"; +import { ConfirmationModal, ModularTable } from "@canonical/react-components"; +import type { FC } from "react"; +import { Link } from "react-router"; +import type { CellProps } from "react-table"; +import { useGetAssociatedScriptProfiles } from "../../api"; +import type { Script, TruncatedScriptProfile } from "../../types"; +import { pluralizeWithCount } from "@/utils/_helpers"; + +interface EditScriptConfirmationModalProps { + readonly script: Script; + readonly confirmButtonLabel: string; + readonly isConfirming: boolean; + readonly onConfirm: () => void; + readonly onClose: () => void; +} + +const EditScriptConfirmationModal: FC = ({ + script, + confirmButtonLabel, + isConfirming, + onConfirm, + onClose, +}) => { + const { associatedScriptProfiles, isAssociatedScriptProfilesLoading } = + useGetAssociatedScriptProfiles(script.id); + + const modalContent = () => { + if (isAssociatedScriptProfilesLoading) { + return ; + } + + if (associatedScriptProfiles.length === 0) { + return ( +

+ All future script runs will be done using the latest version of the + code. +

+ ); + } + + return ( + <> +

+ All future script runs will be done using the latest version of the + code. Submitting these changes will affect the following profiles: +

+ ) => ( + + {row.original.title} + + ), + }, + { + Header: "associated instances", + Cell: ({ row }: CellProps) => { + const associatedProfile = associatedScriptProfiles.find( + (profile) => profile.id === row.original.id, + ); + + const associatedComputers = + associatedProfile?.computers.num_associated_computers; + + return associatedComputers ? ( + + {pluralizeWithCount(associatedComputers, "instance")} + + ) : ( + + ); + }, + }, + ]} + data={script.script_profiles} + /> + + ); + }; + + return ( + + {modalContent()} + + ); +}; + +export default EditScriptConfirmationModal; diff --git a/src/features/scripts/components/EditScriptConfirmationModal/index.ts b/src/features/scripts/components/EditScriptConfirmationModal/index.ts new file mode 100644 index 000000000..17f0002a5 --- /dev/null +++ b/src/features/scripts/components/EditScriptConfirmationModal/index.ts @@ -0,0 +1 @@ +export { default } from "./EditScriptConfirmationModal"; diff --git a/src/features/scripts/components/EditScriptForm/EditScriptForm.module.scss b/src/features/scripts/components/EditScriptForm/EditScriptForm.module.scss deleted file mode 100644 index 19b0d8a37..000000000 --- a/src/features/scripts/components/EditScriptForm/EditScriptForm.module.scss +++ /dev/null @@ -1,7 +0,0 @@ -@import "vanilla-framework/scss/settings_colors"; - -.modal { - table { - max-width: 40em; - } -} diff --git a/src/features/scripts/components/EditScriptForm/EditScriptForm.test.tsx b/src/features/scripts/components/EditScriptForm/EditScriptForm.test.tsx index d5ee5e965..c70b11459 100644 --- a/src/features/scripts/components/EditScriptForm/EditScriptForm.test.tsx +++ b/src/features/scripts/components/EditScriptForm/EditScriptForm.test.tsx @@ -1,17 +1,23 @@ import { scripts } from "@/tests/mocks/script"; import { renderWithProviders } from "@/tests/render"; -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { ComponentProps } from "react"; import { describe, it } from "vitest"; import EditScriptForm from "./EditScriptForm"; const [script] = scripts; +const props: ComponentProps = { + script, + onBack: vi.fn(), +}; + describe("EditScriptForm", () => { const user = userEvent.setup(); it("should display edit script form", async () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getByText(/title/i)).toBeInTheDocument(); expect(screen.getByText(/code/i)).toBeInTheDocument(); @@ -19,19 +25,38 @@ describe("EditScriptForm", () => { expect(screen.getByText(/submit new version/i)).toBeInTheDocument(); }); - it("should display confirmation modal when submitting changes", async () => { - renderWithProviders(); + it("should show the edit confirmation modal when 'Submit new version' is clicked", async () => { + renderWithProviders(); + + await user.type(screen.getByRole("textbox", { name: /title/i }), " edited"); + await user.click( + screen.getByRole("button", { name: /submit new version/i }), + ); + + expect(await screen.findByRole("dialog")).toBeInTheDocument(); + expect( + within(await screen.findByRole("dialog")).getByRole("button", { + name: /submit new version/i, + }), + ).toBeInTheDocument(); + }); + + it("should submit the new version and show a success notification", async () => { + renderWithProviders(); - const submitButton = screen.getByRole("button", { - name: /submit new version/i, - }); + await user.type(screen.getByRole("textbox", { name: /title/i }), " edited"); + await user.click( + screen.getByRole("button", { name: /submit new version/i }), + ); - await user.click(submitButton); + await user.click( + within(await screen.findByRole("dialog")).getByRole("button", { + name: /submit new version/i, + }), + ); expect( - screen.getByText( - /all future script runs will be done using the latest version of the code/i, - ), + await screen.findByText(/successfully submitted a new version/i), ).toBeInTheDocument(); }); }); diff --git a/src/features/scripts/components/EditScriptForm/EditScriptForm.tsx b/src/features/scripts/components/EditScriptForm/EditScriptForm.tsx index d3a13fc34..35d889262 100644 --- a/src/features/scripts/components/EditScriptForm/EditScriptForm.tsx +++ b/src/features/scripts/components/EditScriptForm/EditScriptForm.tsx @@ -1,29 +1,17 @@ import CodeEditor from "@/components/form/CodeEditor"; import SidePanelFormButtons from "@/components/form/SidePanelFormButtons"; -import LoadingState from "@/components/layout/LoadingState"; -import NoData from "@/components/layout/NoData"; import useDebug from "@/hooks/useDebug"; import useNotify from "@/hooks/useNotify"; import useSidePanel from "@/hooks/useSidePanel"; -import { ROUTES } from "@/libs/routes"; import { getFormikError } from "@/utils/formikErrors"; -import { - Button, - ConfirmationModal, - Form, - Icon, - Input, - ModularTable, -} from "@canonical/react-components"; +import { Button, Form, Icon, Input } from "@canonical/react-components"; import { useFormik } from "formik"; import type { FC } from "react"; -import { useRef, useState } from "react"; -import { Link } from "react-router"; -import type { CellProps } from "react-table"; +import { useRef } from "react"; +import { useBoolean } from "usehooks-ts"; import { useCreateScriptAttachment, useEditScript, - useGetAssociatedScriptProfiles, useRemoveScriptAttachment, } from "../../api"; import { DEFAULT_SCRIPT } from "../../constants"; @@ -31,13 +19,9 @@ import { getCreateAttachmentsPromises, getEditScriptParams, } from "../../helpers"; -import type { - Script, - ScriptFormValues, - TruncatedScriptProfile, -} from "../../types"; +import type { Script, ScriptFormValues } from "../../types"; +import EditScriptConfirmationModal from "../EditScriptConfirmationModal"; import ScriptFormAttachments from "../ScriptFormAttachments"; -import classes from "./EditScriptForm.module.scss"; import { getInitialValues, getValidationSchema, @@ -46,11 +30,16 @@ import { interface EditScriptFormProps { readonly script: Script; + readonly onBack?: () => void; } -const EditScriptForm: FC = ({ script }) => { +const EditScriptForm: FC = ({ script, onBack }) => { const inputRef = useRef(null); - const [modalOpen, setModalOpen] = useState(false); + const { + value: showConfirmationModal, + setTrue: showModal, + setFalse: closeModal, + } = useBoolean(); const { closeSidePanel } = useSidePanel(); const debug = useDebug(); @@ -60,14 +49,11 @@ const EditScriptForm: FC = ({ script }) => { const { removeScriptAttachment } = useRemoveScriptAttachment(); const { editScript, isEditing } = useEditScript(); - const { associatedScriptProfiles, isAssociatedScriptProfilesLoading } = - useGetAssociatedScriptProfiles(script.id, { enabled: modalOpen }); - const handleSubmit = async (values: ScriptFormValues) => { - const newAttachments = Object.values(values.attachments).filter( - (a) => a !== null, - ); try { + const newAttachments = Object.values(values.attachments).filter( + (a) => a !== null, + ); const promises: Promise[] = [ editScript(getEditScriptParams({ scriptId: script.id, values })), ]; @@ -88,15 +74,17 @@ const EditScriptForm: FC = ({ script }) => { ); } await Promise.all(promises); + + closeModal(); closeSidePanel(); - setModalOpen(false); + notify.success({ title: `You have successfully submitted a new version of ${script.title}`, message: "All its associated profiles will now be run using this new version.", }); } catch (error) { - setModalOpen(false); + closeModal(); debug(error); } }; @@ -205,92 +193,20 @@ const EditScriptForm: FC = ({ script }) => { /> { - setModalOpen(true); - }} + onSubmit={showModal} + hasBackButton={!!onBack} + onBackButtonPress={onBack} submitButtonText="Submit new version" /> - {modalOpen && ( - { - formik.handleSubmit(); - }} - confirmButtonAppearance="positive" - confirmButtonDisabled={isEditing} - confirmButtonLoading={isEditing} - close={() => { - setModalOpen(false); - }} - className={classes.modal} - confirmButtonProps={{ - type: "button", - }} - > - {associatedScriptProfiles.length > 0 ? ( - <> -

- All future script runs will be done using the latest version of - the code. Submitting these changes will affect the following - profiles: -

- ) => ( - - {row.original.title} - - ), - }, - { - Header: "associated instances", - Cell: ({ row }: CellProps) => { - const associatedProfile = associatedScriptProfiles.find( - (profile) => profile.id === row.original.id, - ); - - const associatedComputers = - associatedProfile?.computers.num_associated_computers; - - if (isAssociatedScriptProfilesLoading) { - return ; - } - - return associatedComputers ? ( - - {associatedComputers} instance - {associatedComputers > 1 ? "s" : ""} - - ) : ( - - ); - }, - }, - ]} - data={script.script_profiles} - /> - - ) : ( -

- All future script runs will be done using the latest version of - the code. -

- )} -
+ isConfirming={isEditing} + onClose={closeModal} + /> )} ); diff --git a/src/features/scripts/components/RunScriptForm/RunScriptForm.test.tsx b/src/features/scripts/components/RunScriptForm/RunScriptForm.test.tsx index 11690ee78..b84e77597 100644 --- a/src/features/scripts/components/RunScriptForm/RunScriptForm.test.tsx +++ b/src/features/scripts/components/RunScriptForm/RunScriptForm.test.tsx @@ -1,12 +1,20 @@ import { scripts } from "@/tests/mocks/script"; import { renderWithProviders } from "@/tests/render"; -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it } from "vitest"; import RunScriptForm from "./RunScriptForm"; const [script] = scripts; +const selectTag = async ( + user: ReturnType, + tagName: string, +) => { + await user.click(await screen.findByRole("combobox", { name: "Tags" })); + await user.click(await screen.findByRole("checkbox", { name: tagName })); +}; + describe("RunScriptForm", () => { const user = userEvent.setup(); @@ -46,4 +54,180 @@ describe("RunScriptForm", () => { expect(screen.getByText("Select instances")).toBeInTheDocument(); }); + + it("should display the code editor with its label", async () => { + renderWithProviders(); + + expect(await screen.findByText(/script code/i)).toBeInTheDocument(); + expect(screen.getByTestId("mock-monaco")).toBeInTheDocument(); + }); + + it("should change submit button label to 'Save and run' when code is modified", async () => { + renderWithProviders(); + + await screen.findByRole("button", { name: /run script/i }); + + const codeEditor = screen.getByTestId("mock-monaco"); + await user.clear(codeEditor); + await user.type(codeEditor, "#!/bin/bash\necho hello"); + + expect( + screen.getByRole("button", { name: /save and run/i }), + ).toBeInTheDocument(); + }); + + it("should show the edit confirmation modal when code is modified and submit is clicked", async () => { + renderWithProviders(); + + await selectTag(user, "appservers"); + + const codeEditor = screen.getByTestId("mock-monaco"); + await user.clear(codeEditor); + await user.type(codeEditor, "#!/bin/bash\necho hello"); + + expect( + await screen.findByRole("button", { name: /save and run/i }), + ).not.toHaveAttribute("aria-disabled", "true"); + + await user.click(screen.getByRole("button", { name: /save and run/i })); + + expect( + await screen.findByText(/submit new version of/i), + ).toBeInTheDocument(); + }); + + it("should run the script without showing the edit confirmation modal when code is not modified", async () => { + renderWithProviders(); + + await selectTag(user, "appservers"); + + expect( + await screen.findByRole("button", { name: /run script/i }), + ).not.toHaveAttribute("aria-disabled", "true"); + + await user.click(screen.getByRole("button", { name: /run script/i })); + + expect( + screen.queryByText(/submit new version of/i), + ).not.toBeInTheDocument(); + + const dialog = await screen.findByRole("dialog"); + await user.click( + within(dialog).getByRole("button", { name: "Run script" }), + ); + + expect( + await screen.findByText(/script execution queued/i), + ).toBeInTheDocument(); + }); + + it("should call editScript before runScript when submitting with modified code", async () => { + renderWithProviders(); + + await selectTag(user, "appservers"); + + const codeEditor = screen.getByTestId("mock-monaco"); + await user.clear(codeEditor); + await user.type(codeEditor, "#!/bin/bash\necho hello"); + + expect( + await screen.findByRole("button", { name: /save and run/i }), + ).not.toHaveAttribute("aria-disabled", "true"); + + await user.click(screen.getByRole("button", { name: /save and run/i })); + + const editConfirmButton = await screen.findByRole("button", { + name: "Submit and run", + }); + await user.click(editConfirmButton); + + const runConfirmButton = await screen.findByRole("button", { + name: "Run script", + }); + + expect(runConfirmButton).not.toHaveAttribute("aria-disabled", "true"); + + await user.click(runConfirmButton); + + expect( + await screen.findByText(/script execution queued/i), + ).toBeInTheDocument(); + }); + + it("should show 'no instances to run script on' modal when submitting with tags that have no script-capable instances", async () => { + const scriptWithoutTaggedInstances = { + ...script, + access_group: "empty-access-group access-group:empty-access-group", + }; + + renderWithProviders( + , + ); + + await selectTag(user, "appservers"); + + const runButton = await screen.findByRole("button", { + name: /run script/i, + }); + + await user.click(runButton); + + expect( + await screen.findByText(/no instances to run script on/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/please select different tags and try again/i), + ).toBeInTheDocument(); + }); + + it("should show instance list and confirmation message in run modal when tags have script-capable instances", async () => { + renderWithProviders(); + + await selectTag(user, "appservers"); + + const runButton = await screen.findByRole("button", { + name: /run script/i, + }); + expect(runButton).not.toHaveAttribute("aria-disabled", "true"); + + await user.click(runButton); + + expect( + await screen.findByText( + /this script will run on the following instances/i, + ), + ).toBeInTheDocument(); + + const dialog = screen.getByRole("dialog"); + expect( + within(dialog).getByRole("columnheader", { name: /instance/i }), + ).toBeInTheDocument(); + }); + + it("should show warning notification on tag blur when selected tags have no associated instances", async () => { + const scriptWithoutTaggedInstances = { + ...script, + access_group: "empty-access-group access-group:empty-access-group", + }; + + renderWithProviders( + , + ); + + await selectTag(user, "appservers"); + + // Click another input to close the dropdown and trigger the onClose callback, + // which sets hasClosedTagDropdown=true (required to display the warning). + await user.click(screen.getByRole("textbox", { name: /run as user/i })); + + expect( + await screen.findByText( + "There are no instances with the selected tags that can run scripts.", + ), + ).toBeInTheDocument(); + + expect( + screen.queryByText(/this script will run on the following instances/i), + ).not.toBeInTheDocument(); + }); }); diff --git a/src/features/scripts/components/RunScriptForm/RunScriptForm.tsx b/src/features/scripts/components/RunScriptForm/RunScriptForm.tsx index a35eb7f8e..095d812a0 100644 --- a/src/features/scripts/components/RunScriptForm/RunScriptForm.tsx +++ b/src/features/scripts/components/RunScriptForm/RunScriptForm.tsx @@ -1,3 +1,5 @@ +import CodeEditor from "@/components/form/CodeEditor"; +import { DeliveryBlock } from "@/components/form/DeliveryScheduling"; import MultiSelectField from "@/components/form/MultiSelectField"; import SidePanelFormButtons from "@/components/form/SidePanelFormButtons"; import LoadingState from "@/components/layout/LoadingState"; @@ -17,27 +19,62 @@ import { } from "@canonical/react-components"; import { useFormik } from "formik"; import moment from "moment/moment"; -import { useState, type FC } from "react"; -import { useRunScript } from "../../api"; +import { type FC, useState } from "react"; +import { useBoolean } from "usehooks-ts"; +import { useEditScript, useRunScript } from "../../api"; +import { getCode, getEncodedCode } from "../../helpers"; import type { Script } from "../../types"; +import EditScriptConfirmationModal from "../EditScriptConfirmationModal"; import RunScriptFormInstanceList from "../RunScriptFormInstanceList"; -import { INITIAL_VALUES, VALIDATION_SCHEMA } from "./constants"; +import { + INITIAL_VALUES, + NO_TAGGED_FEATURED_INSTANCES_WARNING_MESSAGE, + VALIDATION_SCHEMA, +} from "./constants"; import classes from "./RunScriptForm.module.scss"; import type { FormProps } from "./types"; -import { DeliveryBlock } from "@/components/form/DeliveryScheduling"; +import { pluralize } from "@/utils/_helpers"; interface RunScriptFormProps { readonly script: Script; + readonly submittedCode?: string; + readonly onBack?: () => void; } -const RunScriptForm: FC = ({ script }) => { +const RunScriptForm: FC = ({ + script, + submittedCode, + onBack, +}) => { + const { + value: isRunConfirmVisible, + setTrue: showRunConfirm, + setFalse: hideRunConfirm, + } = useBoolean(); + + const { + value: isEditConfirmVisible, + setTrue: showEditConfirm, + setFalse: hideEditConfirm, + } = useBoolean(); + const debug = useDebug(); const { notify } = useNotify(); const { closeSidePanel } = useSidePanel(); const { runScript } = useRunScript(); - - const handleSubmit = async (values: FormProps) => { + const { editScript } = useEditScript(); + const [isTagDropdownOpen, setIsTagDropdownOpen] = useState(false); + const [hasClosedTagDropdown, setHasClosedTagDropdown] = useState(false); + + const originalCode = + submittedCode ?? + getCode({ + code: script.code, + interpreter: script.interpreter, + }); + + const submitRun = async (values: FormProps) => { const valuesToSubmit = { query: values.queryType === "ids" @@ -60,6 +97,13 @@ const RunScriptForm: FC = ({ script }) => { } try { + if (values.code !== originalCode) { + await editScript({ + script_id: script.id, + code: getEncodedCode(values.code), + }); + } + await runScript(valuesToSubmit); closeSidePanel(); @@ -73,14 +117,28 @@ const RunScriptForm: FC = ({ script }) => { } }; + const handleSubmit = async (values: FormProps) => { + if (values.code !== originalCode) { + showEditConfirm(); + return; + } + + if (values.queryType === "tags") { + showRunConfirm(); + return; + } + + await submitRun(values); + }; + const formik = useFormik({ - initialValues: INITIAL_VALUES, + initialValues: { ...INITIAL_VALUES, code: originalCode }, onSubmit: handleSubmit, validateOnMount: true, validationSchema: VALIDATION_SCHEMA, }); - const [isModalVisible, setIsModalVisible] = useState(false); + const codeChanged = formik.values.code !== originalCode; const { tags, isGettingTags } = useGetTags(); @@ -102,22 +160,6 @@ const RunScriptForm: FC = ({ script }) => { }, ); - if (isGettingTags || isGettingInstances) { - return ; - } - - if (taggedInstancesError) { - debug(taggedInstancesError); - } - - const hideModal = () => { - setIsModalVisible(false); - }; - - const showModal = () => { - setIsModalVisible(true); - }; - const tagOptions: MultiSelectItem[] = tags.map((tag) => ({ label: tag, @@ -141,18 +183,35 @@ const RunScriptForm: FC = ({ script }) => { return getFeatures(instance).scripts; }) ?? []; - const trySubmit = () => { - if (formik.values.queryType == "tags") { - showModal(); + const shouldShowNoTaggedInstancesWarning = + hasClosedTagDropdown && + !isTagDropdownOpen && + formik.values.queryType === "tags" && + formik.values.tags.length > 0 && + !isGettingTaggedInstances && + !taggedInstancesError && + !taggedInstancesWithScriptsFeature.length; + + const proceedWithRun = () => { + if (formik.values.queryType === "tags") { + showRunConfirm(); } else { - formik.handleSubmit(); + submitRun(formik.values); } }; + if (isGettingTags || isGettingInstances) { + return ; + } + + if (taggedInstancesError) { + debug(taggedInstancesError); + } + return ( <> -
-

Select instances by:

+ +

* Select instances by:

@@ -170,6 +229,9 @@ const RunScriptForm: FC = ({ script }) => { formik.setFieldValue("instanceIds", []), formik.setFieldTouched("instanceIds", false), ]); + + setIsTagDropdownOpen(false); + setHasClosedTagDropdown(false); }} value="tags" checked={formik.values.queryType === "tags"} @@ -206,10 +268,20 @@ const RunScriptForm: FC = ({ script }) => { "tags", items.map(({ value }) => value), ); - + }} + onOpen={() => { + setIsTagDropdownOpen(true); + }} + onClose={() => { + setIsTagDropdownOpen(false); + setHasClosedTagDropdown(true); formik.setFieldTouched("tags", true, false); }} error={getFormikError(formik, "tags")} + warning={ + shouldShowNoTaggedInstancesWarning && + NO_TAGGED_FEATURED_INSTANCES_WARNING_MESSAGE + } /> )} @@ -263,39 +335,85 @@ const RunScriptForm: FC = ({ script }) => { + { + await formik.setFieldValue("code", value ?? ""); + }} + error={getFormikError(formik, "code")} + required + /> + - {isModalVisible && ( + {isEditConfirmVisible && ( + { + hideEditConfirm(); + proceedWithRun(); + }} + onClose={hideEditConfirm} + /> + )} + + {isRunConfirmVisible && + taggedInstancesWithScriptsFeature.length === 0 && ( + +

+ {NO_TAGGED_FEATURED_INSTANCES_WARNING_MESSAGE} Select different + tags and try again. +

+
+ )} + + {isRunConfirmVisible && taggedInstancesWithScriptsFeature.length > 0 && ( 1 ? `${formik.values.tags.length} tags` : `${formik.values.tags[0]} tag`}`} + renderInPortal + title={`Run "${script.title}" script on ${formik.values.tags.length > 1 ? `${formik.values.tags.length} tags` : `${formik.values.tags[0]} tag`}`} confirmButtonLabel="Run script" onConfirm={() => { - formik.handleSubmit(); + hideRunConfirm(); + submitRun(formik.values); }} confirmButtonDisabled={ formik.isSubmitting || !!taggedInstancesError || - isGettingTaggedInstances + isGettingTaggedInstances || + !taggedInstancesWithScriptsFeature.length } confirmButtonLoading={isGettingTaggedInstances} - close={hideModal} + close={hideRunConfirm} confirmButtonAppearance="positive" >

This script will run on the following instances, which are associated with the selected{" "} - {formik.values.tags.length == 1 ? "tag" : "tags"}. + {pluralize(formik.values.tags.length, "tag")}.

-
)} diff --git a/src/features/scripts/components/RunScriptForm/constants.ts b/src/features/scripts/components/RunScriptForm/constants.ts index 8c43909b7..f3c3d90bc 100644 --- a/src/features/scripts/components/RunScriptForm/constants.ts +++ b/src/features/scripts/components/RunScriptForm/constants.ts @@ -2,7 +2,10 @@ import * as Yup from "yup"; import type { FormProps } from "./types"; import { deliveryValidationSchema } from "@/components/form/DeliveryScheduling"; -export const INITIAL_VALUES: FormProps = { +export const NO_TAGGED_FEATURED_INSTANCES_WARNING_MESSAGE = + "There are no instances with the selected tags that can run scripts."; + +export const INITIAL_VALUES: Omit = { deliver_after: "", deliver_immediately: true, instanceIds: [], @@ -29,4 +32,5 @@ export const VALIDATION_SCHEMA = Yup.object().shape({ then: (schema) => schema.min(1, "At least one tag is required."), }), username: Yup.string().required("This field is required."), + code: Yup.string().required("This field is required."), }); diff --git a/src/features/scripts/components/RunScriptForm/types.ts b/src/features/scripts/components/RunScriptForm/types.ts index 151895d79..b009161c7 100644 --- a/src/features/scripts/components/RunScriptForm/types.ts +++ b/src/features/scripts/components/RunScriptForm/types.ts @@ -1,4 +1,5 @@ export interface FormProps { + code: string; deliver_after: string; deliver_immediately: boolean; instanceIds: number[]; diff --git a/src/features/scripts/components/RunScriptFormInstanceList/RunScriptFormInstanceList.test.tsx b/src/features/scripts/components/RunScriptFormInstanceList/RunScriptFormInstanceList.test.tsx new file mode 100644 index 000000000..2a8edc524 --- /dev/null +++ b/src/features/scripts/components/RunScriptFormInstanceList/RunScriptFormInstanceList.test.tsx @@ -0,0 +1,131 @@ +import { instances } from "@/tests/mocks/instance"; +import { renderWithProviders } from "@/tests/render"; +import type { Instance } from "@/types/Instance"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import RunScriptFormInstanceList from "./RunScriptFormInstanceList"; + +const [firstInstance, secondInstance] = instances; + +describe("RunScriptFormInstanceList", () => { + it("should render the instance and associated tag columns", () => { + renderWithProviders( + , + ); + + expect( + screen.getByRole("columnheader", { name: /instance/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: /associated tag/i }), + ).toBeInTheDocument(); + }); + + it("should display instance titles", () => { + renderWithProviders( + , + ); + + expect(screen.getByText(firstInstance.title)).toBeInTheDocument(); + expect(screen.getByText(secondInstance.title)).toBeInTheDocument(); + }); + + it("should only show tags that match the provided tags prop", () => { + const instanceWithTags: Instance = { + ...firstInstance, + tags: ["appservers", "webfarm"], + }; + const nonMatchingTag = "non-existent-tag"; + + renderWithProviders( + , + ); + + expect(screen.getByText("appservers")).toBeInTheDocument(); + expect(screen.queryByText("webfarm")).not.toBeInTheDocument(); + expect(screen.queryByText(nonMatchingTag)).not.toBeInTheDocument(); + }); + + it("should not show tags that are not in the provided tags prop", () => { + renderWithProviders( + , + ); + + firstInstance.tags.forEach((tag) => { + expect(screen.queryByText(tag)).not.toBeInTheDocument(); + }); + }); + + it("should not render pagination when instances fit on one page", () => { + renderWithProviders( + , + ); + + expect(screen.queryByRole("navigation")).not.toBeInTheDocument(); + }); + + it("should render pagination when instances exceed page size", () => { + const manyInstances = Array.from({ length: 11 }, (_, i) => ({ + ...firstInstance, + id: i + 100, + title: `Instance ${i + 1}`, + })); + + renderWithProviders( + , + ); + + expect(screen.getByText(/page 1 of/i)).toBeInTheDocument(); + }); + + it("should navigate to the next page when next is clicked", async () => { + const user = userEvent.setup(); + const manyInstances = Array.from({ length: 11 }, (_, i) => ({ + ...firstInstance, + id: i + 100, + title: `Instance ${i + 1}`, + })); + + renderWithProviders( + , + ); + + expect(screen.getByText("Instance 1")).toBeInTheDocument(); + expect(screen.queryByText("Instance 11")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /next page/i })); + + expect(screen.queryByText("Instance 1")).not.toBeInTheDocument(); + expect(screen.getByText("Instance 11")).toBeInTheDocument(); + }); + + it("should navigate back to the previous page when previous is clicked", async () => { + const user = userEvent.setup(); + const manyInstances = Array.from({ length: 11 }, (_, i) => ({ + ...firstInstance, + id: i + 100, + title: `Instance ${i + 1}`, + })); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("button", { name: /next page/i })); + expect(screen.getByText("Instance 11")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /previous page/i })); + expect(screen.getByText("Instance 1")).toBeInTheDocument(); + expect(screen.queryByText("Instance 11")).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/scripts/components/RunScriptFormInstanceList/RunScriptFormInstanceList.tsx b/src/features/scripts/components/RunScriptFormInstanceList/RunScriptFormInstanceList.tsx index 5164e7241..9b6684e46 100644 --- a/src/features/scripts/components/RunScriptFormInstanceList/RunScriptFormInstanceList.tsx +++ b/src/features/scripts/components/RunScriptFormInstanceList/RunScriptFormInstanceList.tsx @@ -1,53 +1,113 @@ -import TablePaginationBase from "@/components/layout/TablePagination/components/TablePaginationBase"; -import { - DEFAULT_CURRENT_PAGE, - DEFAULT_PAGE_SIZE, -} from "@/libs/pageParamsManager/constants"; import type { Instance } from "@/types/Instance"; import { ModularTable } from "@canonical/react-components"; import { useMemo, useState, type FC } from "react"; import type { CellProps, Column } from "react-table"; -import classes from "./RunScriptFormInstanceList.module.scss"; +import TruncatedCell from "@/components/layout/TruncatedCell"; +import { useExpandableRow } from "@/hooks/useExpandableRow"; +import { getCellProps, getRowProps } from "./helpers"; +import { ModalTablePagination } from "@/components/layout/TablePagination"; +import { DEFAULT_MODAL_PAGE_SIZE } from "@/constants"; +import { DEFAULT_CURRENT_PAGE } from "@/libs/pageParamsManager/constants"; interface RunScriptFormInstanceListProps { readonly instances: Instance[]; + readonly tags: string[]; } const RunScriptFormInstanceList: FC = ({ instances, + tags, }) => { const [currentPage, setCurrentPage] = useState(DEFAULT_CURRENT_PAGE); - const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + + const { expandedRowIndex, expandedColumnId, getTableRowsRef, handleExpand } = + useExpandableRow(); + + const maxPage = Math.max( + 1, + Math.ceil(instances.length / DEFAULT_MODAL_PAGE_SIZE), + ); + const page = Math.min(currentPage, maxPage); + const offset = (page - 1) * DEFAULT_MODAL_PAGE_SIZE; + const currentInstances = instances.slice( + offset, + offset + DEFAULT_MODAL_PAGE_SIZE, + ); const columns = useMemo[]>( () => [ { Header: "Instance", + accessor: "title", Cell: ({ row: { original: instance } }: CellProps) => instance.title, }, + { + accessor: "tags", + Header: "Associated tag", + meta: { + isExpandable: true, + }, + Cell: ({ row: { original: instance, index } }: CellProps) => { + const instanceTags = instance.tags.filter((instanceTag) => + tags.some((tag) => tag === instanceTag), + ); + return ( + { + handleExpand(index, "tags"); + }} + content={instanceTags.map((tag) => ( + + {tag} + + ))} + showCount + /> + ); + }, + }, ], - [], + [tags, expandedColumnId, expandedRowIndex, handleExpand], ); - const offset = (currentPage - 1) * pageSize; + const closeExpandedRow = () => { + if (expandedRowIndex == null) { + return; + } - const currentInstances = instances.slice(offset, offset + pageSize); + handleExpand(expandedRowIndex, expandedColumnId ?? undefined); + }; return ( - <> - - - + - + + {maxPage > 1 && ( + { + closeExpandedRow(); + setCurrentPage((prevState) => Math.min(prevState + 1, maxPage)); + }} + onPrev={() => { + closeExpandedRow(); + setCurrentPage((prevState) => + Math.max(prevState - 1, DEFAULT_CURRENT_PAGE), + ); + }} + /> + )} +
); }; diff --git a/src/features/scripts/components/RunScriptFormInstanceList/helpers.ts b/src/features/scripts/components/RunScriptFormInstanceList/helpers.ts new file mode 100644 index 000000000..08fcc5d57 --- /dev/null +++ b/src/features/scripts/components/RunScriptFormInstanceList/helpers.ts @@ -0,0 +1,7 @@ +import type { Instance } from "@/types/Instance"; +import { createTablePropGetters } from "@/utils/table"; + +export const { getCellProps, getRowProps } = createTablePropGetters({ + itemTypeName: "script instance", + headerColumnId: "title", +}); diff --git a/src/features/scripts/components/ScriptDetails/ScriptDetails.test.tsx b/src/features/scripts/components/ScriptDetails/ScriptDetails.test.tsx index 2fc191dc5..6cec50a0c 100644 --- a/src/features/scripts/components/ScriptDetails/ScriptDetails.test.tsx +++ b/src/features/scripts/components/ScriptDetails/ScriptDetails.test.tsx @@ -18,12 +18,22 @@ const redactedScriptId = detailedScriptsData.find( (script) => script.status === "REDACTED", )?.id; +const notExecutableScriptId = detailedScriptsData.find( + (script) => script.status === "ACTIVE" && !script.is_executable, +)?.id; + +const notRedactableScriptId = detailedScriptsData.find( + (script) => script.status === "ACTIVE" && !script.is_redactable, +)?.id; + describe("ScriptDetails", () => { const user = userEvent.setup(); assert(archivedScriptId); assert(activeScriptId); assert(redactedScriptId); + assert(notExecutableScriptId); + assert(notRedactableScriptId); it("should display details for an active script", async () => { const { container } = renderWithProviders( @@ -32,10 +42,46 @@ describe("ScriptDetails", () => { await expectLoadingState(); - const buttons = ["Edit", "Archive"]; + const buttons = ["Edit", "Run", "Archive", "Delete"]; expect(container).toHaveTexts(buttons); }); + it("should disable the Run button when the script is not executable", async () => { + renderWithProviders(); + + await expectLoadingState(); + + const runButton = screen.getByRole("button", { name: /run/i }); + expect(runButton).toHaveAttribute("aria-disabled", "true"); + }); + + it("should disable the Delete button when the script is not redactable", async () => { + renderWithProviders(); + + await expectLoadingState(); + + const deleteButton = screen.getByRole("button", { + name: /delete new v2 script/i, + }); + expect(deleteButton).toHaveAttribute("aria-disabled", "true"); + }); + + it("opens delete confirmation modal when clicking Delete button", async () => { + renderWithProviders(); + + await expectLoadingState(); + + const deleteButton = screen.getByRole("button", { + name: /delete new v2 script/i, + }); + await user.click(deleteButton); + + const modalBody = screen.getByText( + /deleting the script will remove the contents from Landscape/i, + ); + expect(modalBody).toBeInTheDocument(); + }); + it("should display details for an archived script", async () => { renderWithProviders(); diff --git a/src/features/scripts/components/ScriptDetails/ScriptDetails.tsx b/src/features/scripts/components/ScriptDetails/ScriptDetails.tsx index 975aa80bd..c7ccab520 100644 --- a/src/features/scripts/components/ScriptDetails/ScriptDetails.tsx +++ b/src/features/scripts/components/ScriptDetails/ScriptDetails.tsx @@ -4,16 +4,18 @@ import { DISPLAY_DATE_TIME_FORMAT } from "@/constants"; import { useGetSingleScript } from "@/features/scripts"; import useDebug from "@/hooks/useDebug"; import useSidePanel from "@/hooks/useSidePanel"; -import { Button, Icon, Notification } from "@canonical/react-components"; +import { Button, Icon, ICONS, Notification } from "@canonical/react-components"; import moment from "moment"; import type { FC } from "react"; -import { lazy, Suspense, useState } from "react"; -import { useArchiveScriptModal } from "../../hooks"; +import { lazy, Suspense } from "react"; +import { useArchiveScriptModal, useDeleteScriptModal } from "../../hooks"; import type { ScriptTabId } from "../../types"; +import { useBoolean } from "usehooks-ts"; const ScriptDetailsTabs = lazy(async () => import("../ScriptDetailsTabs")); const EditScriptForm = lazy(async () => import("../EditScriptForm")); +const RunScriptForm = lazy(async () => import("../RunScriptForm")); interface ScriptDetailsProps { readonly scriptId: number; @@ -24,21 +26,23 @@ const ScriptDetails: FC = ({ scriptId, initialTabId = "info", }) => { - const [modalOpen, setModalOpen] = useState(false); + const { + setTrue: openModal, + setFalse: closeModal, + value: modalOpen, + } = useBoolean(); + + const { + value: deleteModalOpen, + setTrue: openDeleteModal, + setFalse: closeDeleteModal, + } = useBoolean(); const { setSidePanelContent } = useSidePanel(); const debug = useDebug(); const { script } = useGetSingleScript(scriptId); - const handleOpenModal = (): void => { - setModalOpen(true); - }; - - const handleCloseModal = (): void => { - setModalOpen(false); - }; - const { archiveModalBody, archiveModalButtonLabel, @@ -47,7 +51,18 @@ const ScriptDetails: FC = ({ onConfirmArchive, } = useArchiveScriptModal({ script, - afterSuccess: handleCloseModal, + afterSuccess: closeModal, + }); + + const { + deleteModalBody, + deleteModalButtonLabel, + deleteModalTitle, + isRemoving, + onConfirmDelete, + } = useDeleteScriptModal({ + script, + afterSuccess: closeDeleteModal, }); const viewVersionHistory = (): void => { @@ -64,15 +79,44 @@ const ScriptDetails: FC = ({ ); }; + const handleBackToDetails = (): void => { + if (script === null) { + debug("Script not loaded"); + return; + } + + setSidePanelContent( + script.title, + }> + + , + ); + }; + const handleEditScript = (): void => { if (script === null) { debug("Script not loaded"); return; } + setSidePanelContent( `Edit "${script.title}" script`, }> - + + , + ); + }; + + const handleRunScript = (): void => { + if (script === null) { + debug("Script not loaded"); + return; + } + + setSidePanelContent( + `Run "${script.title}" script`, + }> + , ); }; @@ -111,16 +155,39 @@ const ScriptDetails: FC = ({ Edit + + + +
@@ -146,10 +213,24 @@ const ScriptDetails: FC = ({ confirmButtonDisabled={isArchivingScript} confirmButtonLoading={isArchivingScript} onConfirm={onConfirmArchive} - close={handleCloseModal} + close={closeModal} > <>{archiveModalBody} + + + {deleteModalBody} + ); }; diff --git a/src/features/scripts/helpers.ts b/src/features/scripts/helpers.ts index 8a3490ee9..a07cdb246 100644 --- a/src/features/scripts/helpers.ts +++ b/src/features/scripts/helpers.ts @@ -4,7 +4,7 @@ import type { CreateScriptAttachmentParams } from "./api"; import { DISPLAY_DATE_TIME_FORMAT } from "@/constants"; import moment from "moment"; -const getEncodedCode = (code: string) => { +export const getEncodedCode = (code: string) => { const escapedCode = JSON.parse(JSON.stringify(code).replace(/\\r/g, "")); return Buffer.from(escapedCode).toString("base64"); diff --git a/src/features/security-profiles/api/useGetSecurityProfileAuditDownload.ts b/src/features/security-profiles/api/useGetSecurityProfileAuditDownload.ts index f6e23abbd..97d952acd 100644 --- a/src/features/security-profiles/api/useGetSecurityProfileAuditDownload.ts +++ b/src/features/security-profiles/api/useGetSecurityProfileAuditDownload.ts @@ -11,12 +11,15 @@ export const useGetSecurityProfileAuditDownload = () => { const authFetch = useFetch(); const { isPending, mutateAsync } = useMutation< - AxiosResponse, + AxiosResponse, AxiosError, GetSecurityProfileAuditDownloadParams >({ mutationFn: async ({ path }) => - authFetch.get(`security-profiles/blob?path=${path}`), + authFetch.get("security-profiles/blob", { + params: { path }, + responseType: "blob", + }), }); return { diff --git a/src/features/security-profiles/components/SecurityProfileDetailsSidePanel/SecurityProfileDetailsSidePanel.tsx b/src/features/security-profiles/components/SecurityProfileDetailsSidePanel/SecurityProfileDetailsSidePanel.tsx index 4bac35a2d..ae38d7fdf 100644 --- a/src/features/security-profiles/components/SecurityProfileDetailsSidePanel/SecurityProfileDetailsSidePanel.tsx +++ b/src/features/security-profiles/components/SecurityProfileDetailsSidePanel/SecurityProfileDetailsSidePanel.tsx @@ -18,6 +18,7 @@ import { SECURITY_PROFILE_MODE_LABELS, } from "../../constants"; import { getSchedule, getStatus, getTailoringFile } from "../../helpers"; +import { useSecurityProfileDownload } from "../../hooks/useSecurityProfileDownload"; import SecurityProfileArchiveModal from "../SecurityProfileArchiveModal"; const SecurityProfileDetailsSidePanel: FC = () => { @@ -27,6 +28,7 @@ const SecurityProfileDetailsSidePanel: FC = () => { const { securityProfile: profile, isGettingSecurityProfile } = useGetPageSecurityProfile(); const profileLimitReached = useIsSecurityProfilesLimitReached(); + const downloadSecurityProfileFile = useSecurityProfileDownload("tailoring"); const { data: accessGroupsData, isPending: isGettingAccessGroups } = getAccessGroupQuery(); @@ -129,7 +131,7 @@ const SecurityProfileDetailsSidePanel: FC = () => { ({ type: "okay" }); diff --git a/src/features/security-profiles/components/SecurityProfilesContainer/SecurityProfilesContainer.tsx b/src/features/security-profiles/components/SecurityProfilesContainer/SecurityProfilesContainer.tsx index fbf8a3dde..6b4fd6b05 100644 --- a/src/features/security-profiles/components/SecurityProfilesContainer/SecurityProfilesContainer.tsx +++ b/src/features/security-profiles/components/SecurityProfilesContainer/SecurityProfilesContainer.tsx @@ -14,7 +14,7 @@ import { useGetSecurityProfiles, useIsSecurityProfilesLimitReached, } from "../../api"; -import { useSecurityProfileDownloadAudit } from "../../hooks/useSecurityProfileDownloadAudit"; +import { useSecurityProfileDownload } from "../../hooks/useSecurityProfileDownload"; import SecurityProfilesHeader from "../SecurityProfilesHeader"; import SecurityProfilesList from "../SecurityProfilesList"; @@ -79,7 +79,7 @@ const SecurityProfilesContainer: FC = ({ { enabled: !!pendingReports.length }, ); - const downloadAudit = useSecurityProfileDownloadAudit(); + const downloadAudit = useSecurityProfileDownload("audit"); const [ isProfileLimitNotificationIgnored, diff --git a/src/features/security-profiles/helpers.tsx b/src/features/security-profiles/helpers.tsx index c6cac7906..45b8667cd 100644 --- a/src/features/security-profiles/helpers.tsx +++ b/src/features/security-profiles/helpers.tsx @@ -42,20 +42,31 @@ export const getTags = (profile: SecurityProfile) => ? "All instances" : profile.tags.join(", ") || ; -export const getTailoringFile = (profile: SecurityProfile) => { +export const getTailoringFile = ( + profile: SecurityProfile, + downloadFile: (path: string | null, filename?: string) => Promise, +) => { if (!profile.tailoring_file_uri) { return ; } - const match = profile.tailoring_file_uri.match(/[^/]+$/); + const pathWithoutQuery = profile.tailoring_file_uri.split(/[?#]/)[0] ?? ""; + const match = pathWithoutQuery.match(/[^/]+$/); + const filename = match ? match[0] : "tailoring-file.xml"; return ( diff --git a/src/features/security-profiles/hooks/useSecurityProfileDownload.tsx b/src/features/security-profiles/hooks/useSecurityProfileDownload.tsx new file mode 100644 index 000000000..9ad872ff6 --- /dev/null +++ b/src/features/security-profiles/hooks/useSecurityProfileDownload.tsx @@ -0,0 +1,133 @@ +import useDebug from "@/hooks/useDebug"; +import { useGetSecurityProfileAuditDownload } from "../api"; + +type SecurityProfileDownloadMode = "audit" | "tailoring"; + +const getFilenameFromPath = (path: string) => { + const cleanPath = path.split(/[?#]/)[0] ?? ""; + const filename = cleanPath.slice(cleanPath.lastIndexOf("/") + 1); + return filename || null; +}; + +const getFilenameFromContentDisposition = (contentDisposition?: string) => { + if (!contentDisposition) { + return null; + } + + const filenameStarMatch = contentDisposition.match(/filename\*=([^;]+)/i); + if (filenameStarMatch?.[1]) { + const value = filenameStarMatch[1].trim(); + const encodedValue = value.includes("''") + ? (value.split("''")[1] ?? value) + : value; + return decodeURIComponent(encodedValue).replace(/^"|"$/g, ""); + } + + const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i); + if (filenameMatch?.[1]) { + return filenameMatch[1]; + } + + return null; +}; + +const decodeBase64ToBlob = (value: string, mimeType: string) => { + try { + const cleaned = value.replace(/\s+/g, ""); + if (!cleaned) { + return null; + } + + const binary = atob(cleaned); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return new Blob([bytes], { type: mimeType }); + } catch { + return null; + } +}; + +const withExtension = ( + filename: string, + mimeType: string, + mode: SecurityProfileDownloadMode, +) => { + if (/\.[^./]+$/.test(filename)) { + return filename; + } + + if (mode === "tailoring") { + return `${filename}.xml`; + } + + if (mimeType.includes("csv")) { + return `${filename}.csv`; + } + + return filename; +}; + +export const useSecurityProfileDownload = ( + mode: SecurityProfileDownloadMode, +) => { + const debug = useDebug(); + const { getSecurityProfileAuditDownload } = + useGetSecurityProfileAuditDownload(); + + return async (path: string | null, filename?: string) => { + if (!path) { + debug(new Error("Could not download file because no path was provided.")); + return; + } + + try { + const { data, headers } = await getSecurityProfileAuditDownload({ + path, + }); + + if (data.type.includes("text/html")) { + throw new Error( + "Received HTML instead of the expected downloadable file.", + ); + } + + const decodedBlob = decodeBase64ToBlob( + await data.text(), + mode === "tailoring" ? "application/xml" : "text/csv;charset=utf-8", + ); + + const fileToDownload = decodedBlob ?? data; + + const url = URL.createObjectURL(fileToDownload); + const link = document.createElement("a"); + + link.href = url; + + const headerFilename = getFilenameFromContentDisposition( + headers["content-disposition"], + ); + const pathFilename = getFilenameFromPath(path); + const resolvedFilename = + filename ?? + headerFilename ?? + pathFilename ?? + (mode === "tailoring" ? "tailoring-file" : "download"); + + link.download = withExtension( + resolvedFilename, + fileToDownload.type, + mode, + ); + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (error) { + debug(error); + } + }; +}; diff --git a/src/features/security-profiles/hooks/useSecurityProfileDownloadAudit.tsx b/src/features/security-profiles/hooks/useSecurityProfileDownloadAudit.tsx deleted file mode 100644 index 48388a673..000000000 --- a/src/features/security-profiles/hooks/useSecurityProfileDownloadAudit.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useGetSecurityProfileAuditDownload } from "../api"; - -export const useSecurityProfileDownloadAudit = () => { - const { getSecurityProfileAuditDownload } = - useGetSecurityProfileAuditDownload(); - - return async (path: string | null) => { - if (!path) { - throw new Error(); - } - - const { data } = await getSecurityProfileAuditDownload({ - path, - }); - - const blob = new Blob([data], { - type: "text/csv;charset=utf-8;", - }); - - const url = URL.createObjectURL(blob); - - const link = document.createElement("a"); - - link.href = url; - - link.download = path.slice(path.lastIndexOf("/") + 1); - - document.body.appendChild(link); - - link.click(); - - document.body.removeChild(link); - }; -}; diff --git a/src/tests/mocks/script.ts b/src/tests/mocks/script.ts index 0d0285953..65f7b8879 100644 --- a/src/tests/mocks/script.ts +++ b/src/tests/mocks/script.ts @@ -326,6 +326,18 @@ export const detailedScriptsData = [ code: "#!/bin/shell\nls /tmp", version_number: 1, }, + { + ...scripts[0], + id: 100, + is_executable: false, + version_number: 1, + }, + { + ...scripts[0], + id: 101, + is_redactable: false, + version_number: 1, + }, ] as const; export const scriptVersions: TruncatedScriptVersion[] = [ @@ -416,3 +428,6 @@ export const scriptVersion = { interpreter: "shell", title: "List temporary files", }; + +export const scriptAttachment = "attachment file"; +export const scriptAttachmentHtml = ""; diff --git a/src/tests/server/handlers/script.ts b/src/tests/server/handlers/script.ts index c028a3eed..c3351221a 100644 --- a/src/tests/server/handlers/script.ts +++ b/src/tests/server/handlers/script.ts @@ -1,13 +1,19 @@ -import { API_URL } from "@/constants"; +import { API_URL, API_URL_OLD } from "@/constants"; import { getEndpointStatus } from "@/tests/controllers/controller"; import { detailedScriptsData, + scriptAttachment, + scriptAttachmentHtml, scripts, scriptVersion, scriptVersionsWithPagination, } from "@/tests/mocks/script"; import { scriptProfiles } from "@/tests/mocks/scriptProfiles"; -import { generatePaginatedResponse } from "@/tests/server/handlers/_helpers"; +import { activities } from "@/tests/mocks/activity"; +import { + generatePaginatedResponse, + isAction, +} from "@/tests/server/handlers/_helpers"; import { http, HttpResponse } from "msw"; export default [ @@ -49,7 +55,26 @@ export default [ }), http.get(`${API_URL}scripts/:id/script-profiles`, async () => { - return HttpResponse.json({ script_profiles: scriptProfiles }); + const endpointStatus = getEndpointStatus(); + if ( + (!endpointStatus.path || + endpointStatus.path.includes("script-profiles")) && + endpointStatus.status === "empty" + ) { + return HttpResponse.json({ + results: [], + count: 0, + next: null, + previous: null, + }); + } + return HttpResponse.json( + generatePaginatedResponse({ + data: scriptProfiles, + limit: 20, + offset: 0, + }), + ); }), http.get(`${API_URL}scripts/:id`, async ({ params }) => { @@ -65,8 +90,25 @@ export default [ return HttpResponse.json(scriptVersion); }), - http.get(`${API_URL}scripts-attachment/:id`, async () => { - return HttpResponse.json("attachment"); + http.get(`${API_URL}scripts/:id/attachments/:attachmentId`, async () => { + const endpointStatus = getEndpointStatus(); + + if ( + endpointStatus.path && + endpointStatus.path.includes("scripts/attachments/html") + ) { + return new HttpResponse(scriptAttachmentHtml, { + headers: { + "Content-Type": "text/html", + }, + }); + } + + return new HttpResponse(scriptAttachment, { + headers: { + "Content-Type": "text/plain", + }, + }); }), http.get(`${API_URL}scripts/:id/versions`, async ({ request }) => { @@ -85,4 +127,32 @@ export default [ }), ); }), + + http.get(API_URL_OLD, ({ request }) => { + if (!isAction(request, "EditScript")) { + return; + } + return HttpResponse.json({}); + }), + + http.post(API_URL_OLD, ({ request }) => { + if (!isAction(request, "EditScript")) { + return; + } + return HttpResponse.json({}); + }), + + http.post(API_URL_OLD, ({ request }) => { + if (!isAction(request, "CreateScriptAttachment")) { + return; + } + return HttpResponse.json({}); + }), + + http.get(API_URL_OLD, ({ request }) => { + if (!isAction(request, "ExecuteScript")) { + return; + } + return HttpResponse.json(activities[0]); + }), ]; From 629dc56df6f94bcf2cebbc7c6ae621f52cb59a02 Mon Sep 17 00:00:00 2001 From: Joey Mucci Date: Fri, 17 Apr 2026 13:22:12 -0400 Subject: [PATCH 21/42] Soft deletion [LNDENG-3984] (#547) ## Summary Adds support for the v2 deletion endpoint. I forked off Ethan's [branch](https://github.com/canonical/landscape-ui/pull/540) and changed a couple lines as a result of the manual testing. ## Release Impact According to the [Landscape Server Release Cycle](https://docs.google.com/document/d/1sKAp5IvArpfArhMNojFwKOHm9LEdHKB4Et6tu1_-0GY/edit?tab=t.0), this change will target the following release cycle: - **Target Branch**: `dev` / `main` (Beta) - **Version Impact**: - [ ] Patch (Fix) - [x] Minor (Feature) - [ ] Major (Breaking) ## Checklist - [x] **Changeset Added**: I have run `pnpm changeset` and committed the resulting `.md` file. - [x] **UI Verified**: I have verified the changes locally. - [x] **Linting**: No linting errors are present (especially in `scripts/`). ## Versioning Reminder > [!IMPORTANT] > This repository now uses **CalVer** ($YY.0M.Point.Patch$). > Please ensure your changeset description is clear, as it will be automatically added to the `CHANGELOG.md` upon merging to `main`. --------- Co-authored-by: Ethan Shaw --- .changeset/thirty-adults-rule.md | 5 +++++ .../api/useRemoveInstancesFromLandscape.ts | 19 ++++++++++++++++--- .../InstanceRemoveFromLandscapeModal.tsx | 4 +++- .../InstancesPageActions.tsx | 2 +- .../UbuntuProHeader/UbuntuProHeader.tsx | 2 +- src/tests/mocks/features.ts | 15 ++++++++++++++- src/tests/server/handlers/instance.ts | 5 +++++ src/types/FeatureKey.ts | 11 ++++++----- 8 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 .changeset/thirty-adults-rule.md diff --git a/.changeset/thirty-adults-rule.md b/.changeset/thirty-adults-rule.md new file mode 100644 index 000000000..77e691035 --- /dev/null +++ b/.changeset/thirty-adults-rule.md @@ -0,0 +1,5 @@ +--- +"landscape-ui": minor +--- + +Support for v2 deletion endpoint diff --git a/src/features/instances/api/useRemoveInstancesFromLandscape.ts b/src/features/instances/api/useRemoveInstancesFromLandscape.ts index d34aa4e13..42e136654 100644 --- a/src/features/instances/api/useRemoveInstancesFromLandscape.ts +++ b/src/features/instances/api/useRemoveInstancesFromLandscape.ts @@ -1,3 +1,5 @@ +import useAuth from "@/hooks/useAuth"; +import useFetch from "@/hooks/useFetch"; import useFetchOld from "@/hooks/useFetchOld"; import type { ApiError } from "@/types/api/ApiError"; import type { Instance } from "@/types/Instance"; @@ -8,17 +10,28 @@ export interface RemoveInstancesParams { computer_ids: number[]; } +// Currently determining which endpoint to use with a feature flag export const useRemoveInstancesFromLandscape = () => { - const authFetchOld = useFetchOld(); + const { isFeatureEnabled } = useAuth(); const queryClient = useQueryClient(); + const authFetch = useFetch(); + const authFetchOld = useFetchOld(); + const isComputerSoftDeletionEnabled = isFeatureEnabled( + "computer-soft-deletion", + ); const { isPending, mutateAsync } = useMutation< AxiosResponse, AxiosError, RemoveInstancesParams >({ - mutationFn: async (params) => - authFetchOld.get("RemoveComputers", { params }), + mutationFn: async (params: RemoveInstancesParams) => { + if (isComputerSoftDeletionEnabled) { + return authFetch.post("/computers:delete", params); + } + + return authFetchOld.get("RemoveComputers", { params }); + }, onSuccess: async () => queryClient.invalidateQueries({ queryKey: ["instances"] }), }); diff --git a/src/features/instances/components/InstanceRemoveFromLandscapeModal/InstanceRemoveFromLandscapeModal.tsx b/src/features/instances/components/InstanceRemoveFromLandscapeModal/InstanceRemoveFromLandscapeModal.tsx index 26a6b8a86..5269bc874 100644 --- a/src/features/instances/components/InstanceRemoveFromLandscapeModal/InstanceRemoveFromLandscapeModal.tsx +++ b/src/features/instances/components/InstanceRemoveFromLandscapeModal/InstanceRemoveFromLandscapeModal.tsx @@ -28,6 +28,8 @@ const InstanceRemoveFromLandscapeModal: FC< `instances`, ); + const title = `Remove ${label} from Landscape`; + const removeFromLandscape = async () => { try { await removeInstancesFromLandscape({ @@ -56,7 +58,7 @@ const InstanceRemoveFromLandscapeModal: FC< Attach token, onClick: handleAttachToken, }, - isFeatureEnabled("ubuntu_pro_licensing") + isFeatureEnabled("ubuntu-pro-licensing") ? { children: Detach token, onClick: openDetachModal, diff --git a/src/features/ubuntupro/components/UbuntuProHeader/UbuntuProHeader.tsx b/src/features/ubuntupro/components/UbuntuProHeader/UbuntuProHeader.tsx index 98c971fbc..3dbbbe972 100644 --- a/src/features/ubuntupro/components/UbuntuProHeader/UbuntuProHeader.tsx +++ b/src/features/ubuntupro/components/UbuntuProHeader/UbuntuProHeader.tsx @@ -66,7 +66,7 @@ const UbuntuProHeader: FC = ({ instance }) => { icon: "delete", label: "Detach token", onClick: openDetachModal, - excluded: !isFeatureEnabled("ubuntu_pro_licensing"), + excluded: !isFeatureEnabled("ubuntu-pro-licensing"), }, ], }} diff --git a/src/tests/mocks/features.ts b/src/tests/mocks/features.ts index 2411ba4cd..936af1908 100644 --- a/src/tests/mocks/features.ts +++ b/src/tests/mocks/features.ts @@ -70,7 +70,7 @@ export const features: Feature[] = [ name: "Ubuntu Pro Licensing", description: "Allows attaching, replacing, and detaching Ubuntu Pro tokens to/from instances.", - key: "ubuntu_pro_licensing", + key: "ubuntu-pro-licensing", database_key: 8, enabled: true, details: { @@ -89,4 +89,17 @@ export const features: Feature[] = [ account: false, }, }, + { + name: "Computer Soft Deletion", + description: + "Soft delete computers instead of hard deleting them. Soft-deleted computers still exist in the database, but will be filtered out and eventually removed.", + key: "computer-soft-deletion", + database_key: 12, + enabled: true, + details: { + configuration: true, + // self_hosted_default: false, + account: true, + }, + }, ]; diff --git a/src/tests/server/handlers/instance.ts b/src/tests/server/handlers/instance.ts index 1cb32e5e4..f5c385963 100644 --- a/src/tests/server/handlers/instance.ts +++ b/src/tests/server/handlers/instance.ts @@ -451,6 +451,11 @@ export default [ }, ), + http.post(`${API_URL}computers\\:delete`, async () => { + await delay(); + return HttpResponse.json(); + }), + http.get(API_URL_OLD, async ({ request }) => { if (!isAction(request, ["AddTagsToComputers"])) { return; diff --git a/src/types/FeatureKey.ts b/src/types/FeatureKey.ts index 5f824df71..0e3cad041 100644 --- a/src/types/FeatureKey.ts +++ b/src/types/FeatureKey.ts @@ -1,9 +1,10 @@ export type FeatureKey = - | "oidc-configuration" - | "spa-dashboard" + | "computer-soft-deletion" | "employee-management" + | "oidc-configuration" | "script-profiles" - | "usg-profiles" + | "spa-dashboard" | "support-provider-login" - | "wsl-child-instance-profiles" - | "ubuntu_pro_licensing"; + | "ubuntu-pro-licensing" + | "usg-profiles" + | "wsl-child-instance-profiles"; From d9b1d1bf1ca048bb0f87ffe07ca4b797e34892c2 Mon Sep 17 00:00:00 2001 From: Rubin Aga <66167934+rubinaga@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:02:59 +0200 Subject: [PATCH 22/42] Rebase changes from main into deb-archive test: add tests for all non profile related files with under 60% coverage (#537) This PR adds code coverage for all non profile related files with under 60% coverage. According to the [Landscape Server Release Cycle](https://docs.google.com/document/d/1sKAp5IvArpfArhMNojFwKOHm9LEdHKB4Et6tu1_-0GY/edit?tab=t.0), this change will target the following release cycle: - **Target Branch**: `dev` / `main` (Beta) - **Version Impact**: - [ ] Patch (Fix) - [ ] Minor (Feature) - [ ] Major (Breaking) - [ ] **Changeset Added**: I have run `pnpm changeset` and committed the resulting `.md` file. - [ ] **UI Verified**: I have verified the changes locally. - [ ] **Linting**: No linting errors are present (especially in `scripts/`). > [!IMPORTANT] > This repository now uses **CalVer** ($YY.0M.Point.Patch$). > Please ensure your changeset description is clear, as it will be automatically added to the `CHANGELOG.md` upon merging to `main`. --- src/App.test.tsx | 15 + .../SearchQueryEditor.test.tsx | 86 +++++ src/components/form/CheckboxGroup.test.tsx | 53 +++ src/components/form/CodeEditor.test.tsx | 19 ++ src/components/form/CodeEditorInner.test.tsx | 101 ++++++ .../form/FileInput/FileInput.test.tsx | 83 ++++- src/components/form/monacoMock.test.tsx | 65 ++++ .../guards/__tests__/SelfHostedGuard.test.tsx | 70 ++++ .../layout/GlobalShell/GlobalShell.test.tsx | 55 +-- src/context/auth.test.tsx | 256 ++++++++++++++ .../AlertTagsCell/AlertTagsCell.test.tsx | 121 ++++++- src/features/alerts/index.ts | 7 +- src/features/api-credentials/index.ts | 1 + .../OTPInputContainer.test.tsx | 80 ++++- .../OTPInputContainer/OTPInputContainer.tsx | 2 +- src/features/auth/helpers.test.ts | 76 +++++ .../AutoinstallFileForm.test.tsx | 256 ++++++++++++-- .../AutoinstallFileForm.tsx | 1 + .../EmployeeDropdown.test.tsx | 188 ++++++++-- .../NewGPGKeyForm/NewGPGKeyForm.test.tsx | 94 +++++ .../components/KernelOverview/helpers.test.ts | 104 ++++++ .../RestartInstanceForm.test.tsx | 135 +++++++- .../EditPocketForm/EditPocketForm.test.tsx | 194 +++++++++++ .../components/EditPocketForm/helpers.test.ts | 132 +++++++ .../components/EditPocketForm/helpers.ts | 2 +- .../PackageList/PackageList.test.tsx | 235 ++++++++++++- .../components/PackageList/PackageList.tsx | 21 +- .../components/AlertCard/AlertCard.test.tsx | 63 +++- .../PackageProfileAddSidePanel.test.tsx | 3 +- .../ProcessesHeader/ProcessesHeader.test.tsx | 130 ++++++- .../AttachmentFile/AttachmentFile.test.tsx | 82 +++-- .../AttachmentFile/AttachmentFile.tsx | 4 +- .../CreateScriptForm.test.tsx | 173 +++++++++- .../CreateScriptForm/CreateScriptForm.tsx | 7 +- .../EditScriptForm/EditScriptForm.test.tsx | 301 +++++++++++++++- .../EditScriptForm/EditScriptForm.tsx | 1 + .../RunScriptForm/RunScriptForm.test.tsx | 9 +- .../RunScriptForm/RunScriptForm.tsx | 14 +- .../ScriptFormAttachments.tsx | 4 +- src/features/scripts/helpers.test.ts | 104 ++++++ .../SecurityProfileDetailsSidePanel.test.tsx | 3 +- .../SecurityProfileDownloadAuditForm.tsx | 4 +- .../components/EditSnap/helpers.test.tsx | 123 +++++++ .../snaps/components/EditSnap/helpers.tsx | 1 + .../SnapsActions/SnapsActions.test.tsx | 180 ++++++++++ .../DetachTokenModal.test.tsx | 114 +++++++ .../components/Upgrades/Upgrades.test.tsx | 120 ++++++- .../upgrades/components/Upgrades/Upgrades.tsx | 12 +- .../WelcomePopup/WelcomePopup.test.tsx | 22 +- .../WslInstancesHeader.test.tsx | 132 ++++++- src/main.test.tsx | 100 ++++++ src/main.tsx | 63 +++- src/pages/PageNotFound.test.tsx | 21 ++ .../dashboard/account/AccountPage.test.tsx | 29 ++ .../account/alerts/AlertsPage.test.tsx | 26 ++ .../dashboard/account/alerts/AlertsPage.tsx | 13 +- .../api-credentials/ApiCredentials.test.tsx | 23 ++ .../activities/ActivitiesPage.test.tsx | 38 +++ .../instances/InstancesPage/helpers.test.ts | 86 +++++ .../instances/ReportForm/ReportForm.test.tsx | 61 ++++ .../SingleInstanceContainer.test.tsx | 152 +++++++-- .../SingleInstanceContainer/helpers.test.ts | 203 +++++++++++ .../AssignEmployeeToInstanceForm.test.tsx | 63 +++- .../info/EditInstance/EditInstance.test.tsx | 200 +++++++++++ .../packages/PackagesPanel/helpers.test.ts | 22 ++ .../users/EditUserForm/EditUserForm.test.tsx | 159 ++++++++- .../tabs/users/UserList/UserList.test.tsx | 93 ++++- .../tabs/users/UserList/helpers.test.ts | 32 ++ .../UserPanelActionButtons.test.tsx | 321 +++++++++++++++++- .../UserPanelActionButtons/helpers.test.tsx | 138 ++++++++ .../users/UserPanelActionButtons/helpers.tsx | 17 +- .../dashboard/overview/OverviewPage.test.tsx | 26 ++ .../dashboard/repositories/RepositoryPage.tsx | 9 - .../LocalRepositoriesPage.tsx | 3 +- .../local-repositories/constants.ts | 2 + .../mirrors/DistributionsPage.test.tsx | 97 ++++++ .../publications/PublicationsPage.tsx | 3 +- .../dashboard/settings/SettingsPage.test.tsx | 27 ++ .../access-group/AccessGroupsPage.test.tsx | 59 ++++ .../roles/EditRoleForm/EditRoleForm.test.tsx | 144 +++++++- .../roles/EditRoleForm/helpers.test.ts | 153 ++++++++- src/routes/AuthRoutes.test.tsx | 74 ++++ src/routes/elements.test.tsx | 131 +++++++ src/tests/browser.ts | 6 +- src/tests/mocks/alerts.ts | 12 + src/tests/mocks/instance.ts | 5 +- src/tests/mocks/wsl.ts | 4 +- src/tests/monacoMock.tsx | 65 +++- src/tests/server/handlers/_constants.ts | 17 +- src/tests/server/handlers/accessGroup.ts | 8 + src/tests/server/handlers/activity.ts | 20 +- src/tests/server/handlers/alerts.ts | 51 ++- src/tests/server/handlers/aptSource.ts | 19 +- src/tests/server/handlers/auth.ts | 23 +- src/tests/server/handlers/autoinstallFiles.ts | 27 ++ src/tests/server/handlers/employees.ts | 41 +++ src/tests/server/handlers/gpgKey.ts | 31 ++ src/tests/server/handlers/index.ts | 2 + src/tests/server/handlers/instance.ts | 94 ++++- src/tests/server/handlers/kernel.ts | 15 + src/tests/server/handlers/pockets.ts | 134 +++++++- src/tests/server/handlers/process.ts | 26 +- src/tests/server/handlers/reports.ts | 23 ++ src/tests/server/handlers/roles.ts | 63 +++- src/tests/server/handlers/script.ts | 117 +++++-- src/tests/server/handlers/tag.ts | 30 +- src/tests/server/handlers/user.ts | 87 +++++ src/tests/server/handlers/usn.ts | 13 + src/tests/server/handlers/wsl.ts | 11 + src/types/Instance.ts | 1 + 110 files changed, 7230 insertions(+), 361 deletions(-) create mode 100644 src/App.test.tsx create mode 100644 src/components/filter/SearchQueryEditor/SearchQueryEditor.test.tsx create mode 100644 src/components/form/CheckboxGroup.test.tsx create mode 100644 src/components/form/CodeEditor.test.tsx create mode 100644 src/components/form/CodeEditorInner.test.tsx create mode 100644 src/components/form/monacoMock.test.tsx create mode 100644 src/components/guards/__tests__/SelfHostedGuard.test.tsx create mode 100644 src/context/auth.test.tsx create mode 100644 src/features/auth/helpers.test.ts create mode 100644 src/features/kernel/components/KernelOverview/helpers.test.ts create mode 100644 src/features/mirrors/components/EditPocketForm/helpers.test.ts create mode 100644 src/features/scripts/helpers.test.ts create mode 100644 src/features/snaps/components/EditSnap/helpers.test.tsx create mode 100644 src/main.test.tsx create mode 100644 src/pages/PageNotFound.test.tsx create mode 100644 src/pages/dashboard/account/AccountPage.test.tsx create mode 100644 src/pages/dashboard/account/alerts/AlertsPage.test.tsx create mode 100644 src/pages/dashboard/account/api-credentials/ApiCredentials.test.tsx create mode 100644 src/pages/dashboard/activities/ActivitiesPage.test.tsx create mode 100644 src/pages/dashboard/instances/InstancesPage/helpers.test.ts create mode 100644 src/pages/dashboard/instances/ReportForm/ReportForm.test.tsx create mode 100644 src/pages/dashboard/instances/[single]/SingleInstanceContainer/helpers.test.ts create mode 100644 src/pages/dashboard/instances/[single]/tabs/info/EditInstance/EditInstance.test.tsx create mode 100644 src/pages/dashboard/instances/[single]/tabs/packages/PackagesPanel/helpers.test.ts create mode 100644 src/pages/dashboard/instances/[single]/tabs/users/UserList/helpers.test.ts create mode 100644 src/pages/dashboard/instances/[single]/tabs/users/UserPanelActionButtons/helpers.test.tsx create mode 100644 src/pages/dashboard/overview/OverviewPage.test.tsx delete mode 100644 src/pages/dashboard/repositories/RepositoryPage.tsx create mode 100644 src/pages/dashboard/repositories/local-repositories/constants.ts create mode 100644 src/pages/dashboard/repositories/mirrors/DistributionsPage.test.tsx create mode 100644 src/pages/dashboard/settings/SettingsPage.test.tsx create mode 100644 src/pages/dashboard/settings/access-group/AccessGroupsPage.test.tsx create mode 100644 src/routes/AuthRoutes.test.tsx create mode 100644 src/routes/elements.test.tsx create mode 100644 src/tests/server/handlers/reports.ts diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 000000000..242bc3bd5 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,15 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { renderWithProviders } from "@/tests/render"; +import App from "./App"; + +describe("App", () => { + it("renders not found route for unknown path", async () => { + renderWithProviders(, undefined, "/does-not-exist"); + + expect(await screen.findByText("Page not found")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Go back to the home page" }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/filter/SearchQueryEditor/SearchQueryEditor.test.tsx b/src/components/filter/SearchQueryEditor/SearchQueryEditor.test.tsx new file mode 100644 index 000000000..5b65096b2 --- /dev/null +++ b/src/components/filter/SearchQueryEditor/SearchQueryEditor.test.tsx @@ -0,0 +1,86 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import SearchQueryEditor from "./SearchQueryEditor"; + +const codeEditorSpy = vi.fn(); + +vi.mock("@/components/form/CodeEditor", () => ({ + default: (props: Record) => { + codeEditorSpy(props); + return
; + }, +})); + +describe("SearchQueryEditor", () => { + it("configures monaco language when terms exist", () => { + const configureSearchLanguage = vi.fn(); + + render( + , + ); + + const call = codeEditorSpy.mock.calls.at(-1)?.[0] as { + monacoBeforeMount: (monaco: unknown) => void; + options: Record; + language: string; + }; + + expect(call.language).toBe("landscape-query"); + expect(call.options).toMatchObject({ + fixedOverflowWidgets: true, + suggestOnTriggerCharacters: true, + quickSuggestions: true, + }); + + const monaco = { editor: {} }; + call.monacoBeforeMount(monaco); + + expect(configureSearchLanguage).toHaveBeenCalledWith( + monaco, + "landscape-query", + ["status"], + { + profileTypes: ["security"], + usgStatuses: ["compliant"], + wslStatuses: ["enabled"], + }, + ); + }); + + it("does not configure monaco language when terms are empty", () => { + const configureSearchLanguage = vi.fn(); + + render( + , + ); + + const call = codeEditorSpy.mock.calls.at(-1)?.[0] as { + monacoBeforeMount: (monaco: unknown) => void; + }; + + call.monacoBeforeMount({}); + expect(configureSearchLanguage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/form/CheckboxGroup.test.tsx b/src/components/form/CheckboxGroup.test.tsx new file mode 100644 index 000000000..9545118e0 --- /dev/null +++ b/src/components/form/CheckboxGroup.test.tsx @@ -0,0 +1,53 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "@/tests/render"; +import type { SelectOption } from "@/types/SelectOption"; +import CheckboxGroup from "./CheckboxGroup"; + +const options: SelectOption[] = [ + { label: "Alpha", value: "alpha" }, + { label: "Beta", value: "beta" }, +]; + +describe("CheckboxGroup", () => { + it("renders label, help, and error message", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Access groups")).toBeInTheDocument(); + expect(screen.getByText("Select one or more groups")).toBeInTheDocument(); + expect(screen.getByText("Selection is required")).toBeInTheDocument(); + }); + + it("adds and removes values when options are toggled", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + renderWithProviders( + , + ); + + await user.click(screen.getByLabelText("Alpha")); + expect(onChange).toHaveBeenCalledWith([]); + + await user.click(screen.getByLabelText("Beta")); + expect(onChange).toHaveBeenCalledWith(["alpha", "beta"]); + }); +}); diff --git a/src/components/form/CodeEditor.test.tsx b/src/components/form/CodeEditor.test.tsx new file mode 100644 index 000000000..cae30051c --- /dev/null +++ b/src/components/form/CodeEditor.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import CodeEditor from "./CodeEditor"; + +vi.mock("./CodeEditorInner", () => ({ + default: ({ label }: { label: string }) => ( +
{label}
+ ), +})); + +describe("CodeEditor", () => { + it("renders lazy inner editor", async () => { + render(); + + expect(await screen.findByTestId("code-editor-inner")).toHaveTextContent( + "Script", + ); + }); +}); diff --git a/src/components/form/CodeEditorInner.test.tsx b/src/components/form/CodeEditorInner.test.tsx new file mode 100644 index 000000000..a416b6ffa --- /dev/null +++ b/src/components/form/CodeEditorInner.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import CodeEditorInner from "./CodeEditorInner"; + +const useThemeSpy = vi.fn(); + +vi.mock("@/context/theme", () => ({ + useTheme: () => useThemeSpy(), +})); + +vi.mock("@/libs/monaco", () => ({})); + +describe("CodeEditorInner", () => { + it("renders label and validation state, then triggers change and blur", async () => { + const user = userEvent.setup(); + + useThemeSpy.mockReturnValue({ isDarkMode: true }); + + const onChange = vi.fn(); + const onBlur = vi.fn(); + const monacoBeforeMount = vi.fn(); + + render( + <> + + + , + ); + + expect(screen.getByText("Command")).toBeInTheDocument(); + expect(screen.getByText("invalid")).toBeInTheDocument(); + + const label = screen.getByText("Command"); + expect(label).toHaveClass("is-required"); + + const editor = screen.getByTestId("mock-monaco"); + expect(editor).toHaveAttribute("data-theme", "vs-dark"); + expect(editor).toHaveAttribute("data-language", "shell"); + + await user.click(editor); + await user.type(editor, "abc"); + await user.tab(); + + expect(onChange).toHaveBeenCalled(); + expect(onBlur).toHaveBeenCalled(); + expect(monacoBeforeMount).toHaveBeenCalled(); + }); + + it("uses light theme when dark mode is disabled", () => { + useThemeSpy.mockReturnValue({ isDarkMode: false }); + + render(); + + expect(screen.getByTestId("mock-monaco")).toHaveAttribute( + "data-theme", + "vs-light", + ); + }); + + it("does not call blur callback when focus stays inside editor container", async () => { + const user = userEvent.setup(); + useThemeSpy.mockReturnValue({ isDarkMode: false }); + const onBlur = vi.fn(); + + render( + Inside header action} + />, + ); + + const editor = screen.getByTestId("mock-monaco"); + const control = editor.closest(".p-form__control"); + assert(control); + + const insideControlButton = document.createElement("button"); + insideControlButton.type = "button"; + insideControlButton.textContent = "Inside control"; + control.appendChild(insideControlButton); + + await user.click(editor); + await user.click(screen.getByRole("button", { name: "Inside control" })); + expect(onBlur).not.toHaveBeenCalled(); + + await user.click( + screen.getByRole("button", { name: "Inside header action" }), + ); + expect(onBlur).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/form/FileInput/FileInput.test.tsx b/src/components/form/FileInput/FileInput.test.tsx index bd5e4041c..242462882 100644 --- a/src/components/form/FileInput/FileInput.test.tsx +++ b/src/components/form/FileInput/FileInput.test.tsx @@ -1,4 +1,4 @@ -import { screen } from "@testing-library/react"; +import { fireEvent, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "@/tests/render"; @@ -80,4 +80,85 @@ describe("FileInput", () => { expect(screen.getByText("Script file")).toBeInTheDocument(); }); + + it("shows help text when a file is present", () => { + const file = new File(["content"], "helped.txt", { type: "text/plain" }); + + renderWithProviders( + , + ); + + expect(screen.getByText("Only text files are allowed")).toBeInTheDocument(); + }); + + it("does not call onFileUpload when no files are selected", () => { + const onFileUpload = vi.fn(); + + renderWithProviders( + , + ); + + fireEvent.change(screen.getByLabelText("Upload script"), { + target: { files: null }, + }); + + expect(onFileUpload).not.toHaveBeenCalled(); + }); + + it("uploads selected file and triggers onBlur callback", async () => { + const user = userEvent.setup(); + const onFileUpload = vi.fn().mockResolvedValue(undefined); + const onBlur = vi.fn(); + const file = new File(["script"], "script.sh", { + type: "text/x-shellscript", + }); + + renderWithProviders( + , + ); + + const input = screen.getByLabelText("Upload script"); + await user.click(input); + await user.upload(input, file); + + expect(onFileUpload).toHaveBeenCalledWith([file]); + expect(onBlur).toHaveBeenCalledTimes(1); + }); + + it("uploads selected file when onBlur is not provided", async () => { + const user = userEvent.setup(); + const onFileUpload = vi.fn().mockResolvedValue(undefined); + const file = new File(["script"], "script.sh", { + type: "text/x-shellscript", + }); + + renderWithProviders( + , + ); + + await user.upload(screen.getByLabelText("Upload script"), file); + + expect(onFileUpload).toHaveBeenCalledWith([file]); + }); }); diff --git a/src/components/form/monacoMock.test.tsx b/src/components/form/monacoMock.test.tsx new file mode 100644 index 000000000..ae278cd31 --- /dev/null +++ b/src/components/form/monacoMock.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import CodeEditorMock, { Editor } from "@/tests/monacoMock"; + +describe("monacoMock", () => { + it("renders editor value and invokes optional callbacks", async () => { + const user = userEvent.setup(); + const beforeMount = vi.fn(); + const onChange = vi.fn(); + + render( + , + ); + + const editor = screen.getByTestId("mock-monaco"); + expect(editor).toHaveValue("echo hi"); + expect(editor).toHaveAttribute("data-language", "shell"); + expect(editor).toHaveAttribute("data-theme", "vs-dark"); + expect(editor).toHaveClass("editor"); + expect(beforeMount).toHaveBeenCalledWith({ mocked: true }); + + await user.clear(editor); + await user.type(editor, "updated value"); + + expect(onChange).toHaveBeenCalled(); + }); + + it("uses defaultValue when value is undefined", async () => { + const user = userEvent.setup(); + + render(); + + const editor = screen.getByTestId("mock-monaco"); + expect(editor).toHaveValue("fallback"); + + await user.type(editor, " text"); + expect(editor).toHaveValue("fallback"); + }); + + it("renders CodeEditorMock content and conditionally renders errors", () => { + const { rerender } = render( + Header action} + />, + ); + + expect(screen.getByText("Script")).toBeInTheDocument(); + expect(screen.getByText("Header action")).toBeInTheDocument(); + expect(screen.getByText("Invalid script")).toBeInTheDocument(); + + rerender(); + expect(screen.queryByText("Invalid script")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/guards/__tests__/SelfHostedGuard.test.tsx b/src/components/guards/__tests__/SelfHostedGuard.test.tsx new file mode 100644 index 000000000..7aea553df --- /dev/null +++ b/src/components/guards/__tests__/SelfHostedGuard.test.tsx @@ -0,0 +1,70 @@ +import EnvError from "@/pages/EnvError"; +import { EnvContext, type EnvContextState } from "@/context/env"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { describe, expect, it } from "vitest"; +import { SelfHostedGuard } from "../SelfHostedGuard"; + +describe("SelfHostedGuard", () => { + const envState: EnvContextState = { + envLoading: false, + isSaas: true, + isSelfHosted: false, + packageVersion: "", + revision: "", + displayDisaStigBanner: false, + }; + + const renderWithRoutes = (value: EnvContextState) => { + return render( + + + + +
secret
+ + } + /> + } /> +
+
+
, + ); + }; + + it("renders loading state while env is loading", () => { + renderWithRoutes({ + ...envState, + envLoading: true, + }); + + expect(screen.queryByText("secret")).not.toBeInTheDocument(); + expect(screen.queryByText("Environment Error")).not.toBeInTheDocument(); + }); + + it("renders children when self hosted", () => { + renderWithRoutes({ + ...envState, + isSelfHosted: true, + isSaas: false, + }); + + expect(screen.getByText("secret")).toBeInTheDocument(); + expect(screen.queryByText("Environment Error")).not.toBeInTheDocument(); + }); + + it("navigates away when not self hosted", () => { + renderWithRoutes({ + ...envState, + }); + + expect(screen.queryByText("secret")).not.toBeInTheDocument(); + expect(screen.getByText("Environment Error")).toBeInTheDocument(); + expect( + screen.getByText("This feature is not available in SaaS mode."), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/layout/GlobalShell/GlobalShell.test.tsx b/src/components/layout/GlobalShell/GlobalShell.test.tsx index afd217a53..59e38dc8e 100644 --- a/src/components/layout/GlobalShell/GlobalShell.test.tsx +++ b/src/components/layout/GlobalShell/GlobalShell.test.tsx @@ -1,40 +1,49 @@ -import { NotifyContext } from "@/context/notify"; +import useNotify from "@/hooks/useNotify"; import { renderWithProviders } from "@/tests/render"; import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect } from "vitest"; import { GlobalShell } from "./GlobalShell"; +const NotificationTrigger = () => { + const { notify } = useNotify(); + + return ( + + ); +}; + describe("GlobalShell", () => { it("renders children", () => { renderWithProviders(Shell content); expect(screen.getByText("Shell content")).toBeInTheDocument(); }); - it("shows a notification when notify has a message", () => { - const notifyValue = { - notify: { - notification: { - type: "positive" as const, - message: "Success notification", - title: "Success", - }, - success: () => undefined, - error: () => undefined, - info: () => undefined, - clear: () => undefined, - }, - sidePanel: { - open: false, - setOpen: () => undefined, - }, - }; + it("shows a notification when notify has a message", async () => { + const user = userEvent.setup(); renderWithProviders( - - content - , + + + , + ); + + await user.click( + screen.getByRole("button", { name: "Trigger notification" }), ); - expect(screen.getByText("Success notification")).toBeInTheDocument(); + expect( + (await screen.findAllByText("Success notification")).length, + ).toBeGreaterThan(0); }); }); diff --git a/src/context/auth.test.tsx b/src/context/auth.test.tsx new file mode 100644 index 000000000..0a80c524f --- /dev/null +++ b/src/context/auth.test.tsx @@ -0,0 +1,256 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useContext, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import useFeatures from "@/hooks/useFeatures"; +import { + getSameOriginPath, + getSameOriginUrl, + redirectToExternalUrl, + useGetAuthState, +} from "@/features/auth"; +import { authUser } from "@/tests/mocks/auth"; +import { HOMEPAGE_PATH } from "@/constants"; +import { ROUTES } from "@/libs/routes"; +import AuthProvider, { AuthContext } from "./auth"; + +const navigate = vi.hoisted(() => vi.fn()); +const mockUseLocation = vi.hoisted(() => vi.fn()); + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + + return { + ...actual, + useNavigate: () => navigate, + useLocation: () => mockUseLocation(), + }; +}); + +vi.mock("@/hooks/useFeatures"); +vi.mock("@/features/auth"); + +describe("AuthProvider", () => { + const wrapperWithProvider = (queryClient: QueryClient) => { + return function Wrapper({ children }: { readonly children: ReactNode }) { + return ( + + {children} + + ); + }; + }; + + const renderAuthContext = (queryClient: QueryClient) => { + return renderHook(() => useContext(AuthContext), { + wrapper: wrapperWithProvider(queryClient), + }); + }; + + beforeEach(() => { + navigate.mockReset(); + + mockUseLocation.mockReturnValue({ pathname: "/dashboard" }); + + vi.mocked(useGetAuthState).mockReturnValue({ + user: authUser, + isLoading: false, + isFetched: true, + }); + + vi.mocked(useFeatures).mockReturnValue({ + isFeatureEnabled: vi.fn(() => true), + isFeaturesLoading: false, + }); + + vi.mocked(getSameOriginUrl).mockReturnValue( + new URL("/dashboard", window.location.origin), + ); + vi.mocked(getSameOriginPath).mockReturnValue("/dashboard"); + vi.mocked(redirectToExternalUrl).mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("disables auth-state query when handling auth callbacks", () => { + mockUseLocation.mockReturnValue({ pathname: "/auth/handle-auth" }); + + const queryClient = new QueryClient(); + renderAuthContext(queryClient); + + expect(useGetAuthState).toHaveBeenCalledWith({ enabled: false }); + }); + + it("exposes computed auth flags and feature checks", () => { + const isFeatureEnabled = vi.fn(() => true); + vi.mocked(useFeatures).mockReturnValue({ + isFeatureEnabled, + isFeaturesLoading: false, + }); + + const queryClient = new QueryClient(); + const { result } = renderAuthContext(queryClient); + + expect(result.current.authorized).toBe(true); + expect(result.current.hasAccounts).toBe(true); + expect(result.current.authLoading).toBe(false); + expect(result.current.isFeatureEnabled("spa-dashboard")).toBe(true); + }); + + it("sets auth user and clears non-auth queries on logout", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(["authUser"], authUser); + queryClient.setQueryData(["features", authUser.email], [{ key: "x" }]); + + const { result } = renderAuthContext(queryClient); + + act(() => { + result.current.setUser(authUser); + }); + + expect(queryClient.getQueryData(["authUser"])).toEqual(authUser); + + act(() => { + result.current.logout(); + }); + + expect(queryClient.getQueryData(["authUser"])).toBeNull(); + expect( + queryClient.getQueryData(["features", authUser.email]), + ).toBeUndefined(); + expect(navigate).toHaveBeenCalledWith(ROUTES.auth.login(), { + replace: true, + }); + }); + + it("navigates to homepage for unsafe redirect targets", () => { + vi.mocked(getSameOriginUrl).mockReturnValue(null); + + const queryClient = new QueryClient(); + const { result } = renderAuthContext(queryClient); + + act(() => { + result.current.safeRedirect("https://google.com"); + }); + + expect(navigate).toHaveBeenCalledWith(HOMEPAGE_PATH, { replace: true }); + }); + + it("navigates internally with same-origin safe path", () => { + vi.mocked(getSameOriginUrl).mockReturnValue( + new URL("/dashboard/settings", window.location.origin), + ); + vi.mocked(getSameOriginPath).mockReturnValue("/dashboard/settings"); + + const queryClient = new QueryClient(); + const { result } = renderAuthContext(queryClient); + + act(() => { + result.current.safeRedirect("/dashboard/settings", { replace: false }); + }); + + expect(navigate).toHaveBeenCalledWith("/dashboard/settings", { + replace: false, + }); + }); + + it("uses external redirect helper when external option is requested", () => { + const safeUrl = new URL("/dashboard/settings", window.location.origin); + vi.mocked(getSameOriginUrl).mockReturnValue(safeUrl); + + const queryClient = new QueryClient(); + const { result } = renderAuthContext(queryClient); + + act(() => { + result.current.safeRedirect("/dashboard/settings", { + external: true, + replace: false, + }); + }); + + expect(redirectToExternalUrl).toHaveBeenCalledWith(safeUrl.toString(), { + replace: false, + }); + }); + + it("redirects to safe path fallback when same-origin URL has no path", () => { + vi.mocked(getSameOriginUrl).mockReturnValue( + new URL(window.location.origin), + ); + vi.mocked(getSameOriginPath).mockReturnValue(null); + + const queryClient = new QueryClient(); + const { result } = renderAuthContext(queryClient); + + act(() => { + result.current.safeRedirect(window.location.origin, { replace: false }); + }); + + expect(navigate).toHaveBeenCalledWith(HOMEPAGE_PATH, { replace: false }); + }); + + it("exposes hasAccounts false when user has no accounts", () => { + vi.mocked(useGetAuthState).mockReturnValue({ + user: { ...authUser, accounts: [], current_account: "" }, + isLoading: false, + isFetched: true, + }); + + const queryClient = new QueryClient(); + const { result } = renderAuthContext(queryClient); + + expect(result.current.authorized).toBe(true); + expect(result.current.hasAccounts).toBe(false); + }); + + it("renders redirecting state while external redirect is in progress", async () => { + const user = userEvent.setup(); + + const TriggerExternalRedirect = () => { + const { safeRedirect } = useContext(AuthContext); + + return ( + + ); + }; + + const queryClient = new QueryClient(); + + render( + + + + + , + ); + + await user.click(screen.getByRole("button", { name: "Trigger redirect" })); + + expect(screen.getAllByText("Redirecting...").length).toBeGreaterThan(0); + }); + + it("provides initial auth context defaults", () => { + const { result } = renderHook(() => useContext(AuthContext)); + + expect(result.current.authLoading).toBe(false); + expect(result.current.authorized).toBe(false); + expect(result.current.hasAccounts).toBe(false); + expect(result.current.user).toBeNull(); + expect(result.current.isFeatureEnabled("spa-dashboard")).toBe(false); + expect(result.current.logout()).toBeUndefined(); + expect(result.current.setUser(authUser)).toBeUndefined(); + expect(result.current.safeRedirect()).toBeUndefined(); + expect(result.current.redirectToExternalUrl("/dashboard")).toBeUndefined(); + }); +}); diff --git a/src/features/alerts/components/AlertTagsCell/AlertTagsCell.test.tsx b/src/features/alerts/components/AlertTagsCell/AlertTagsCell.test.tsx index 1bb88b3b8..0846fa709 100644 --- a/src/features/alerts/components/AlertTagsCell/AlertTagsCell.test.tsx +++ b/src/features/alerts/components/AlertTagsCell/AlertTagsCell.test.tsx @@ -1,12 +1,20 @@ import { alerts } from "@/tests/mocks/alerts"; import { renderWithProviders } from "@/tests/render"; +import { setEndpointStatus } from "@/tests/controllers/controller"; import type { MultiSelectItem } from "@canonical/react-components"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import AlertTagsCell from "./AlertTagsCell"; -const mockAlert = alerts[0]; +const [mockAlert] = alerts; +assert(mockAlert); +const nonAllAlert = alerts.find((alert) => !alert.all_computers); +assert(nonAllAlert); +const taggedNonAllAlert = { + ...nonAllAlert, + tags: ["tag1", "tag2"], +}; const mockAvailableTagOptions: MultiSelectItem[] = [ { value: "All", label: "All instances" }, @@ -16,6 +24,10 @@ const mockAvailableTagOptions: MultiSelectItem[] = [ ]; describe("AlertTagsCell", () => { + beforeEach(() => { + setEndpointStatus("default"); + }); + it("allows selecting and deselecting tags", async () => { renderWithProviders( { expect(screen.getByText("Save changes")).toHaveAttribute("aria-disabled"); expect(screen.getByText("Revert")).toHaveAttribute("aria-disabled"); }); + + it("saves all-instances selection and shows success message", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("checkbox", { name: "All instances" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect(await screen.findByText("Alert tags updated")).toBeInTheDocument(); + }); + + it("saves deselected all-instances tags and shows success message", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("checkbox", { name: "All instances" })); + await user.click(screen.getByRole("checkbox", { name: "Tag 2" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect(await screen.findByText("Alert tags updated")).toBeInTheDocument(); + }); + + it("saves tag additions/removals for non-all alert and shows success message", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("checkbox", { name: "Tag 1" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect(await screen.findByText("Alert tags updated")).toBeInTheDocument(); + }); + + it("saves all-instances selection for non-all alerts", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("checkbox", { name: "All instances" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect(await screen.findByText("Alert tags updated")).toBeInTheDocument(); + }); + + it("saves deselected tags for non-all alerts with preselected tags", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("checkbox", { name: "Tag 2" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect(await screen.findByText("Alert tags updated")).toBeInTheDocument(); + }); + + it("shows an error notification when saving tags fails", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "error", path: "AssociateAlert" }); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("checkbox", { name: "Tag 1" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); }); diff --git a/src/features/alerts/index.ts b/src/features/alerts/index.ts index e4d049de4..daece53d1 100644 --- a/src/features/alerts/index.ts +++ b/src/features/alerts/index.ts @@ -1,3 +1,8 @@ export { default as AlertsList } from "./components/AlertsList"; export { useAlerts } from "./hooks"; -export type { Alert, SubscriptionParams } from "./types"; +export type { + Alert, + SubscriptionParams, + AssociateAlertParams, + DisassociateAlertParams, +} from "./types"; diff --git a/src/features/api-credentials/index.ts b/src/features/api-credentials/index.ts index 1594806ac..ddbcc11bd 100644 --- a/src/features/api-credentials/index.ts +++ b/src/features/api-credentials/index.ts @@ -1,2 +1,3 @@ export { default as ApiCredentialsTables } from "./components/ApiCredentialsTables"; export { useApiCredentials } from "./hooks"; +export type { UserCredentials } from "./types"; diff --git a/src/features/attach/components/OTPInputContainer/OTPInputContainer.test.tsx b/src/features/attach/components/OTPInputContainer/OTPInputContainer.test.tsx index 88a80719e..863188f45 100644 --- a/src/features/attach/components/OTPInputContainer/OTPInputContainer.test.tsx +++ b/src/features/attach/components/OTPInputContainer/OTPInputContainer.test.tsx @@ -1,12 +1,18 @@ import { renderWithProviders } from "@/tests/render"; +import { PATHS, ROUTES } from "@/libs/routes"; +import { setEndpointStatus } from "@/tests/controllers/controller"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import OTPInputContainer from "./OTPInputContainer"; describe("OTPInputContainer", () => { const user = userEvent.setup(); + beforeEach(() => { + setEndpointStatus("default"); + }); + it("should render the component with the correct title", () => { renderWithProviders(); expect( @@ -30,4 +36,76 @@ describe("OTPInputContainer", () => { const errorMessage = screen.getByText("Code must be 6 characters long"); expect(errorMessage).toBeInTheDocument(); }); + + it("does not show length validation error when code is complete", async () => { + renderWithProviders(); + + const inputs = screen.getAllByRole("textbox"); + for (let i = 0; i < 6; i++) { + const input = inputs[i]; + assert(input); + await user.type(input, String(i + 1)); + } + + await user.click(screen.getByRole("button", { name: /next/i })); + + expect( + screen.queryByText("Code must be 6 characters long"), + ).not.toBeInTheDocument(); + }); + + it("prefills code from query string when code is incomplete", () => { + renderWithProviders( + , + undefined, + `${ROUTES.auth.attach({ code: "QWER1" })}`, + PATHS.auth.attach, + ); + + const inputs = screen.getAllByRole("textbox"); + expect(inputs[0]).toHaveValue("Q"); + expect(inputs[1]).toHaveValue("W"); + expect(inputs[2]).toHaveValue("E"); + expect(inputs[3]).toHaveValue("R"); + expect(inputs[4]).toHaveValue("1"); + expect(inputs[5]).toHaveValue(""); + }); + + it("shows expired-code error and stays on attach page", async () => { + renderWithProviders( + , + undefined, + `${ROUTES.auth.attach({ code: "EXPIRE" })}`, + PATHS.auth.attach, + ); + + expect( + await screen.findByText( + "The code you entered has expired and is no longer valid.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { + name: /enter code to connect to the ubuntu installer/i, + }), + ).toBeInTheDocument(); + }); + + it("shows endpoint error when verify request fails", async () => { + setEndpointStatus({ + status: "error", + path: "ubuntu-installer-attach-sessions/code", + }); + + renderWithProviders( + , + undefined, + `${ROUTES.auth.attach({ code: "QWERTY" })}`, + PATHS.auth.attach, + ); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); }); diff --git a/src/features/attach/components/OTPInputContainer/OTPInputContainer.tsx b/src/features/attach/components/OTPInputContainer/OTPInputContainer.tsx index c557aab2e..7d0b48370 100644 --- a/src/features/attach/components/OTPInputContainer/OTPInputContainer.tsx +++ b/src/features/attach/components/OTPInputContainer/OTPInputContainer.tsx @@ -24,7 +24,7 @@ const OTPInputContainer: FC = () => { const formik = useFormik({ initialValues: { - code: Object.assign(Array(OTP_LENGTH).fill(""), code?.split("") || []), + code: Object.assign(Array(OTP_LENGTH).fill(""), code.split("")), }, validationSchema: Yup.object().shape({ code: Yup.array().test( diff --git a/src/features/auth/helpers.test.ts b/src/features/auth/helpers.test.ts new file mode 100644 index 000000000..d4f2b664d --- /dev/null +++ b/src/features/auth/helpers.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ROUTES } from "@/libs/routes"; +import { + getProviderIcon, + getSameOriginPath, + getSameOriginUrl, + redirectToExternalUrl, +} from "./helpers"; + +describe("auth helpers", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("uses location.assign by default for external redirects", () => { + const assignSpy = vi.fn(); + const replaceSpy = vi.fn(); + + vi.spyOn(window, "location", "get").mockReturnValue({ + ...window.location, + assign: assignSpy, + replace: replaceSpy, + }); + + const targetUrl = "https://example.com/login"; + redirectToExternalUrl(targetUrl); + + expect(assignSpy).toHaveBeenCalledWith(targetUrl); + }); + + it("uses location.replace when replace option is set", () => { + const assignSpy = vi.fn(); + const replaceSpy = vi.fn(); + + vi.spyOn(window, "location", "get").mockReturnValue({ + ...window.location, + assign: assignSpy, + replace: replaceSpy, + }); + + const targetUrl = "https://example.com/login"; + redirectToExternalUrl(targetUrl, { replace: true }); + + expect(replaceSpy).toHaveBeenCalledWith(targetUrl); + }); + + it("returns null for missing and cross-origin inputs", () => { + expect(getSameOriginUrl()).toBeNull(); + expect(getSameOriginUrl("https://google.com")).toBeNull(); + expect(getSameOriginUrl("http://%")).toBeNull(); + }); + + it("resolves same-origin relative URLs", () => { + const input = `${ROUTES.settings.root({ tab: "users" })}#top`; + const parsed = getSameOriginUrl(input); + + expect(parsed).toBeInstanceOf(URL); + expect(parsed?.pathname).toBe(ROUTES.settings.root()); + expect(parsed?.search).toBe("?tab=users"); + expect(parsed?.hash).toBe("#top"); + }); + + it("returns same-origin path with query and hash", () => { + const input = `${ROUTES.settings.root({ tab: "users" })}#top`; + + expect(getSameOriginPath(input)).toBe( + `${ROUTES.settings.root({ tab: "users" })}#top`, + ); + expect(getSameOriginPath("https://example.com/outside")).toBeNull(); + }); + + it("falls back to the default provider icon for unknown providers", () => { + expect(getProviderIcon("okta")).toBe("okta"); + expect(getProviderIcon("unknown-provider")).toBe("connected"); + }); +}); diff --git a/src/features/autoinstall-files/components/AutoinstallFileForm/AutoinstallFileForm.test.tsx b/src/features/autoinstall-files/components/AutoinstallFileForm/AutoinstallFileForm.test.tsx index 10b0eca09..49a119032 100644 --- a/src/features/autoinstall-files/components/AutoinstallFileForm/AutoinstallFileForm.test.tsx +++ b/src/features/autoinstall-files/components/AutoinstallFileForm/AutoinstallFileForm.test.tsx @@ -1,22 +1,27 @@ +import { setEndpointStatus } from "@/tests/controllers/controller"; import { ADD_AUTOINSTALL_FILE_NOTIFICATION } from "@/pages/dashboard/settings/employees/tabs/autoinstall-files"; import { renderWithProviders } from "@/tests/render"; -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; -import { describe } from "vitest"; +import { beforeEach, describe, expect, vi } from "vitest"; import AutoinstallFileForm from "./AutoinstallFileForm"; import { DEFAULT_FILE } from "./constants"; describe("AutoinstallFileForm", () => { - const createAutoinstallFileProps: ComponentProps = - { - buttonText: "Add", - description: "Add an autoinstall file.", - initialFile: DEFAULT_FILE, - notification: ADD_AUTOINSTALL_FILE_NOTIFICATION, - onSubmit: vi.fn(), - }; - - const editAutoinstallFileProps: ComponentProps = { + const createAutoinstallFileProps = (): ComponentProps< + typeof AutoinstallFileForm + > => ({ + buttonText: "Add", + description: "Add an autoinstall file.", + initialFile: DEFAULT_FILE, + notification: ADD_AUTOINSTALL_FILE_NOTIFICATION, + onSubmit: vi.fn(async () => undefined), + }); + + const editAutoinstallFileProps = (): ComponentProps< + typeof AutoinstallFileForm + > => ({ buttonText: "Save changes", description: `The duplicated file will be assigned to the same user groups in the identity provider as the original file.`, initialFile: { @@ -25,11 +30,27 @@ describe("AutoinstallFileForm", () => { is_default: false, }, notification: ADD_AUTOINSTALL_FILE_NOTIFICATION, - onSubmit: vi.fn(), - }; + onSubmit: vi.fn(async () => undefined), + }); + + const createAutoinstallFileWithContentsProps = (): ComponentProps< + typeof AutoinstallFileForm + > => ({ + ...createAutoinstallFileProps(), + initialFile: { + ...DEFAULT_FILE, + contents: "#cloud-config\nusers:\n - name: ubuntu", + }, + }); + + beforeEach(() => { + setEndpointStatus("default"); + }); it("should not render default checkbox when editing", () => { - renderWithProviders(); + renderWithProviders( + , + ); expect( screen.queryByRole("checkbox", { name: "Default" }), ).not.toBeInTheDocument(); @@ -42,18 +63,215 @@ describe("AutoinstallFileForm", () => { }); it("should show a disabled button when first creating a form", async () => { - renderWithProviders( - , - ); + const props = createAutoinstallFileProps(); + renderWithProviders(); expect( screen.getByRole("checkbox", { name: "Default" }), ).toBeInTheDocument(); const submitButton = screen.getByRole("button", { - name: createAutoinstallFileProps.buttonText, + name: props.buttonText, }); expect(submitButton).toHaveAttribute("aria-disabled", "true"); }); + + it("populates filename from uploaded file when creating", async () => { + const props = createAutoinstallFileProps(); + const user = userEvent.setup(); + const file = new File(["#cloud-config\nusers: []"], "custom-config.yaml", { + type: "text/yaml", + }); + + renderWithProviders(); + + const fileInput = screen.getByTestId("autoinstall-upload-input"); + + await user.upload(fileInput, file); + + expect(screen.getByRole("textbox", { name: "File name" })).toHaveValue( + "custom-config", + ); + expect(screen.getByRole("button", { name: "Add" })).not.toHaveAttribute( + "aria-disabled", + "true", + ); + }); + + it("keeps existing filename when uploading a file", async () => { + const props = createAutoinstallFileProps(); + const user = userEvent.setup(); + const file = new File(["#cloud-config\npackage_update: true"], "new.yaml", { + type: "text/yaml", + }); + + renderWithProviders(); + + const fileNameInput = screen.getByRole("textbox", { name: "File name" }); + await user.type(fileNameInput, "custom-file-name"); + + await user.upload(screen.getByTestId("autoinstall-upload-input"), file); + + expect(fileNameInput).toHaveValue("custom-file-name"); + }); + + it("enables submit when content differs from initial file content", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + expect(screen.getByRole("button", { name: "Add" })).toHaveAttribute( + "aria-disabled", + "true", + ); + + await user.type(screen.getByTestId("mock-monaco"), "\n# changed"); + + expect(screen.getByRole("button", { name: "Add" })).not.toHaveAttribute( + "aria-disabled", + "true", + ); + }); + + it("submits successfully after validation", async () => { + const user = userEvent.setup(); + const props = createAutoinstallFileProps(); + renderWithProviders(); + + await user.type(screen.getByRole("textbox", { name: "File name" }), "base"); + await user.type(screen.getByTestId("mock-monaco"), "#cloud-config"); + await user.click(screen.getByRole("button", { name: "Add" })); + + expect(props.onSubmit).toHaveBeenCalledWith({ + accept_warning: false, + contents: "#cloud-config", + filename: "base.yaml", + is_default: false, + }); + }); + + it("handles submit errors from creating file", async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(async () => { + throw new Error("submit failed"); + }); + const props = { ...createAutoinstallFileProps(), onSubmit }; + renderWithProviders(); + + await user.type(screen.getByRole("textbox", { name: "File name" }), "base"); + await user.type(screen.getByTestId("mock-monaco"), "#cloud-config"); + await user.click(screen.getByRole("button", { name: "Add" })); + + expect(onSubmit).toHaveBeenCalledWith({ + accept_warning: false, + contents: "#cloud-config", + filename: "base.yaml", + is_default: false, + }); + expect(await screen.findByText("submit failed")).toBeInTheDocument(); + }); + + it("handles validation errors that are not override warnings", async () => { + const user = userEvent.setup(); + const props = createAutoinstallFileProps(); + setEndpointStatus({ status: "error", path: "autoinstall:validate" }); + renderWithProviders(); + + await user.type(screen.getByRole("textbox", { name: "File name" }), "base"); + await user.type(screen.getByTestId("mock-monaco"), "#cloud-config"); + await user.click(screen.getByRole("button", { name: "Add" })); + + expect(props.onSubmit).not.toHaveBeenCalled(); + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); + + it("shows override modal and submits when confirmed", async () => { + const user = userEvent.setup(); + const props = createAutoinstallFileProps(); + setEndpointStatus({ + status: "error", + path: "autoinstall:validate-override", + }); + renderWithProviders(); + + await user.type(screen.getByRole("textbox", { name: "File name" }), "base"); + await user.type(screen.getByTestId("mock-monaco"), "#cloud-config"); + await user.click(screen.getByRole("button", { name: "Add" })); + + expect( + await screen.findByRole("heading", { name: "Override autoinstall file" }), + ).toBeInTheDocument(); + expect(screen.getByText("users")).toBeInTheDocument(); + expect(screen.getByText("identity")).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: "Override and add file" }), + ); + + expect(props.onSubmit).toHaveBeenCalledWith({ + accept_warning: true, + contents: "#cloud-config", + filename: "base.yaml", + is_default: false, + }); + }); + + it("hides override modal when canceled", async () => { + const user = userEvent.setup(); + const props = createAutoinstallFileProps(); + setEndpointStatus({ + status: "error", + path: "autoinstall:validate-override", + }); + renderWithProviders(); + + await user.type(screen.getByRole("textbox", { name: "File name" }), "base"); + await user.type(screen.getByTestId("mock-monaco"), "#cloud-config"); + await user.click(screen.getByRole("button", { name: "Add" })); + + const dialog = await screen.findByRole("dialog", { + name: "Override autoinstall file", + }); + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + + expect( + screen.queryByRole("heading", { name: "Override autoinstall file" }), + ).not.toBeInTheDocument(); + }); + + it("opens hidden file input when clicking populate from file", async () => { + const user = userEvent.setup(); + const clickSpy = vi.spyOn(HTMLInputElement.prototype, "click"); + const props = createAutoinstallFileProps(); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { name: "Populate from file" }), + ); + + expect(clickSpy).toHaveBeenCalled(); + clickSpy.mockRestore(); + }); + + it("ignores empty file input change", async () => { + const user = userEvent.setup(); + const props = createAutoinstallFileProps(); + const file = new File(["#cloud-config\nusers: []"], "custom-config.yaml", { + type: "text/yaml", + }); + renderWithProviders(); + + const fileInput = screen.getByTestId("autoinstall-upload-input"); + await user.upload(fileInput, file); + await user.upload(fileInput, []); + + expect(screen.getByRole("textbox", { name: "File name" })).toHaveValue( + "custom-config", + ); + }); }); diff --git a/src/features/autoinstall-files/components/AutoinstallFileForm/AutoinstallFileForm.tsx b/src/features/autoinstall-files/components/AutoinstallFileForm/AutoinstallFileForm.tsx index 9ce95ae62..f68933949 100644 --- a/src/features/autoinstall-files/components/AutoinstallFileForm/AutoinstallFileForm.tsx +++ b/src/features/autoinstall-files/components/AutoinstallFileForm/AutoinstallFileForm.tsx @@ -185,6 +185,7 @@ const AutoinstallFileForm: FC = ({ ref={inputRef} className="u-hide" type="file" + data-testid="autoinstall-upload-input" accept={AUTOINSTALL_FILE_LANGUAGES.map( (language) => `.${language}`, ).join(",")} diff --git a/src/features/employees/components/EmployeeDropdown/EmployeeDropdown.test.tsx b/src/features/employees/components/EmployeeDropdown/EmployeeDropdown.test.tsx index eab9a654b..77db64613 100644 --- a/src/features/employees/components/EmployeeDropdown/EmployeeDropdown.test.tsx +++ b/src/features/employees/components/EmployeeDropdown/EmployeeDropdown.test.tsx @@ -1,53 +1,199 @@ import { renderWithProviders } from "@/tests/render"; -import { screen } from "@testing-library/react"; +import { setEndpointStatus } from "@/tests/controllers/controller"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; -import { describe, expect, vi } from "vitest"; +import { beforeEach, describe, expect, vi } from "vitest"; +import { PATHS, ROUTES } from "@/libs/routes"; import EmployeeDropdown from "./EmployeeDropdown"; import { employees as mockEmployees } from "@/tests/mocks/employees"; -const props: ComponentProps = { +const baseProps: ComponentProps = { employee: null, setEmployee: vi.fn(), error: undefined, }; describe("EmployeeDropdown", () => { - beforeEach(async () => { - renderWithProviders( - , - undefined, - "/instances/1", - "instances/:instanceId", - ); - }); + describe("component", () => { + beforeEach(() => { + setEndpointStatus("default"); + }); - it("renders employee dropdown search component", () => { - const searchBox = screen.getByRole("searchbox"); - expect(searchBox).toBeInTheDocument(); - }); + const renderDropdown = ( + overrideProps?: Partial>, + ) => { + return renderWithProviders( + , + undefined, + ROUTES.instances.details.single(1), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + }; + + it("renders employee dropdown search component", () => { + renderDropdown(); + expect(screen.getByRole("searchbox")).toBeInTheDocument(); + }); - describe("employee selection flow", () => { it("shows matching employees after searching", async () => { + renderDropdown(); + const searchBox = screen.getByRole("searchbox"); await userEvent.type(searchBox, "John"); expect(searchBox).toHaveValue("John"); const employees = await screen.findAllByText("John"); - const matchinEmployees = mockEmployees.filter((employee) => + const matchingEmployees = mockEmployees.filter((employee) => employee.name.includes("John"), ); - //expected length 2, since John Smith and John Doe should be returned - expect(employees).toHaveLength(matchinEmployees.length); + expect(employees).toHaveLength(matchingEmployees.length); }); it("shows error if no matching employees found", async () => { + renderDropdown(); + const searchBox = screen.getByRole("searchbox"); await userEvent.type(searchBox, "checking for error"); expect(searchBox).toHaveValue("checking for error"); - const errorText = await screen.findByText(/No employees found by/i); - expect(errorText).toBeVisible(); + expect(await screen.findByText(/No employees found by/i)).toBeVisible(); + }); + + it("allows removing selected employee", async () => { + const setEmployee = vi.fn(); + renderDropdown({ employee: mockEmployees[0], setEmployee }); + + await userEvent.click(screen.getByRole("button", { name: /remove/i })); + + expect(setEmployee).toHaveBeenCalledWith(null); + }); + + it("keeps dropdown open and resets search when clear button is used", async () => { + const user = userEvent.setup(); + renderDropdown(); + + const searchBox = screen.getByRole("searchbox"); + await user.type(searchBox, "John"); + await user.click( + screen.getByRole("button", { name: /clear search field/i }), + ); + + expect(searchBox).toHaveValue(""); + expect( + await screen.findByText(mockEmployees[0]?.name ?? "John Doe"), + ).toBeInTheDocument(); + }); + + it("handles selecting an employee from the list", async () => { + const user = userEvent.setup(); + const setEmployee = vi.fn(); + renderDropdown({ setEmployee }); + + await user.type(screen.getByRole("searchbox"), "John"); + const [firstEmployeeOption] = + await screen.findAllByTestId("dropdownElement"); + assert(firstEmployeeOption); + await user.click(firstEmployeeOption); + + expect(setEmployee).toHaveBeenCalledWith(mockEmployees[0]); + expect(screen.getByRole("searchbox")).toHaveValue(""); + }); + + it("loads more results when suggestion list is scrolled near bottom", async () => { + const user = userEvent.setup(); + renderDropdown(); + + const searchBox = screen.getByRole("searchbox"); + await user.type(searchBox, "a"); + + const list = await screen.findByRole("listbox"); + Object.defineProperty(list, "scrollHeight", { + configurable: true, + value: 120, + }); + Object.defineProperty(list, "clientHeight", { + configurable: true, + value: 80, + }); + Object.defineProperty(list, "scrollTop", { + configurable: true, + value: 30, + }); + + list.dispatchEvent(new Event("scroll")); + + await waitFor(async () => { + expect((await screen.findAllByTestId("dropdownElement")).length).toBe( + 2, + ); + }); + }); + + it("shows next-page loading indicator while fetching additional results", async () => { + const user = userEvent.setup(); + setEndpointStatus({ + status: "default", + path: "employees:next-page-loading", + }); + renderDropdown(); + + await user.type(screen.getByRole("searchbox"), "a"); + const list = await screen.findByRole("listbox"); + + Object.defineProperty(list, "scrollHeight", { + configurable: true, + value: 100, + }); + Object.defineProperty(list, "clientHeight", { + configurable: true, + value: 80, + }); + Object.defineProperty(list, "scrollTop", { + configurable: true, + value: 30, + }); + + list.dispatchEvent(new Event("scroll")); + + expect(await screen.findByText("Loading...")).toBeInTheDocument(); + }); + + it("does not fetch next page when there is no next page", async () => { + const user = userEvent.setup(); + renderDropdown(); + + await user.type(screen.getByRole("searchbox"), "a"); + const list = await screen.findByRole("listbox"); + const initialSuggestionCount = ( + await screen.findAllByTestId("dropdownElement") + ).length; + + Object.defineProperty(list, "scrollHeight", { + configurable: true, + value: 100, + }); + Object.defineProperty(list, "clientHeight", { + configurable: true, + value: 80, + }); + Object.defineProperty(list, "scrollTop", { + configurable: true, + value: 0, + }); + + list.dispatchEvent(new Event("scroll")); + + await waitFor(async () => { + expect((await screen.findAllByTestId("dropdownElement")).length).toBe( + initialSuggestionCount, + ); + }); + }); + + it("renders field-level error text", () => { + renderDropdown({ error: "Employee is required" }); + expect(screen.getByText("Employee is required")).toBeInTheDocument(); }); }); }); diff --git a/src/features/gpg-keys/components/NewGPGKeyForm/NewGPGKeyForm.test.tsx b/src/features/gpg-keys/components/NewGPGKeyForm/NewGPGKeyForm.test.tsx index 9abfbb231..2d435e279 100644 --- a/src/features/gpg-keys/components/NewGPGKeyForm/NewGPGKeyForm.test.tsx +++ b/src/features/gpg-keys/components/NewGPGKeyForm/NewGPGKeyForm.test.tsx @@ -1,5 +1,7 @@ import { renderWithProviders } from "@/tests/render"; import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { setEndpointStatus } from "@/tests/controllers/controller"; import NewGPGKeyForm from "./NewGPGKeyForm"; describe("NewGPGKeyForm", () => { @@ -13,4 +15,96 @@ describe("NewGPGKeyForm", () => { }); expect(addGPGKeyButton).toBeInTheDocument(); }); + + it("shows required validation errors when submitting empty form", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /import key/i })); + + expect(await screen.findAllByText("This field is required")).toHaveLength( + 2, + ); + }); + + it("validates name format", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await user.type( + screen.getByRole("textbox", { name: "Name" }), + "Invalid_Name", + ); + await user.type(screen.getByRole("textbox", { name: "Material" }), "ABC"); + await user.click(screen.getByRole("button", { name: /import key/i })); + + expect( + await screen.findByText( + "It has to start with an alphanumeric character and only contain lowercase letters, numbers and - or + signs.", + ), + ).toBeInTheDocument(); + }); + + it("validates duplicate key name", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await user.type(screen.getByRole("textbox", { name: "Name" }), "sign-key"); + await user.type(screen.getByRole("textbox", { name: "Material" }), "ABC"); + await user.click(screen.getByRole("button", { name: /import key/i })); + + expect( + await screen.findByText("It must be unique within the account."), + ).toBeInTheDocument(); + }); + + it("allows unique names when existing keys cannot be loaded", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "empty", path: "gpgKey" }); + + renderWithProviders(); + + await user.type(screen.getByRole("textbox", { name: "Name" }), "sign-key"); + await user.type(screen.getByRole("textbox", { name: "Material" }), "ABC"); + await user.click(screen.getByRole("button", { name: /import key/i })); + + expect( + screen.queryByText("It must be unique within the account."), + ).not.toBeInTheDocument(); + }); + + it("submits successfully", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await user.type(screen.getByRole("textbox", { name: "Name" }), "newkey"); + await user.type(screen.getByRole("textbox", { name: "Material" }), "ABC"); + await user.click(screen.getByRole("button", { name: /import key/i })); + + expect( + screen.queryByText("This field is required"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("It has to start with an alphanumeric character"), + ).not.toBeInTheDocument(); + }); + + it("shows endpoint error", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "error", path: "importGpgKey" }); + + renderWithProviders(); + + await user.type(screen.getByRole("textbox", { name: "Name" }), "newkey2"); + await user.type(screen.getByRole("textbox", { name: "Material" }), "ABC"); + await user.click(screen.getByRole("button", { name: /import key/i })); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); }); diff --git a/src/features/kernel/components/KernelOverview/helpers.test.ts b/src/features/kernel/components/KernelOverview/helpers.test.ts new file mode 100644 index 000000000..f54a16b27 --- /dev/null +++ b/src/features/kernel/components/KernelOverview/helpers.test.ts @@ -0,0 +1,104 @@ +import { DISPLAY_DATE_FORMAT } from "@/constants"; +import moment from "moment"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getLivepatchCoverageDisplayValue, + getLivepatchCoverageIcon, + getStatusIcon, + getStatusTooltipMessage, +} from "./helpers"; + +const NOW = new Date("2026-04-02T12:00:00Z"); +const TWO_DAYS = 2; +const TEN_DAYS = 10; + +describe("KernelOverview helpers", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("builds upgrade tooltip with formatted expiry date when valid", () => { + const expiry = "2026-04-12T00:00:00Z"; + + expect( + getStatusTooltipMessage("Kernel upgrade available", expiry), + ).toContain( + `covered by Livepatch until ${moment(expiry).format(DISPLAY_DATE_FORMAT)}`, + ); + }); + + it("omits expiry details for invalid kernel-upgrade dates", () => { + expect( + getStatusTooltipMessage("Kernel upgrade available", "bad-date"), + ).toBe("A new kernel version is available."); + }); + + it("returns status icons for known and unknown statuses", () => { + expect(getStatusIcon("Patched by Livepatch")).toBe( + "status-succeeded-small", + ); + expect(getStatusIcon("Fully patched")).toBe("status-succeeded-small"); + expect(getStatusIcon("Kernel upgrade available")).toBe( + "status-succeeded-small", + ); + expect(getStatusIcon("Restart required")).toBe("status-waiting-small"); + expect(getStatusIcon("End of life")).toBe("status-failed-small"); + expect(getStatusIcon("Unknown")).toBe("status-failed-small"); + }); + + it("returns tooltip messages for all non-upgrade statuses and unknown states", () => { + expect(getStatusTooltipMessage("Fully patched", "")).toBe( + "All available kernel security patches have been applied. You have no pending patches.", + ); + expect(getStatusTooltipMessage("Restart required", "")).toBe( + "Low and/or medium patches have been installed. You must restart to complete patching.", + ); + expect(getStatusTooltipMessage("End of life", "")).toBe( + "The kernel is no longer covered by Livepatch. It is not getting high and critical security patches.", + ); + expect(getStatusTooltipMessage("Livepatch disabled", "")).toBe( + "Livepatch is disabled. Kernel patches will not be applied automatically until you enabled Livepatch.", + ); + expect(getStatusTooltipMessage("Unexpected", "")).toBe( + "There was an error getting the status.", + ); + }); + + it("computes livepatch coverage icons by enablement and expiry window", () => { + const soonExpiry = moment(NOW).add(TWO_DAYS, "days").toISOString(); + const laterExpiry = moment(NOW).add(TEN_DAYS, "days").toISOString(); + const expired = moment(NOW).subtract(1, "day").toISOString(); + + expect(getLivepatchCoverageIcon(true, soonExpiry)).toBe( + "status-waiting-small", + ); + expect(getLivepatchCoverageIcon(true, laterExpiry)).toBe( + "status-succeeded-small", + ); + expect(getLivepatchCoverageIcon(true, expired)).toBe("status-failed-small"); + expect(getLivepatchCoverageIcon(false, laterExpiry)).toBe( + "status-failed-small", + ); + expect(getLivepatchCoverageIcon(true, "not-a-date")).toBe( + "status-failed-small", + ); + }); + + it("computes display value for disabled, expired, and active coverage", () => { + const laterExpiry = moment(NOW).add(TEN_DAYS, "days").toISOString(); + const expired = moment(NOW).subtract(1, "day").toISOString(); + + expect(getLivepatchCoverageDisplayValue(false, laterExpiry)).toBe( + "Livepatch is disabled", + ); + expect(getLivepatchCoverageDisplayValue(true, expired)).toBe("Expired"); + expect(getLivepatchCoverageDisplayValue(true, laterExpiry)).toBe( + `Expires on ${moment(laterExpiry).format(DISPLAY_DATE_FORMAT)}`, + ); + }); +}); diff --git a/src/features/kernel/components/RestartInstanceForm/RestartInstanceForm.test.tsx b/src/features/kernel/components/RestartInstanceForm/RestartInstanceForm.test.tsx index 29688d580..f6cc1eb6f 100644 --- a/src/features/kernel/components/RestartInstanceForm/RestartInstanceForm.test.tsx +++ b/src/features/kernel/components/RestartInstanceForm/RestartInstanceForm.test.tsx @@ -1,32 +1,39 @@ +import { setEndpointStatus } from "@/tests/controllers/controller"; +import { ENDPOINT_STATUS_API_ERROR_MESSAGE } from "@/tests/server/handlers/_constants"; import { renderWithProviders } from "@/tests/render"; -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { ComponentProps } from "react"; +import { PATHS, ROUTES } from "@/libs/routes"; import RestartInstanceForm from "./RestartInstanceForm"; -describe("UpgradeKernelForm", () => { - it("renders notification message", () => { - renderWithProviders( - , - ); +const props: ComponentProps = { + showNotification: true, + instanceName: "test-instance", +}; +const INSTANCE_ID = 11; + +describe("RestartInstanceForm", () => { + beforeEach(() => { + setEndpointStatus("default"); + }); + + it("renders notification when restart is recommended", () => { + renderWithProviders(); expect(screen.getByText(/restart recommended/i)).toBeVisible(); }); - it("renders notification message", () => { + it("does not render notification when restart is not recommended", () => { renderWithProviders( - , + , ); expect(screen.queryByText(/restart recommended/i)).toBeNull(); }); - it("radio button functionalities", async () => { + it("shows randomization input when enabling random delivery", async () => { + const user = userEvent.setup(); renderWithProviders( - , + , ); const instantDeliveryTimeRadioOption = screen.getByLabelText( @@ -42,7 +49,101 @@ describe("UpgradeKernelForm", () => { expect(randomizeDeliveryTrueOption).not.toBeChecked(); expect(randomizeDeliveryFalseOption).toBeChecked(); - await userEvent.click(randomizeDeliveryTrueOption); + await user.click(randomizeDeliveryTrueOption); expect(screen.getByText(/time in minutes/i)).toBeVisible(); }); + + it("shows upgrade and restart action only when kernel id is provided", () => { + const { rerender } = renderWithProviders( + , + ); + + expect( + screen.getByRole("button", { name: "Upgrade and Restart" }), + ).toBeInTheDocument(); + + rerender(); + + expect( + screen.queryByRole("button", { name: "Upgrade and Restart" }), + ).not.toBeInTheDocument(); + }); + + it("submits restart and shows a success notification", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(INSTANCE_ID), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + await user.click(screen.getByRole("button", { name: "Restart" })); + + const dialog = screen.getByRole("dialog", { name: "Restarting instance" }); + await user.click(within(dialog).getByRole("button", { name: "Restart" })); + + expect( + await screen.findByText('You queued "test-instance" to be restarted.'), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "View details" })); + + expect( + await screen.findByRole("heading", { name: "Start instance Bionic WSL" }), + ).toBeInTheDocument(); + }); + + it("submits upgrade and restart and shows a success notification", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(INSTANCE_ID), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + await user.click( + screen.getByRole("button", { name: "Upgrade and Restart" }), + ); + + const dialog = screen.getByRole("dialog", { + name: "Upgrading kernel and restarting instance", + }); + await user.click( + within(dialog).getByRole("button", { name: "Upgrade and Restart" }), + ); + + expect( + await screen.findByText('You queued kernel upgrade for "test-instance"'), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "View details" })); + + expect( + await screen.findByRole("heading", { name: "Start instance Bionic WSL" }), + ).toBeInTheDocument(); + }); + + it("shows an error notification when restart fails", async () => { + const user = userEvent.setup(); + setEndpointStatus("error"); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(INSTANCE_ID), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + await user.click(screen.getByRole("button", { name: "Restart" })); + const dialog = screen.getByRole("dialog", { name: "Restarting instance" }); + await user.click(within(dialog).getByRole("button", { name: "Restart" })); + + expect( + await screen.findByText(ENDPOINT_STATUS_API_ERROR_MESSAGE), + ).toBeInTheDocument(); + }); }); diff --git a/src/features/mirrors/components/EditPocketForm/EditPocketForm.test.tsx b/src/features/mirrors/components/EditPocketForm/EditPocketForm.test.tsx index 8c094ab30..6910bf14a 100644 --- a/src/features/mirrors/components/EditPocketForm/EditPocketForm.test.tsx +++ b/src/features/mirrors/components/EditPocketForm/EditPocketForm.test.tsx @@ -1,6 +1,8 @@ +import { setEndpointStatus } from "@/tests/controllers/controller"; import { pockets } from "@/tests/mocks/pockets"; import { renderWithProviders } from "@/tests/render"; import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; import { DEFAULT_SNAPSHOT_URI } from "../../constants"; import EditPocketForm from "./EditPocketForm"; @@ -22,6 +24,31 @@ const uploadPocketProps: ComponentProps = { seriesName: "Focal Fossa", pocket: uploadPocket, }; +const uploadPocketAllowUnsignedProps: ComponentProps = { + distributionName: "Ubuntu", + seriesName: "Focal Fossa", + pocket: { + ...uploadPocket, + upload_allow_unsigned: true, + }, +}; + +const uploadPocketWithKnownGpgKeysProps: ComponentProps = + { + distributionName: "Ubuntu", + seriesName: "Focal Fossa", + pocket: { + ...uploadPocket, + upload_gpg_keys: [ + { + fingerprint: "", + id: 26, + has_secret: false, + name: "test-public", + }, + ], + }, + }; const pullPocketWithFilterType = pockets.find( (p) => p.mode === "pull" && p.filter_type, @@ -44,7 +71,16 @@ const pullPocketWithoutFilterTypeProps: ComponentProps = pocket: pullPocketWithoutFilterType, }; +const fillRequiredFields = async (user: ReturnType) => { + await user.click(screen.getByRole("checkbox", { name: "Main" })); + await user.click(screen.getByRole("checkbox", { name: "amd64" })); +}; + describe("EditPocketForm", () => { + beforeEach(() => { + setEndpointStatus("default"); + }); + it("renders form fields for mirror pocket", () => { const { container } = renderWithProviders( , @@ -118,4 +154,162 @@ describe("EditPocketForm", () => { }), ).toBeVisible(); }); + + it("disables uploader gpg keys when unsigned uploads are allowed", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await user.click( + screen.getByRole("checkbox", { + name: "Allow uploaded packages to be unsigned", + }), + ); + + expect( + screen.getByRole("combobox", { name: "Uploader GPG keys" }), + ).toHaveAttribute("disabled"); + }); + + it("normalizes filter package input by removing spaces", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + const filterInput = screen.getByRole("textbox", { + name: "Filter packages", + }); + + await user.clear(filterInput); + await user.type(filterInput, " foo, bar ,baz "); + + expect(filterInput).toHaveValue("foo,bar,baz"); + }); + + it("updates checkbox groups", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await fillRequiredFields(user); + + expect(screen.getByRole("checkbox", { name: "Main" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "amd64" })).toBeChecked(); + }); + + it("submits upload pocket changes and supports uploader gpg key selection", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await fillRequiredFields(user); + + await user.click( + screen.getByRole("combobox", { name: "Uploader GPG keys" }), + ); + await user.click(screen.getByRole("checkbox", { name: "test-public" })); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + it("submits upload pocket changes when unsigned uploads are enabled", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await fillRequiredFields(user); + + await user.click( + screen.getByRole("checkbox", { + name: "Allow uploaded packages to be unsigned", + }), + ); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + it("submits upload pocket with original allow-unsigned pocket data", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await fillRequiredFields(user); + + await user.click( + screen.getByRole("checkbox", { + name: "Allow uploaded packages to be unsigned", + }), + ); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + it("submits upload pocket without uploader gpg key changes", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + await fillRequiredFields(user); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + it("submits pull pocket changes with updated filter package list", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await fillRequiredFields(user); + + const filterInput = screen.getByRole("textbox", { + name: "Filter packages", + }); + await user.clear(filterInput); + await user.type(filterInput, "alpha,beta"); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + it("submits pull pocket changes without modifying filters", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await fillRequiredFields(user); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + it("submits pull pocket without filter type", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + await fillRequiredFields(user); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + it("submits mirror pocket changes", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await fillRequiredFields(user); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + it("shows notification when edit pocket submit fails", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "error", path: "EditPocket" }); + + renderWithProviders(); + await fillRequiredFields(user); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); }); diff --git a/src/features/mirrors/components/EditPocketForm/helpers.test.ts b/src/features/mirrors/components/EditPocketForm/helpers.test.ts new file mode 100644 index 000000000..6bbbc04f5 --- /dev/null +++ b/src/features/mirrors/components/EditPocketForm/helpers.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { pockets } from "@/tests/mocks/pockets"; +import type { Pocket } from "../../types"; +import { + getEditPocketParams, + getInitialValues, + getValidationSchema, +} from "./helpers"; +import { INITIAL_VALUES } from "./constants"; + +const mirrorPocket = pockets.find((pocket) => pocket.mode === "mirror"); +const uploadPocket = pockets.find((pocket) => pocket.mode === "upload"); +const pullPocket = pockets.find((pocket) => pocket.mode === "pull"); + +assert(mirrorPocket); +assert(uploadPocket); +assert(pullPocket); + +describe("EditPocketForm helpers", () => { + it("enforces single component for mirror suite directories", async () => { + const schema = getValidationSchema("mirror"); + + await expect( + schema.validate({ + ...INITIAL_VALUES, + name: "Pocket", + distribution: "Distribution", + series: "Series", + mirror_suite: "focal/", + components: ["main", "restricted"], + architectures: ["amd64"], + }), + ).rejects.toThrowError(/single component must be passed/i); + + await expect( + schema.validate({ + ...INITIAL_VALUES, + name: "Pocket", + distribution: "Distribution", + series: "Series", + mirror_suite: "focal/", + components: ["main"], + architectures: ["amd64"], + }), + ).resolves.toBeTruthy(); + }); + + it("allows multiple components for non-mirror modes", async () => { + const schema = getValidationSchema("pull"); + + await expect( + schema.validate({ + ...INITIAL_VALUES, + name: "Pocket", + distribution: "Distribution", + series: "Series", + components: ["main", "restricted"], + architectures: ["amd64"], + }), + ).resolves.toBeTruthy(); + }); + + it("allows multiple components in mirror mode when suite is not a directory", async () => { + const schema = getValidationSchema("mirror"); + + await expect( + schema.validate({ + ...INITIAL_VALUES, + name: "Pocket", + distribution: "Distribution", + series: "Series", + mirror_suite: "focal-updates", + components: ["main", "restricted"], + architectures: ["amd64"], + }), + ).resolves.toBeTruthy(); + }); + + it("builds mode-specific initial values", () => { + const mirrorValues = getInitialValues("Dist", "Series", mirrorPocket); + const uploadValues = getInitialValues("Dist", "Series", uploadPocket); + const pullValues = getInitialValues("Dist", "Series", pullPocket); + + expect(mirrorValues.mirror_uri).toBe(mirrorPocket.mirror_uri); + expect(mirrorValues.mirror_suite).toBe(mirrorPocket.mirror_suite); + expect(uploadValues.upload_allow_unsigned).toBe( + uploadPocket.upload_allow_unsigned, + ); + expect(uploadValues.upload_gpg_keys).toEqual( + uploadPocket.upload_gpg_keys.map((key) => key.name), + ); + expect(pullValues.filters).toEqual(pullPocket.filters); + }); + + it("falls back to empty key names when optional key objects are missing", () => { + const pocketWithoutKeys = { + ...mirrorPocket, + gpg_key: undefined, + mirror_gpg_key: undefined, + } as unknown as Pocket; + + const values = getInitialValues("Dist", "Series", pocketWithoutKeys); + + expect(values.gpg_key).toBe(""); + expect(values.mirror_gpg_key).toBe(""); + }); + + it("returns mode-specific edit params", () => { + const mirrorValues = getInitialValues("Dist", "Series", mirrorPocket); + const uploadValues = getInitialValues("Dist", "Series", uploadPocket); + const pullValues = getInitialValues("Dist", "Series", pullPocket); + + const mirrorParams = getEditPocketParams(mirrorValues, "mirror"); + const uploadParams = getEditPocketParams(uploadValues, "upload"); + const pullParams = getEditPocketParams(pullValues, "pull"); + + expect(mirrorParams).toHaveProperty("mirror_uri"); + expect(mirrorParams).toHaveProperty("mirror_suite"); + expect(uploadParams).toHaveProperty("upload_allow_unsigned"); + expect(pullParams).not.toHaveProperty("mirror_uri"); + expect(pullParams).not.toHaveProperty("upload_allow_unsigned"); + }); + + it("throws for invalid pocket mode when building edit params", () => { + expect(() => + getEditPocketParams( + getInitialValues("Dist", "Series", pullPocket), + "invalid" as Pocket["mode"], + ), + ).toThrowError(/provided: invalid for pocket mode/i); + }); +}); diff --git a/src/features/mirrors/components/EditPocketForm/helpers.ts b/src/features/mirrors/components/EditPocketForm/helpers.ts index fa25d2868..c96359725 100644 --- a/src/features/mirrors/components/EditPocketForm/helpers.ts +++ b/src/features/mirrors/components/EditPocketForm/helpers.ts @@ -28,7 +28,7 @@ export const getValidationSchema = ( test: (value, context) => { const { mirror_suite } = context.parent; - if ("mirror" === mode && mirror_suite.endsWith("/")) { + if ("mirror" === mode && mirror_suite?.endsWith("/")) { return MIN_SELECTION_COUNT === value.length; } diff --git a/src/features/mirrors/components/PackageList/PackageList.test.tsx b/src/features/mirrors/components/PackageList/PackageList.test.tsx index 362bb2e68..172874086 100644 --- a/src/features/mirrors/components/PackageList/PackageList.test.tsx +++ b/src/features/mirrors/components/PackageList/PackageList.test.tsx @@ -1,8 +1,11 @@ import { expectLoadingState } from "@/tests/helpers"; +import { setEndpointStatus } from "@/tests/controllers/controller"; import { listPockets, pockets } from "@/tests/mocks/pockets"; import { renderWithProviders } from "@/tests/render"; -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; +import { waitFor } from "@testing-library/react"; import { describe, expect } from "vitest"; import PackageList from "./PackageList"; @@ -29,8 +32,11 @@ const pullPocketProps: ComponentProps = { seriesName: "Focal Fossa", pocket: pullPocket, }; - describe("PackageList", () => { + beforeEach(() => { + setEndpointStatus("default"); + }); + it("renders common buttons", () => { renderWithProviders(); expect(screen.getByRole("button", { name: /edit/i })).toBeVisible(); @@ -75,4 +81,229 @@ describe("PackageList", () => { const rowCheckboxes = screen.getAllByRole("checkbox").length - 1; expect(rowCheckboxes).toEqual(listPockets.length); }); + + it("does not show sync or search controls for upload pocket", async () => { + renderWithProviders(); + + await expectLoadingState(); + + expect( + screen.queryByRole("button", { name: /^sync$/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /^pull$/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("searchbox", { name: /package search/i }), + ).not.toBeInTheDocument(); + }); + + it("renders empty state when no packages are returned", async () => { + setEndpointStatus("empty"); + + renderWithProviders(); + + await expectLoadingState(); + + expect(screen.getByText("No packages found")).toBeInTheDocument(); + expect( + screen.queryByRole("searchbox", { name: /package search/i }), + ).not.toBeInTheDocument(); + }); + + it("opens the edit side panel from actions", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /edit/i })); + + expect( + await screen.findByRole("heading", { name: /edit .* pocket/i }), + ).toBeInTheDocument(); + }); + + it("removes pocket successfully", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { + name: `Remove ${mirrorPocketProps.pocket.name} pocket of ${mirrorPocketProps.distributionName}/${mirrorPocketProps.seriesName}`, + }), + ); + const dialog = screen.getByRole("dialog", { name: "Deleting pocket" }); + await user.click(within(dialog).getByRole("button", { name: "Delete" })); + + await waitFor(() => { + expect( + screen.queryByRole("dialog", { name: "Deleting pocket" }), + ).not.toBeInTheDocument(); + }); + }); + + it("submits mirror sync confirmation flow", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { + name: new RegExp( + `Synchronize ${mirrorPocketProps.pocket.name} pocket of ${mirrorPocketProps.distributionName}/${mirrorPocketProps.seriesName}`, + "i", + ), + }), + ); + + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + it("submits pull sync flow", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { + name: new RegExp( + `Pull packages to ${pullPocketProps.pocket.name} pocket of ${pullPocketProps.distributionName}/${pullPocketProps.seriesName}`, + "i", + ), + }), + ); + + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + it("renders pull package diffs with updates and deletions", async () => { + setEndpointStatus({ status: "default", path: "DiffPullPocketUpdate" }); + renderWithProviders(); + + await expectLoadingState(); + await waitFor(() => { + expect( + document.querySelectorAll(".p-icon--warning").length, + ).toBeGreaterThan(0); + }); + }); + + it("renders pull package list when diffs contain only additions", async () => { + setEndpointStatus({ status: "default", path: "DiffPullPocketAddOnly" }); + + renderWithProviders(); + await expectLoadingState(); + + expect(await screen.findByText("pocket1")).toBeInTheDocument(); + expect(document.querySelector(".p-icon--warning")).not.toBeInTheDocument(); + }); + + it("submits upload remove-packages flow", async () => { + const user = userEvent.setup(); + const [firstPackage] = listPockets; + assert(firstPackage); + renderWithProviders(); + + await expectLoadingState(); + await user.click(screen.getByLabelText(firstPackage.name)); + await user.click( + screen.getByRole("button", { name: /remove selected packages from/i }), + ); + const dialog = screen.getByRole("dialog", { + name: /deleting packages from pocket/i, + }); + await user.click(within(dialog).getByRole("button", { name: /^delete$/i })); + + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + it("searches and clears package search", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await expectLoadingState(); + + const searchInput = screen.getByRole("searchbox", { + name: /package search/i, + }); + await user.type(searchInput, "pocket1{enter}"); + expect(searchInput).toHaveValue("pocket1"); + + await user.click( + screen.getByRole("button", { name: /clear search field/i }), + ); + expect(searchInput).toHaveValue(""); + }); + + it("resets selected packages when paginating upload list", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "default", path: "ListPocketMany" }); + renderWithProviders(); + + await screen.findByRole("checkbox", { name: "Toggle all" }); + const [, firstRowCheckbox] = screen.getAllByRole("checkbox"); + assert(firstRowCheckbox); + await user.click(firstRowCheckbox); + await user.click(screen.getByRole("button", { name: /next page/i })); + await user.click(screen.getByRole("button", { name: /previous page/i })); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /remove selected packages from/i }), + ).toHaveAttribute("aria-disabled", "true"); + }); + }); + + it("changes page size via pagination select", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "default", path: "ListPocketMany" }); + renderWithProviders(); + + await screen.findByRole("combobox", { name: "Instances per page" }); + await user.selectOptions( + screen.getByRole("combobox", { name: "Instances per page" }), + "50", + ); + + expect( + screen.queryByRole("button", { name: /next page/i }), + ).not.toBeInTheDocument(); + }); + + it("shows error notification when removing pocket fails", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "error", path: "RemovePocket" }); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { + name: `Remove ${mirrorPocketProps.pocket.name} pocket of ${mirrorPocketProps.distributionName}/${mirrorPocketProps.seriesName}`, + }), + ); + const dialog = screen.getByRole("dialog", { name: "Deleting pocket" }); + await user.click(within(dialog).getByRole("button", { name: "Delete" })); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); + + it("shows error notification when removing packages fails", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "error", path: "RemovePackagesFromPocket" }); + renderWithProviders(); + + const [firstPocket] = listPockets; + assert(firstPocket); + await screen.findByRole("checkbox", { name: firstPocket.name }); + await user.click(screen.getByLabelText(firstPocket.name)); + await user.click( + screen.getByRole("button", { name: /remove selected packages from/i }), + ); + const dialog = screen.getByRole("dialog", { + name: /deleting packages from pocket/i, + }); + await user.click(within(dialog).getByRole("button", { name: /^delete$/i })); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); }); diff --git a/src/features/mirrors/components/PackageList/PackageList.tsx b/src/features/mirrors/components/PackageList/PackageList.tsx index 3a0683678..4833f6ed7 100644 --- a/src/features/mirrors/components/PackageList/PackageList.tsx +++ b/src/features/mirrors/components/PackageList/PackageList.tsx @@ -74,19 +74,20 @@ const PackageList: FC = ({ pullPackagesToPocketQuery; const handleSync = (): void => { - if ("mirror" === pocket.mode) { - syncMirrorPocket({ - name: pocket.name, - series: seriesName, - distribution: distributionName, - }); - } else if ("pull" === pocket.mode) { + if ("pull" === pocket.mode) { pullPackagesToPocket({ name: pocket.name, series: seriesName, distribution: distributionName, }); + return; } + + syncMirrorPocket({ + name: pocket.name, + series: seriesName, + distribution: distributionName, + }); }; const handleEditPocket = (): void => { @@ -289,6 +290,8 @@ const PackageList: FC = ({ size: 6, medium: 3, }; + const isSyncButtonDisabled = + isSynchronizingMirrorPocket || isPullingPackagesToPocket; return ( <> @@ -306,9 +309,7 @@ const PackageList: FC = ({ className="p-segmented-control__button" type="button" onClick={handleSync} - disabled={ - isSynchronizingMirrorPocket && isPullingPackagesToPocket - } + disabled={isSyncButtonDisabled} aria-label={ "mirror" === pocket.mode ? `Synchronize ${pocket.name} pocket of ${distributionName}/${seriesName}` diff --git a/src/features/overview/components/AlertCard/AlertCard.test.tsx b/src/features/overview/components/AlertCard/AlertCard.test.tsx index 7eae6d77f..003259644 100644 --- a/src/features/overview/components/AlertCard/AlertCard.test.tsx +++ b/src/features/overview/components/AlertCard/AlertCard.test.tsx @@ -1,10 +1,12 @@ import type { Status } from "@/features/instances"; import { ALERT_STATUSES } from "@/features/instances"; import { setEndpointStatus } from "@/tests/controllers/controller"; -import { expectErrorNotification, expectLoadingState } from "@/tests/helpers"; +import { expectErrorNotification } from "@/tests/helpers"; import { renderWithProviders } from "@/tests/render"; import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect } from "vitest"; +import { ROUTES } from "@/libs/routes"; import AlertCard from "./AlertCard"; const alert = @@ -34,7 +36,64 @@ describe("AlertCard", () => { const alertLabel = screen.getByText(props.alternateLabel); expect(alertLabel).toBeInTheDocument(); - await expectLoadingState(); + const errorText = await screen.findByText("Error loading data."); + expect(errorText).toBeInTheDocument(); await expectErrorNotification(); }); + + it("uses label fallback and icon fallback when alternateLabel and gray icon are missing", () => { + const fallbackAlert = ALERT_STATUSES.PackageUpgradesAlert; + + renderWithProviders( + , + ); + + expect(screen.getByText(fallbackAlert.label)).toBeInTheDocument(); + }); + + it("renders a link to instances route for non-pending alerts", async () => { + renderWithProviders(); + + const link = await screen.findByRole("link", { name: /instance/i }); + expect(link).toHaveAttribute( + "href", + ROUTES.instances.root({ status: props.filterValue }), + ); + }); + + it("opens pending instances review in side panel", async () => { + const user = userEvent.setup(); + const pendingAlert = ALERT_STATUSES.PendingComputersAlert; + + renderWithProviders(); + + const reviewButton = await screen.findByRole("button", { + name: /instance/i, + }); + await user.click(reviewButton); + + expect( + await screen.findByRole("heading", { name: "Review Pending Instances" }), + ).toBeInTheDocument(); + }); + + it("renders zero-count state for pending alerts with no pending instances", async () => { + setEndpointStatus({ status: "empty", path: "GetPendingComputers" }); + const pendingAlert = ALERT_STATUSES.PendingComputersAlert; + + renderWithProviders(); + + expect(await screen.findByText("0")).toBeInTheDocument(); + expect(screen.getByText("instances")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /instance/i }), + ).not.toBeInTheDocument(); + }); }); diff --git a/src/features/package-profiles/components/PackageProfileAddSidePanel/PackageProfileAddSidePanel.test.tsx b/src/features/package-profiles/components/PackageProfileAddSidePanel/PackageProfileAddSidePanel.test.tsx index 1a357eb12..fa26ad736 100644 --- a/src/features/package-profiles/components/PackageProfileAddSidePanel/PackageProfileAddSidePanel.test.tsx +++ b/src/features/package-profiles/components/PackageProfileAddSidePanel/PackageProfileAddSidePanel.test.tsx @@ -7,6 +7,7 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect } from "vitest"; import PackageProfileAddSidePanel from "./PackageProfileAddSidePanel"; +import { NO_DATA_TEXT } from "@/components/layout/NoData"; describe("PackageProfileAddSidePanel", () => { const user = userEvent.setup(); @@ -26,7 +27,7 @@ describe("PackageProfileAddSidePanel", () => { ); await user.type( screen.getByRole("textbox", { name: "Description" }), - "---", + NO_DATA_TEXT, ); await user.selectOptions( screen.getByRole("combobox", { name: "Package constraints" }), diff --git a/src/features/processes/components/ProcessesHeader/ProcessesHeader.test.tsx b/src/features/processes/components/ProcessesHeader/ProcessesHeader.test.tsx index bd901e16b..7416f277e 100644 --- a/src/features/processes/components/ProcessesHeader/ProcessesHeader.test.tsx +++ b/src/features/processes/components/ProcessesHeader/ProcessesHeader.test.tsx @@ -1,20 +1,28 @@ +import { setEndpointStatus } from "@/tests/controllers/controller"; +import { expectErrorNotification } from "@/tests/helpers"; import { renderWithProviders } from "@/tests/render"; -import ProcessesHeader from "./ProcessesHeader"; -import { vi } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { vi } from "vitest"; +import { PATHS, ROUTES } from "@/libs/routes"; +import ProcessesHeader from "./ProcessesHeader"; const props = { selectedPids: [], handleClearSelection: vi.fn(), }; +const SELECTED_PID = 123; +const INSTANCE_ID = 11; + describe("ProcessesHeader", () => { beforeEach(() => { - renderWithProviders(); + setEndpointStatus("default"); }); it("should render ProcessesHeader correctly", () => { + renderWithProviders(); + const buttons = ["End process", "Kill process"]; expect(screen.getByRole("searchbox")).toBeInTheDocument(); buttons.forEach((button) => { @@ -23,12 +31,16 @@ describe("ProcessesHeader", () => { }); it("should write in search", async () => { + renderWithProviders(); + const searchBox = screen.getByRole("searchbox"); await userEvent.type(searchBox, "test{enter}"); expect(searchBox).toHaveValue("test"); }); it("should clear search box", async () => { + renderWithProviders(); + const searchBox = screen.getByRole("searchbox"); await userEvent.type(searchBox, "test"); await userEvent.click( @@ -36,4 +48,116 @@ describe("ProcessesHeader", () => { ); expect(searchBox).toHaveValue(""); }); + + it("disables process action buttons when no pids are selected", () => { + renderWithProviders( + , + ); + + expect(screen.getByRole("button", { name: "End process" })).toHaveAttribute( + "aria-disabled", + "true", + ); + expect( + screen.getByRole("button", { name: "Kill process" }), + ).toHaveAttribute("aria-disabled", "true"); + }); + + it("enables process action buttons when pids are selected", () => { + renderWithProviders( + , + ); + + expect(screen.getByRole("button", { name: "End process" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Kill process" })).toBeEnabled(); + }); + + it("ends selected processes and clears selection", async () => { + const user = userEvent.setup(); + const handleClearSelection = vi.fn(); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(INSTANCE_ID), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + await user.click(screen.getByRole("button", { name: "End process" })); + + expect( + await screen.findByText(/Process successfully ended\./i), + ).toBeInTheDocument(); + expect(handleClearSelection).toHaveBeenCalledTimes(1); + }); + + it("kills selected processes and clears selection", async () => { + const user = userEvent.setup(); + const handleClearSelection = vi.fn(); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(INSTANCE_ID), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + await user.click(screen.getByRole("button", { name: "Kill process" })); + + expect( + await screen.findByText(/Processes successfully killed\./i), + ).toBeInTheDocument(); + expect(handleClearSelection).toHaveBeenCalledTimes(1); + }); + + it("shows error notification when ending process fails", async () => { + const user = userEvent.setup(); + const handleClearSelection = vi.fn(); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(INSTANCE_ID), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + setEndpointStatus("error"); + await user.click(screen.getByRole("button", { name: "End process" })); + + await expectErrorNotification(); + expect(handleClearSelection).not.toHaveBeenCalled(); + }); + + it("shows error notification when killing process fails", async () => { + const user = userEvent.setup(); + const handleClearSelection = vi.fn(); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(INSTANCE_ID), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + setEndpointStatus("error"); + await user.click(screen.getByRole("button", { name: "Kill process" })); + + await expectErrorNotification(); + expect(handleClearSelection).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/scripts/components/AttachmentFile/AttachmentFile.test.tsx b/src/features/scripts/components/AttachmentFile/AttachmentFile.test.tsx index d8872e6ec..453b3a5aa 100644 --- a/src/features/scripts/components/AttachmentFile/AttachmentFile.test.tsx +++ b/src/features/scripts/components/AttachmentFile/AttachmentFile.test.tsx @@ -1,9 +1,8 @@ -import { setEndpointStatus } from "@/tests/controllers/controller"; import { renderWithProviders } from "@/tests/render"; -import { screen, waitFor } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; -import { beforeEach, describe, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import AttachmentFile from "./AttachmentFile"; const createObjectURLMock = vi.fn((_object: Blob | MediaSource) => "blob:test"); @@ -35,9 +34,9 @@ const propsWithInitialAttachmentDelete: ComponentProps = }; describe("AttachmentFile", () => { - beforeEach(() => { - setEndpointStatus("default"); + afterEach(() => { vi.clearAllMocks(); + vi.restoreAllMocks(); }); it("should display attachment file with script id prop", async () => { @@ -74,32 +73,75 @@ describe("AttachmentFile", () => { expect(deleteButton).toBeInTheDocument(); }); - it("downloads blob attachments and revokes object URL", async () => { - const clickSpy = vi + it("calls delete callback when remove button is clicked", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click( + screen.getByRole("button", { + name: `Remove ${propsWithInitialAttachmentDelete.filename} attachment`, + }), + ); + + expect( + propsWithInitialAttachmentDelete.onInitialAttachmentDelete, + ).toHaveBeenCalledTimes(1); + }); + + it("downloads attachment file when download button is clicked", async () => { + const user = userEvent.setup(); + const anchorClickSpy = vi .spyOn(HTMLAnchorElement.prototype, "click") .mockImplementation(() => undefined); - const user = userEvent.setup(); + const createObjectUrlSpy = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:attachment"); + const revokeObjectUrlSpy = vi.spyOn(URL, "revokeObjectURL"); renderWithProviders(); + await user.click( screen.getByRole("button", { name: `Download ${propsWithScriptId.filename}`, }), ); - await waitFor(() => { - expect(createObjectURLMock).toHaveBeenCalledTimes(1); - expect(clickSpy).toHaveBeenCalledTimes(1); - expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:test"); - }); + expect(createObjectUrlSpy).toHaveBeenCalledTimes(1); + + const [downloadedAttachmentBlob] = createObjectUrlSpy.mock.calls[0] ?? []; + expect(downloadedAttachmentBlob).toBeInstanceOf(Blob); + expect(await (downloadedAttachmentBlob as Blob).text()).toContain( + "attachment", + ); - const blobToDownload = createObjectURLMock.mock.calls[0]?.[0]; - expect(blobToDownload).toBeInstanceOf(Blob); - if (!(blobToDownload instanceof Blob)) { - throw new Error("Expected downloaded data to be a Blob."); - } - expect(blobToDownload.type).toContain("text/plain"); + expect(anchorClickSpy).toHaveBeenCalledTimes(1); + expect(revokeObjectUrlSpy).toHaveBeenCalledWith("blob:attachment"); + }); + + it("does not try to download when attachment data is missing", async () => { + const user = userEvent.setup(); + const createObjectUrlSpy = vi.spyOn(URL, "createObjectURL"); + const anchorClickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); + const missingDataProps: ComponentProps = { + attachmentId: 999, + filename: "missing.txt", + scriptId: 999, + }; + + renderWithProviders(); + + await user.click( + screen.getByRole("button", { + name: `Download ${missingDataProps.filename}`, + }), + ); - clickSpy.mockRestore(); + expect(createObjectUrlSpy).not.toHaveBeenCalled(); + expect(anchorClickSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/features/scripts/components/AttachmentFile/AttachmentFile.tsx b/src/features/scripts/components/AttachmentFile/AttachmentFile.tsx index 42a55d5f0..fde4fc2ab 100644 --- a/src/features/scripts/components/AttachmentFile/AttachmentFile.tsx +++ b/src/features/scripts/components/AttachmentFile/AttachmentFile.tsx @@ -30,8 +30,8 @@ const AttachmentFile: FC = ({ const handleDownload = async () => { try { const { data } = await refetch(); - if (!data) { - throw new Error("Could not download attachment."); + if (!data || data.data === null || data.data === undefined) { + return; } const url = URL.createObjectURL(data.data); diff --git a/src/features/scripts/components/CreateScriptForm/CreateScriptForm.test.tsx b/src/features/scripts/components/CreateScriptForm/CreateScriptForm.test.tsx index 6288ba334..818d73582 100644 --- a/src/features/scripts/components/CreateScriptForm/CreateScriptForm.test.tsx +++ b/src/features/scripts/components/CreateScriptForm/CreateScriptForm.test.tsx @@ -1,9 +1,15 @@ +import { setEndpointStatus } from "@/tests/controllers/controller"; import { renderWithProviders } from "@/tests/render"; -import { screen } from "@testing-library/react"; -import { describe, it } from "vitest"; +import { fireEvent, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, it, vi } from "vitest"; import CreateScriptForm from "./CreateScriptForm"; describe("CreateScriptForm", () => { + beforeEach(() => { + setEndpointStatus("default"); + }); + it("should display add script form", async () => { renderWithProviders(); @@ -17,4 +23,167 @@ describe("CreateScriptForm", () => { expect(screen.getByText(/list of attachments/i)).toBeInTheDocument(); expect(screen.getByText(/add script/i)).toBeInTheDocument(); }); + + it("populates title and code from an uploaded file", async () => { + const user = userEvent.setup(); + const file = new File(["echo hello"], "bootstrap.sh", { + type: "text/x-shellscript", + }); + + renderWithProviders(); + + await user.upload(screen.getByTestId("create-script-upload-input"), file); + + expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue( + "bootstrap", + ); + expect(screen.getByTestId("mock-monaco")).toHaveValue("echo hello"); + }); + + it("keeps entered title when uploading a file", async () => { + const user = userEvent.setup(); + const file = new File(["echo from file"], "new-title.sh", { + type: "text/x-shellscript", + }); + + renderWithProviders(); + + const titleInput = screen.getByRole("textbox", { name: "Title" }); + await user.type(titleInput, "custom title"); + + await user.upload(screen.getByTestId("create-script-upload-input"), file); + + expect(titleInput).toHaveValue("custom title"); + expect(screen.getByTestId("mock-monaco")).toHaveValue("echo from file"); + }); + + it("shows required field validation on empty submit", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Add script" })); + + expect(await screen.findAllByText("This field is required")).toHaveLength( + 2, + ); + }); + + it("uploads attachments in form inputs", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.upload( + screen.getByLabelText("first attachment"), + new File(["print('hello')"], "first.py", { type: "text/plain" }), + ); + + expect(screen.getByLabelText("first attachment")).toHaveValue( + "C:\\fakepath\\first.py", + ); + }); + + it("submits script with attachments", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.type( + screen.getByRole("textbox", { name: "Title" }), + "My script", + ); + await user.type(screen.getByTestId("mock-monaco"), "echo run"); + await user.upload( + screen.getByLabelText("first attachment"), + new File(["attachment"], "notes.txt", { type: "text/plain" }), + ); + + await user.click(screen.getByRole("button", { name: "Add script" })); + + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + it("submits script without attachments", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.type( + screen.getByRole("textbox", { name: "Title" }), + "My script", + ); + await user.type(screen.getByTestId("mock-monaco"), "echo run"); + await user.click(screen.getByRole("button", { name: "Add script" })); + + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + + it("shows error notification when create script fails", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "error", path: "CreateScript" }); + renderWithProviders(); + + await user.type( + screen.getByRole("textbox", { name: "Title" }), + "My script", + ); + await user.type(screen.getByTestId("mock-monaco"), "echo run"); + await user.click(screen.getByRole("button", { name: "Add script" })); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); + + it("shows error notification when create script attachment fails", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "error", path: "CreateScriptAttachment" }); + renderWithProviders(); + + await user.type( + screen.getByRole("textbox", { name: "Title" }), + "My script", + ); + await user.type(screen.getByTestId("mock-monaco"), "echo run"); + await user.upload( + screen.getByLabelText("first attachment"), + new File(["attachment"], "notes.txt", { type: "text/plain" }), + ); + await user.click(screen.getByRole("button", { name: "Add script" })); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); + + it("ignores empty file upload input", async () => { + renderWithProviders(); + + fireEvent.change(screen.getByTestId("create-script-upload-input"), { + target: { files: null }, + }); + + expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue(""); + }); + + it("handles attachment input with no file selected", () => { + renderWithProviders(); + + fireEvent.change(screen.getByLabelText("first attachment"), { + target: { files: [] }, + }); + + expect(screen.getByLabelText("first attachment")).toHaveValue(""); + }); + + it("opens hidden file input from populate button", async () => { + const user = userEvent.setup(); + const clickSpy = vi.spyOn(HTMLInputElement.prototype, "click"); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { name: "Populate from file" }), + ); + + expect(clickSpy).toHaveBeenCalled(); + clickSpy.mockRestore(); + }); }); diff --git a/src/features/scripts/components/CreateScriptForm/CreateScriptForm.tsx b/src/features/scripts/components/CreateScriptForm/CreateScriptForm.tsx index 03a9ac399..ed8886d33 100644 --- a/src/features/scripts/components/CreateScriptForm/CreateScriptForm.tsx +++ b/src/features/scripts/components/CreateScriptForm/CreateScriptForm.tsx @@ -108,6 +108,7 @@ const CreateScript: FC = () => { ref={inputRef} className="u-hide" type="file" + data-testid="create-script-upload-input" onChange={handleInputChange} /> @@ -163,12 +164,6 @@ const CreateScript: FC = () => { e.target.files?.[0] ?? null, ) } - onInitialAttachmentDelete={(attachment: string) => { - formik.setFieldValue("attachmentsToRemove", [ - ...formik.values.attachmentsToRemove, - attachment, - ]); - }} /> = { script, @@ -14,7 +19,9 @@ const props: ComponentProps = { }; describe("EditScriptForm", () => { - const user = userEvent.setup(); + beforeEach(() => { + setEndpointStatus("default"); + }); it("should display edit script form", async () => { renderWithProviders(); @@ -26,6 +33,8 @@ describe("EditScriptForm", () => { }); it("should show the edit confirmation modal when 'Submit new version' is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); await user.type(screen.getByRole("textbox", { name: /title/i }), " edited"); @@ -42,6 +51,7 @@ describe("EditScriptForm", () => { }); it("should submit the new version and show a success notification", async () => { + const user = userEvent.setup(); renderWithProviders(); await user.type(screen.getByRole("textbox", { name: /title/i }), " edited"); @@ -59,4 +69,291 @@ describe("EditScriptForm", () => { await screen.findByText(/successfully submitted a new version/i), ).toBeInTheDocument(); }); + + it("populates title and code from an uploaded file when title is empty", async () => { + const user = userEvent.setup(); + const editableScript = { ...script, title: "" }; + const file = new File(["echo from upload"], "deploy.sh", { + type: "text/x-shellscript", + }); + + renderWithProviders(); + + const fileInput = screen.getByTestId("edit-script-upload-input"); + + await user.upload(fileInput, file); + + expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue( + "deploy", + ); + expect(screen.getByTestId("mock-monaco")).toHaveValue("echo from upload"); + }); + + it("keeps existing title when uploading a file", async () => { + const user = userEvent.setup(); + const file = new File(["echo from upload"], "should-not-replace.sh", { + type: "text/x-shellscript", + }); + + renderWithProviders(); + + const titleInput = screen.getByRole("textbox", { name: "Title" }); + const existingTitle = titleInput.getAttribute("value"); + + const fileInput = screen.getByTestId("edit-script-upload-input"); + await user.upload(fileInput, file); + + expect(titleInput).toHaveValue(existingTitle); + expect(screen.getByTestId("mock-monaco")).toHaveValue("echo from upload"); + }); + + it("updates code editor value", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const editor = screen.getByTestId("mock-monaco"); + await user.clear(editor); + await user.type(editor, "echo changed"); + + expect(editor).toHaveValue("echo changed"); + }); + + it("submits new version with attachment changes", async () => { + const user = userEvent.setup(); + const scriptWithAttachment = scripts.find( + (entry) => entry.attachments.length, + ); + assert(scriptWithAttachment); + const [initialAttachment] = scriptWithAttachment.attachments; + assert(initialAttachment); + + renderWithProviders(); + + await user.click( + screen.getByRole("button", { + name: `Remove ${initialAttachment.filename} attachment`, + }), + ); + + await user.upload( + screen.getByLabelText("second attachment"), + new File(["new attachment"], "new.txt", { type: "text/plain" }), + ); + + await user.click( + screen.getByRole("button", { name: "Submit new version" }), + ); + + const dialog = screen.getByRole("dialog", { + name: `Submit new version of "${scriptWithAttachment.title}"`, + }); + await user.click( + within(dialog).getByRole("button", { name: "Submit new version" }), + ); + + expect(screen.queryByText("Network Error")).not.toBeInTheDocument(); + }); + + it("opens hidden file input from populate button", async () => { + const user = userEvent.setup(); + const clickSpy = vi.spyOn(HTMLInputElement.prototype, "click"); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { name: "Populate from file" }), + ); + + expect(clickSpy).toHaveBeenCalled(); + clickSpy.mockRestore(); + }); + + it("ignores empty file upload input", async () => { + renderWithProviders(); + + fireEvent.change(screen.getByTestId("edit-script-upload-input"), { + target: { files: null }, + }); + + expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue( + script.title, + ); + }); + + it("handles attachment input with no file selected", () => { + renderWithProviders(); + + fireEvent.change(screen.getByLabelText("first attachment"), { + target: { files: [] }, + }); + + expect(screen.getByLabelText("first attachment")).toHaveValue(""); + }); + + it("submits new version with associated profiles links", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { name: "Submit new version" }), + ); + + expect( + await screen.findByText( + /submitting these changes will affect the following profiles/i, + ), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "abc" })).toHaveAttribute( + "href", + ROUTES.scripts.root({ tab: "profiles" }), + ); + expect(screen.getByRole("link", { name: "12 instances" })).toHaveAttribute( + "href", + ROUTES.instances.root(), + ); + }); + + it("renders singular associated instance link text", async () => { + const user = userEvent.setup(); + const associatedProfile = scriptProfiles.find( + (profile) => profile.id === ASSOCIATED_PROFILE_ID, + ); + assert(associatedProfile); + const originalAssociatedCount = + associatedProfile.computers.num_associated_computers; + associatedProfile.computers.num_associated_computers = 1; + + try { + renderWithProviders(); + + await user.click( + screen.getByRole("button", { name: "Submit new version" }), + ); + + expect( + await screen.findByRole("link", { name: "1 instance" }), + ).toHaveAttribute("href", ROUTES.instances.root()); + } finally { + associatedProfile.computers.num_associated_computers = + originalAssociatedCount; + } + }); + + it("shows no data for associated profiles without instances", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click( + screen.getByRole("button", { name: "Submit new version" }), + ); + + expect(await screen.findByText(NO_DATA_TEXT)).toBeInTheDocument(); + }); + + it("submits when there are no associated profiles", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "empty", path: "script-profiles" }); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { name: "Submit new version" }), + ); + + expect( + await screen.findByText( + /all future script runs will be done using the latest version of the code\./i, + ), + ).toBeInTheDocument(); + expect( + screen.queryByText(/affect the following profiles/i), + ).not.toBeInTheDocument(); + }); + + it("closes confirmation modal with cancel button", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { name: "Submit new version" }), + ); + const dialog = await screen.findByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("shows error notification when edit script fails", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "error", path: "EditScript" }); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { name: "Submit new version" }), + ); + const dialog = await screen.findByRole("dialog"); + await user.click( + within(dialog).getByRole("button", { name: "Submit new version" }), + ); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); + + it("shows error notification when removing attachment fails", async () => { + const user = userEvent.setup(); + const scriptWithAttachment = scripts.find( + (entry) => entry.attachments.length, + ); + assert(scriptWithAttachment); + const [initialAttachment] = scriptWithAttachment.attachments; + assert(initialAttachment); + setEndpointStatus({ status: "error", path: "RemoveScriptAttachment" }); + renderWithProviders(); + + await user.click( + screen.getByRole("button", { + name: `Remove ${initialAttachment.filename} attachment`, + }), + ); + await user.click( + screen.getByRole("button", { name: "Submit new version" }), + ); + const dialog = await screen.findByRole("dialog"); + await user.click( + within(dialog).getByRole("button", { name: "Submit new version" }), + ); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); + + it("shows error notification when adding attachment fails", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "error", path: "CreateScriptAttachment" }); + renderWithProviders(); + + await user.upload( + screen.getByLabelText("first attachment"), + new File(["new attachment"], "new.txt", { type: "text/plain" }), + ); + await user.click( + screen.getByRole("button", { name: "Submit new version" }), + ); + const dialog = await screen.findByRole("dialog"); + await user.click( + within(dialog).getByRole("button", { name: "Submit new version" }), + ); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); }); diff --git a/src/features/scripts/components/EditScriptForm/EditScriptForm.tsx b/src/features/scripts/components/EditScriptForm/EditScriptForm.tsx index 35d889262..dec4c7f67 100644 --- a/src/features/scripts/components/EditScriptForm/EditScriptForm.tsx +++ b/src/features/scripts/components/EditScriptForm/EditScriptForm.tsx @@ -135,6 +135,7 @@ const EditScriptForm: FC = ({ script, onBack }) => { ref={inputRef} className="u-hide" type="file" + data-testid="edit-script-upload-input" onChange={handleInputChange} /> diff --git a/src/features/scripts/components/RunScriptForm/RunScriptForm.test.tsx b/src/features/scripts/components/RunScriptForm/RunScriptForm.test.tsx index b84e77597..b7d94145b 100644 --- a/src/features/scripts/components/RunScriptForm/RunScriptForm.test.tsx +++ b/src/features/scripts/components/RunScriptForm/RunScriptForm.test.tsx @@ -1,8 +1,9 @@ +import { setEndpointStatus } from "@/tests/controllers/controller"; import { scripts } from "@/tests/mocks/script"; import { renderWithProviders } from "@/tests/render"; import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, it } from "vitest"; +import { beforeEach, describe, it } from "vitest"; import RunScriptForm from "./RunScriptForm"; const [script] = scripts; @@ -18,6 +19,10 @@ const selectTag = async ( describe("RunScriptForm", () => { const user = userEvent.setup(); + beforeEach(() => { + setEndpointStatus("default"); + }); + it("should display run script form", async () => { renderWithProviders(); @@ -176,7 +181,7 @@ describe("RunScriptForm", () => { await screen.findByText(/no instances to run script on/i), ).toBeInTheDocument(); expect( - screen.getByText(/please select different tags and try again/i), + screen.getByText(/select different tags and try again/i), ).toBeInTheDocument(); }); diff --git a/src/features/scripts/components/RunScriptForm/RunScriptForm.tsx b/src/features/scripts/components/RunScriptForm/RunScriptForm.tsx index 095d812a0..68f1b404b 100644 --- a/src/features/scripts/components/RunScriptForm/RunScriptForm.tsx +++ b/src/features/scripts/components/RunScriptForm/RunScriptForm.tsx @@ -166,10 +166,9 @@ const RunScriptForm: FC = ({ value: tag, })) ?? []; - const instancesWithScriptsFeature = - instances.filter((instance) => { - return getFeatures(instance).scripts; - }) ?? []; + const instancesWithScriptsFeature = instances.filter((instance) => { + return getFeatures(instance).scripts; + }); const instanceOptions: MultiSelectItem[] = instancesWithScriptsFeature.map( ({ title, id }) => ({ @@ -178,10 +177,11 @@ const RunScriptForm: FC = ({ }), ); - const taggedInstancesWithScriptsFeature = - taggedInstances.filter((instance) => { + const taggedInstancesWithScriptsFeature = taggedInstances.filter( + (instance) => { return getFeatures(instance).scripts; - }) ?? []; + }, + ); const shouldShowNoTaggedInstancesWarning = hasClosedTagDropdown && diff --git a/src/features/scripts/components/ScriptFormAttachments/ScriptFormAttachments.tsx b/src/features/scripts/components/ScriptFormAttachments/ScriptFormAttachments.tsx index be259a6fc..d07d40dae 100644 --- a/src/features/scripts/components/ScriptFormAttachments/ScriptFormAttachments.tsx +++ b/src/features/scripts/components/ScriptFormAttachments/ScriptFormAttachments.tsx @@ -14,7 +14,7 @@ interface ScriptFormAttachmentsProps { readonly onFileInputChange: ( key: keyof ScriptFormValues["attachments"], ) => ChangeEventHandler; - readonly onInitialAttachmentDelete: (attachment: string) => void; + readonly onInitialAttachmentDelete?: (attachment: string) => void; readonly scriptId?: number; } @@ -51,7 +51,7 @@ const ScriptFormAttachments: FC = ({ filename={initialAttachment.filename} scriptId={scriptId} onInitialAttachmentDelete={() => { - onInitialAttachmentDelete(initialAttachment.filename); + onInitialAttachmentDelete?.(initialAttachment.filename); }} /> ); diff --git a/src/features/scripts/helpers.test.ts b/src/features/scripts/helpers.test.ts new file mode 100644 index 000000000..96662c65d --- /dev/null +++ b/src/features/scripts/helpers.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; +import moment from "moment"; +import { DISPLAY_DATE_TIME_FORMAT } from "@/constants"; +import { + formatTitleCase, + getAuthorInfo, + getCode, + getCreateAttachmentsPromises, + getCreateScriptParams, + getEditScriptParams, + removeFileExtension, +} from "./helpers"; + +const SCRIPT_ID = 42; +const FILE_CONTENT = "hello world"; +const emptyAttachments = { + first: null, + second: null, + third: null, + fourth: null, + fifth: null, +}; + +describe("scripts helpers", () => { + it("builds create params with encoded code and trimmed title", () => { + const params = getCreateScriptParams({ + title: " My Script ", + code: "#!/bin/bash\necho hi\r\n", + access_group: "global", + attachments: emptyAttachments, + attachmentsToRemove: [], + }); + + expect(params.title).toBe("My Script"); + expect(params.script_type).toBe("V2"); + expect(params.access_group).toBe("global"); + expect(params.code).toBe("IyEvYmluL2Jhc2gKZWNobyBoaQo="); + }); + + it("builds edit params with script id", () => { + const params = getEditScriptParams({ + scriptId: SCRIPT_ID, + values: { + title: " Updated Script ", + code: "echo edited", + access_group: "global", + attachments: emptyAttachments, + attachmentsToRemove: [], + }, + }); + + expect(params.script_id).toBe(SCRIPT_ID); + expect(params.title).toBe("Updated Script"); + expect(params.code).toBe("ZWNobyBlZGl0ZWQ="); + }); + + it("creates attachment upload promises with encoded payloads", async () => { + const createScriptAttachment = vi.fn().mockResolvedValue({ ok: true }); + + const textFile = new File([FILE_CONTENT], "notes.txt", { + type: "text/plain", + }); + const binaryFile = new File([new Uint8Array([1, 2, 3])], "blob.bin", { + type: "application/octet-stream", + }); + + const uploadPromises = await getCreateAttachmentsPromises({ + attachments: [textFile, binaryFile], + createScriptAttachment, + script_id: SCRIPT_ID, + }); + + await Promise.all(uploadPromises); + + expect(createScriptAttachment).toHaveBeenCalledTimes(2); + expect(createScriptAttachment).toHaveBeenNthCalledWith(1, { + file: "notes.txt$$aGVsbG8gd29ybGQ=", + script_id: SCRIPT_ID, + }); + expect(createScriptAttachment).toHaveBeenNthCalledWith(2, { + file: "blob.bin$$AQID", + script_id: SCRIPT_ID, + }); + }); + + it("handles common filename transformations", () => { + expect(removeFileExtension("archive.tar.gz")).toBe("archive.tar"); + expect(removeFileExtension("README")).toBe("README"); + expect(formatTitleCase("hELLO")).toBe("Hello"); + }); + + it("formats shebang code and author metadata", () => { + const interpreter = "/usr/bin/python3"; + const code = "print('ok')"; + const date = "2024-01-01T12:34:56Z"; + + expect(getCode({ interpreter, code })).toBe( + "#!/usr/bin/python3\nprint('ok')", + ); + expect(getAuthorInfo({ author: "alice", date })).toBe( + `${moment(date).format(DISPLAY_DATE_TIME_FORMAT)}, by alice`, + ); + }); +}); diff --git a/src/features/security-profiles/components/SecurityProfileDetailsSidePanel/SecurityProfileDetailsSidePanel.test.tsx b/src/features/security-profiles/components/SecurityProfileDetailsSidePanel/SecurityProfileDetailsSidePanel.test.tsx index 551b385c2..8741ad396 100644 --- a/src/features/security-profiles/components/SecurityProfileDetailsSidePanel/SecurityProfileDetailsSidePanel.test.tsx +++ b/src/features/security-profiles/components/SecurityProfileDetailsSidePanel/SecurityProfileDetailsSidePanel.test.tsx @@ -2,6 +2,7 @@ import { renderWithProviders } from "@/tests/render"; import { screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import SecurityProfileDetailsSidePanel from "./SecurityProfileDetailsSidePanel"; +import { NO_DATA_TEXT } from "@/components/layout/NoData"; describe("SecurityProfileDetailsSidePanel", () => { it("should render without data", async () => { @@ -11,7 +12,7 @@ describe("SecurityProfileDetailsSidePanel", () => { "/?profile=7", ); - expect((await screen.findAllByText("---"))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(NO_DATA_TEXT))[0]).toBeInTheDocument(); expect(screen.getByText("As soon as possible")).toBeInTheDocument(); }); diff --git a/src/features/security-profiles/components/SecurityProfileDownloadAuditSidePanel/components/SecurityProfileDownloadAuditForm/SecurityProfileDownloadAuditForm.tsx b/src/features/security-profiles/components/SecurityProfileDownloadAuditSidePanel/components/SecurityProfileDownloadAuditForm/SecurityProfileDownloadAuditForm.tsx index 71a82cb8f..0dd62453b 100644 --- a/src/features/security-profiles/components/SecurityProfileDownloadAuditSidePanel/components/SecurityProfileDownloadAuditForm/SecurityProfileDownloadAuditForm.tsx +++ b/src/features/security-profiles/components/SecurityProfileDownloadAuditSidePanel/components/SecurityProfileDownloadAuditForm/SecurityProfileDownloadAuditForm.tsx @@ -93,8 +93,8 @@ const SecurityProfileDownloadAuditForm: FC< const formik = useFormik({ initialValues: { audit_timeframe: "specific-date", - end_date: moment().format(INPUT_DATE_FORMAT), - start_date: moment().format(INPUT_DATE_FORMAT), + end_date: moment().utc().format(INPUT_DATE_FORMAT), + start_date: moment().utc().format(INPUT_DATE_FORMAT), level_of_detail: "summary-only", }, diff --git a/src/features/snaps/components/EditSnap/helpers.test.tsx b/src/features/snaps/components/EditSnap/helpers.test.tsx new file mode 100644 index 000000000..1c8f804cd --- /dev/null +++ b/src/features/snaps/components/EditSnap/helpers.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type * as Yup from "yup"; +import { installedSnaps } from "@/tests/mocks/snap"; +import { EditSnapType } from "../../helpers"; +import { + getEditSnapValidationSchema, + getSnapMessage, + getSuccessMessage, +} from "./helpers"; + +const SNAP_COUNT = 3; + +describe("EditSnap helpers", () => { + it("requires release for switch actions", async () => { + const schema = getEditSnapValidationSchema(EditSnapType.Switch); + const releaseSchema = schema.release as Yup.StringSchema; + + await expect(releaseSchema.validate("")).rejects.toThrowError( + /release is required/i, + ); + await expect(releaseSchema.validate("stable")).resolves.toBe("stable"); + }); + + it("does not require release for non-switch actions", () => { + const holdSchema = getEditSnapValidationSchema(EditSnapType.Hold); + expect(holdSchema.release).toBeUndefined(); + }); + + it("falls back to common schema for unsupported edit types", () => { + const schema = getEditSnapValidationSchema("unsupported" as EditSnapType); + + expect(schema.release).toBeUndefined(); + expect(schema.hold).toBeUndefined(); + }); + + it("validates hold_until date values for hold actions", async () => { + const schema = getEditSnapValidationSchema(EditSnapType.Hold); + const holdUntilSchema = schema.hold_until as Yup.StringSchema; + + await expect(holdUntilSchema.validate("")).resolves.toBe(""); + await expect(holdUntilSchema.validate("not-a-date")).rejects.toThrowError( + /valid date and time/i, + ); + await expect( + holdUntilSchema.validate("2030-01-01T00:00:00Z"), + ).resolves.toBe("2030-01-01T00:00:00Z"); + }); + + it("returns null message for switch actions", () => { + expect(getSnapMessage(EditSnapType.Switch, [...installedSnaps])).toBeNull(); + }); + + it("renders single-snap message with snap name interpolation", () => { + const node = getSnapMessage(EditSnapType.Refresh, [installedSnaps[0]]); + + render(<>{node}); + + expect( + screen.getByText(/update Snap 1 to the latest version available/i), + ).toBeInTheDocument(); + }); + + it("renders multi-snap hold summary when held and unheld snaps are mixed", () => { + const node = getSnapMessage(EditSnapType.Hold, [...installedSnaps]); + + const { container } = render(<>{node}); + + expect( + screen.getByText(/holding a snap will pause automatic updates/i), + ).toBeInTheDocument(); + expect(container).toHaveTextContent("You selected 4 snaps. This will:"); + expect(container).toHaveTextContent("hold 2 snaps"); + expect(container).toHaveTextContent("leave 2 snaps held"); + }); + + it("renders compact multi-snap message when all selected snaps are already held", () => { + const allHeldSnaps = installedSnaps.map((snap) => ({ + ...snap, + held_until: "2030-01-01T00:00:00Z", + })); + + const node = getSnapMessage(EditSnapType.Hold, allHeldSnaps); + + const { container } = render(<>{node}); + + expect(container).toHaveTextContent( + "Holding a snap will pause automatic updates for that particular snap.", + ); + expect(screen.queryByText(/you selected/i)).not.toBeInTheDocument(); + }); + + it("renders multi-snap unhold summary when held and unheld snaps are mixed", () => { + const node = getSnapMessage(EditSnapType.Unhold, [...installedSnaps]); + + const { container } = render(<>{node}); + + expect(container).toHaveTextContent("You selected 4 snaps. This will:"); + expect(container).toHaveTextContent("unhold 2 snaps"); + expect(container).toHaveTextContent("leave 2 snaps unheld"); + }); + + it("builds success copy for each action", () => { + expect(getSuccessMessage(SNAP_COUNT, EditSnapType.Refresh)).toContain( + "to be refreshed", + ); + expect(getSuccessMessage(SNAP_COUNT, EditSnapType.Uninstall)).toContain( + "to be uninstalled", + ); + expect(getSuccessMessage(SNAP_COUNT, EditSnapType.Hold)).toContain( + "to be held", + ); + expect(getSuccessMessage(SNAP_COUNT, EditSnapType.Unhold)).toContain( + "to be unheld", + ); + }); + + it("falls back to an empty verb for unsupported actions", () => { + expect(getSuccessMessage(1, "Unsupported" as EditSnapType)).toBe( + "You queued 1 snap to be updated.", + ); + }); +}); diff --git a/src/features/snaps/components/EditSnap/helpers.tsx b/src/features/snaps/components/EditSnap/helpers.tsx index a0ca800c6..e3913e26d 100644 --- a/src/features/snaps/components/EditSnap/helpers.tsx +++ b/src/features/snaps/components/EditSnap/helpers.tsx @@ -133,6 +133,7 @@ export const getSuccessMessage = (snapCount: number, action: EditSnapType) => { verb = "unheld"; break; default: + verb = "updated"; break; } diff --git a/src/features/snaps/components/SnapsActions/SnapsActions.test.tsx b/src/features/snaps/components/SnapsActions/SnapsActions.test.tsx index 96d71c9e9..03147f6ac 100644 --- a/src/features/snaps/components/SnapsActions/SnapsActions.test.tsx +++ b/src/features/snaps/components/SnapsActions/SnapsActions.test.tsx @@ -4,6 +4,8 @@ import { installedSnaps } from "@/tests/mocks/snap"; import { renderWithProviders } from "@/tests/render"; import { screen } from "@testing-library/react"; import { describe } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { PATHS, ROUTES } from "@/libs/routes"; import { getSelectedSnaps } from "../../helpers"; import SnapsActions from "./SnapsActions"; @@ -29,6 +31,7 @@ const mixedSelectedSnapIds = [ ...snapData.multiple.heldSnaps, ...snapData.multiple.unheldSnaps, ]; +const INSTANCE_ID = 11; const tableSnapButtons = ["Install", "Hold", "Unhold", "Uninstall", "Refresh"]; const formHeldSnapButtons = [ @@ -161,5 +164,182 @@ describe("SnapsActions", () => { expect(container).toHaveTexts(formUnheldSnapButtons); }); + + it("does not render switch channel button for multiple snaps in sidepanel", () => { + renderWithProviders( + , + ); + + expect( + screen.queryByRole("button", { name: "Switch channel" }), + ).not.toBeInTheDocument(); + }); + + it("opens install snaps side panel from table actions", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("button", { name: "Install" })); + + expect( + await screen.findByRole("heading", { name: "Install snaps" }), + ).toBeInTheDocument(); + }); + + it("opens hold side panel for selected snaps", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(INSTANCE_ID), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + await user.click(screen.getByRole("button", { name: "Hold" })); + + expect( + await screen.findByRole("heading", { name: /Hold \d+ snaps/i }), + ).toBeInTheDocument(); + }); + + it("opens uninstall side panel for selected snaps", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(INSTANCE_ID), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + await user.click(screen.getByRole("button", { name: "Uninstall" })); + + expect( + await screen.findByRole("heading", { name: /Uninstall \d+ snaps/i }), + ).toBeInTheDocument(); + }); + + it("opens unhold side panel using held snap count", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(INSTANCE_ID), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + await user.click(screen.getByRole("button", { name: "Unhold" })); + + expect( + await screen.findByRole("heading", { + name: new RegExp( + `Unhold ${snapData.multiple.heldSnaps.length} snaps`, + "i", + ), + }), + ).toBeInTheDocument(); + }); + + it("opens refresh side panel for selected snaps", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(INSTANCE_ID), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + await user.click(screen.getByRole("button", { name: "Refresh" })); + + expect( + await screen.findByRole("heading", { name: /Refresh \d+ snaps/i }), + ).toBeInTheDocument(); + }); + + it("opens switch channel side panel for held snap in sidepanel mode", async () => { + const user = userEvent.setup(); + const heldSnap = installedSnaps.find((snap) => snap.held_until !== null); + assert(heldSnap); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(INSTANCE_ID), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + await user.click(screen.getByRole("button", { name: "Switch channel" })); + + expect( + await screen.findByRole("heading", { name: /Switch .*'s channel/i }), + ).toBeInTheDocument(); + }); + + it("opens uninstall side panel with single snap title in sidepanel mode", async () => { + const user = userEvent.setup(); + const heldSnap = installedSnaps.find((snap) => snap.held_until !== null); + assert(heldSnap); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(INSTANCE_ID), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + await user.click(screen.getByRole("button", { name: "Uninstall" })); + + expect( + await screen.findByRole("heading", { + name: `Uninstall ${heldSnap.snap.name}`, + }), + ).toBeInTheDocument(); + }); }); }); diff --git a/src/features/ubuntupro/components/DetachTokenModal/DetachTokenModal.test.tsx b/src/features/ubuntupro/components/DetachTokenModal/DetachTokenModal.test.tsx index fc747906b..4a67e0810 100644 --- a/src/features/ubuntupro/components/DetachTokenModal/DetachTokenModal.test.tsx +++ b/src/features/ubuntupro/components/DetachTokenModal/DetachTokenModal.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "@/tests/render"; +import { setEndpointStatus } from "@/tests/controllers/controller"; import DetachTokenModal from "./DetachTokenModal"; describe("DetachTokenModal", () => { @@ -64,6 +65,26 @@ describe("DetachTokenModal", () => { expect(screen.getByText("Detach Ubuntu Pro token")).toBeInTheDocument(); }); + it("shows notification title using custom instance title", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.type( + screen.getByPlaceholderText("detach ubuntu pro token"), + "detach ubuntu pro token", + ); + await user.click(screen.getByRole("button", { name: /^detach$/i })); + + expect( + await screen.findByText( + "You queued detachment of Ubuntu Pro token for instance Test Server.", + ), + ).toBeInTheDocument(); + }); + it("uses instanceCount when provided instead of computerIds length", () => { renderWithProviders( , @@ -92,4 +113,97 @@ describe("DetachTokenModal", () => { expect(detachButton).toBeInTheDocument(); expect(detachButton).toHaveClass("p-button--negative"); }); + + it("keeps detach action disabled until confirmation text is entered", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + const detachButton = screen.getByRole("button", { name: /^detach$/i }); + expect(detachButton).toHaveAttribute("aria-disabled", "true"); + + await user.type( + screen.getByPlaceholderText("detach ubuntu pro token"), + "detach ubuntu pro token", + ); + + expect(detachButton).toBeEnabled(); + }); + + it("submits detachment and closes modal after confirmation", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + + renderWithProviders(); + + await user.type( + screen.getByPlaceholderText("detach ubuntu pro token"), + "detach ubuntu pro token", + ); + await user.click(screen.getByRole("button", { name: /^detach$/i })); + + expect( + await screen.findByText(/queued detachment of Ubuntu Pro token/i), + ).toBeInTheDocument(); + expect(onClose).toHaveBeenCalled(); + }); + + it("opens activity details from success notification action", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await user.type( + screen.getByPlaceholderText("detach ubuntu pro token"), + "detach ubuntu pro token", + ); + await user.click(screen.getByRole("button", { name: /^detach$/i })); + + await user.click( + await screen.findByRole("button", { name: "View details" }), + ); + + expect( + await screen.findByRole("heading", { + name: "Start instance Bionic WSL 1", + }), + ).toBeInTheDocument(); + }); + + it("shows plural success text when no instance title is provided", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await user.type( + screen.getByPlaceholderText("detach ubuntu pro token"), + "detach ubuntu pro token", + ); + await user.click(screen.getByRole("button", { name: /^detach$/i })); + + expect( + await screen.findByText( + "This will disconnect the instances from their subscription and pause any enabled Pro services.", + ), + ).toBeInTheDocument(); + }); + + it("handles detach-token failure via debug notification", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "error", path: "detach-token" }); + + const onClose = vi.fn(); + renderWithProviders(); + + await user.type( + screen.getByPlaceholderText("detach ubuntu pro token"), + "detach ubuntu pro token", + ); + await user.click(screen.getByRole("button", { name: /^detach$/i })); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + expect(onClose).toHaveBeenCalled(); + }); }); diff --git a/src/features/upgrades/components/Upgrades/Upgrades.test.tsx b/src/features/upgrades/components/Upgrades/Upgrades.test.tsx index fa7b0730f..b08adea85 100644 --- a/src/features/upgrades/components/Upgrades/Upgrades.test.tsx +++ b/src/features/upgrades/components/Upgrades/Upgrades.test.tsx @@ -1,11 +1,17 @@ -import { describe } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { instances } from "@/tests/mocks/instance"; +import { setEndpointStatus } from "@/tests/controllers/controller"; +import { expectErrorNotification } from "@/tests/helpers"; import { renderWithProviders } from "@/tests/render"; import Upgrades from "./Upgrades"; describe("Upgrades", () => { + beforeEach(() => { + setEndpointStatus("default"); + }); + it("should render tabs", async () => { renderWithProviders(); @@ -58,4 +64,116 @@ describe("Upgrades", () => { await screen.findByText(/Showing \d of \d+ security issues/i), ).toBeInTheDocument(); }); + + it("submits package upgrades from default tab", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Upgrade" })); + + expect( + await screen.findByText("You queued packages to be upgraded"), + ).toBeInTheDocument(); + }); + + it("submits USN upgrades when USNs tab is selected", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await user.click(screen.getByRole("tab", { name: /usns/i })); + await user.click(screen.getByRole("button", { name: "Upgrade" })); + + expect( + await screen.findByText("You queued packages to be upgraded"), + ).toBeInTheDocument(); + }); + + it("renders only instances and packages tabs when no security upgrades exist", () => { + const noSecurityUpgradeInstances = instances.map((instance) => ({ + ...instance, + alerts: (instance.alerts ?? []).filter( + ({ type }) => type !== "SecurityUpgradesAlert", + ), + })); + + renderWithProviders( + , + ); + + expect(screen.getByRole("tab", { name: /instances/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /packages/i })).toBeInTheDocument(); + expect( + screen.queryByRole("tab", { name: /usns/i }), + ).not.toBeInTheDocument(); + }); + + it("shows singular success message when upgrading one instance", async () => { + const user = userEvent.setup(); + const [singleInstance] = instances; + assert(singleInstance); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Upgrade" })); + + expect( + await screen.findByText(/Packages on 1 instance will be upgraded/i), + ).toBeInTheDocument(); + }); + + it("handles package-upgrade submission errors", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + setEndpointStatus("error"); + await user.click(screen.getByRole("button", { name: "Upgrade" })); + + await expectErrorNotification(); + }); + + it("handles usn-upgrade submission errors", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("tab", { name: /usns/i })); + setEndpointStatus("error"); + await user.click(screen.getByRole("button", { name: "Upgrade" })); + + await expectErrorNotification(); + }); + + it("updates excluded package list from packages tab interaction", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await user.click(screen.getByRole("tab", { name: /packages/i })); + await user.click( + screen.getByRole("checkbox", { name: /toggle all packages/i }), + ); + await user.click(screen.getByRole("button", { name: "Upgrade" })); + + expect( + await screen.findByText("You queued packages to be upgraded"), + ).toBeInTheDocument(); + }); + + it("updates excluded usn list from usns tab interaction", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await user.click(screen.getByRole("tab", { name: /usns/i })); + await user.click( + screen.getByRole("checkbox", { name: /toggle all security issues/i }), + ); + await user.click(screen.getByRole("button", { name: "Upgrade" })); + + expect( + await screen.findByText("You queued packages to be upgraded"), + ).toBeInTheDocument(); + }); }); diff --git a/src/features/upgrades/components/Upgrades/Upgrades.tsx b/src/features/upgrades/components/Upgrades/Upgrades.tsx index 1b9e91b71..dbbe9eaec 100644 --- a/src/features/upgrades/components/Upgrades/Upgrades.tsx +++ b/src/features/upgrades/components/Upgrades/Upgrades.tsx @@ -77,6 +77,10 @@ const Upgrades: FC = ({ selectedInstances }) => { validationSchema: VALIDATION_SCHEMA, }); + const handleExcludedPackagesChange = async ( + newExcludedPackages: UpgradesFormProps["excludedPackages"], + ) => formik.setFieldValue("excludedPackages", newExcludedPackages); + return (
@@ -98,9 +102,7 @@ const Upgrades: FC = ({ selectedInstances }) => { - formik.setFieldValue("excludedPackages", newExcludedPackages) - } + onExcludedPackagesChange={handleExcludedPackagesChange} /> )} @@ -109,9 +111,7 @@ const Upgrades: FC = ({ selectedInstances }) => { - formik.setFieldValue("excludedPackages", newExcludedPackages) - } + onExcludedPackagesChange={handleExcludedPackagesChange} /> )} diff --git a/src/features/welcome-banner/WelcomePopup/WelcomePopup.test.tsx b/src/features/welcome-banner/WelcomePopup/WelcomePopup.test.tsx index b397eead1..0a88fd7be 100644 --- a/src/features/welcome-banner/WelcomePopup/WelcomePopup.test.tsx +++ b/src/features/welcome-banner/WelcomePopup/WelcomePopup.test.tsx @@ -29,24 +29,22 @@ describe("WelcomePopup", () => { }); it("should close and save in local storage", async () => { + const user = userEvent.setup(); renderWithProviders(); - await waitFor(async () => { - const closeButton = screen.getByRole("button", { - name: /got it!/i, - }); - - expect(closeButton).toBeInTheDocument(); - - await userEvent.click(closeButton); + const closeButton = await screen.findByRole("button", { + name: /got it!/i, + }); + await user.click(closeButton); + await waitFor(() => { expect( screen.queryByText("Landscape web portal (Preview)"), ).not.toBeInTheDocument(); - - expect(localStorage.getItem("_landscape_isWelcomePopupClosed")).toBe( - "true", - ); }); + + expect(localStorage.getItem("_landscape_isWelcomePopupClosed")).toBe( + "true", + ); }); }); diff --git a/src/features/wsl/components/WslInstancesHeader/WslInstancesHeader.test.tsx b/src/features/wsl/components/WslInstancesHeader/WslInstancesHeader.test.tsx index 2b715ea98..d52cc1556 100644 --- a/src/features/wsl/components/WslInstancesHeader/WslInstancesHeader.test.tsx +++ b/src/features/wsl/components/WslInstancesHeader/WslInstancesHeader.test.tsx @@ -1,8 +1,15 @@ import { screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { PATHS, ROUTES } from "@/libs/routes"; +import { setEndpointStatus } from "@/tests/controllers/controller"; import { renderWithProviders } from "@/tests/render"; import WslInstancesHeader from "./WslInstancesHeader"; -import { instanceChildren } from "@/tests/mocks/wsl"; +import { + instanceChildren, + noncompliantInstanceChild, + uninstalledInstanceChild, +} from "@/tests/mocks/wsl"; import { windowsInstance } from "@/tests/mocks/instance"; describe("WslInstancesHeader", () => { @@ -61,4 +68,127 @@ describe("WslInstancesHeader", () => { screen.getByRole("button", { name: /reinstall/i }), ).toBeInTheDocument(); }); + + it("disables install action when selected instance is not uninstalled", () => { + renderWithProviders( + , + ); + + expect(screen.getByRole("button", { name: /^install$/i })).toHaveAttribute( + "aria-disabled", + "true", + ); + }); + + it("disables uninstall action when selected instance is already not installed", () => { + renderWithProviders( + , + ); + + expect( + screen.getByRole("button", { name: /^uninstall$/i }), + ).toHaveAttribute("aria-disabled", "true"); + }); + + it("opens create new instance side panel", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(windowsInstance.id), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + await user.click( + screen.getByRole("button", { name: /create new instance/i }), + ); + + expect( + await screen.findByRole("heading", { name: "Create new WSL instance" }), + ).toBeInTheDocument(); + }); + + it("opens reinstall modal for a noncompliant selection", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(windowsInstance.id), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + await user.click(screen.getByRole("button", { name: /^reinstall$/i })); + + expect( + screen.getByRole("heading", { + name: `Reinstall ${noncompliantInstanceChild.name}`, + }), + ).toBeInTheDocument(); + }); + + it("submits install for selected uninstalled instances", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(windowsInstance.id), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + await user.click(screen.getByRole("button", { name: /^install$/i })); + + expect( + await screen.findByText( + /successfully queued "WSL instance associated with profile, not installed, installation in progress" to be installed/i, + ), + ).toBeInTheDocument(); + }); + + it("shows endpoint error when install request fails", async () => { + const user = userEvent.setup(); + setEndpointStatus({ + status: "error", + path: "child-instance-profiles/:name:reapply", + }); + + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(windowsInstance.id), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + await user.click(screen.getByRole("button", { name: /^install$/i })); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); }); diff --git a/src/main.test.tsx b/src/main.test.tsx new file mode 100644 index 000000000..f6935aecb --- /dev/null +++ b/src/main.test.tsx @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; +import { getSentryConfig, renderApp, startApp } from "./main"; +import type { createRoot } from "react-dom/client"; + +describe("main", () => { + it("starts worker in dev when enabled and renders app", async () => { + const workerStart = vi.fn(); + const loadWorker = vi.fn(async () => ({ + worker: { start: workerStart }, + })); + const render = vi.fn(); + + await startApp({ + mode: "development", + isDevEnv: true, + isMswEnabled: true, + loadWorker, + render, + }); + + expect(loadWorker).toHaveBeenCalledTimes(1); + expect(workerStart).toHaveBeenCalledTimes(1); + expect(render).toHaveBeenCalledTimes(1); + }); + + it("does not start worker when msw is disabled", async () => { + const loadWorker = vi.fn(); + const render = vi.fn(); + + await startApp({ + mode: "development", + isDevEnv: true, + isMswEnabled: false, + loadWorker, + render, + }); + + expect(loadWorker).not.toHaveBeenCalled(); + expect(render).toHaveBeenCalledTimes(1); + }); + + it("does not start worker outside dev", async () => { + const loadWorker = vi.fn(); + const render = vi.fn(); + + await startApp({ + mode: "development", + isDevEnv: false, + isMswEnabled: true, + loadWorker, + render, + }); + + expect(loadWorker).not.toHaveBeenCalled(); + expect(render).toHaveBeenCalledTimes(1); + }); + + it("does not bootstrap app in test mode", async () => { + const loadWorker = vi.fn(); + const render = vi.fn(); + + await startApp({ + mode: "test", + isDevEnv: true, + isMswEnabled: true, + loadWorker, + render, + }); + + expect(loadWorker).not.toHaveBeenCalled(); + expect(render).not.toHaveBeenCalled(); + }); + + it("renders app root", () => { + const render = vi.fn(); + const createAppRoot = vi.fn(() => ({ render })); + const container = document.createElement("div"); + + renderApp(createAppRoot as unknown as typeof createRoot, container); + + expect(createAppRoot).toHaveBeenCalledWith(container); + expect(render).toHaveBeenCalledTimes(1); + }); + + it("builds sentry config for fallback release and production", () => { + const config = getSentryConfig("", false); + + expect(config.release).toBe("local-dev"); + expect(config.environment).toBe("production"); + expect(config.enabled).toBe(true); + }); + + it("builds sentry config for explicit release and development", () => { + const config = getSentryConfig("1.2.3", true); + + expect(config.release).toBe("1.2.3"); + expect(config.environment).toBe("development"); + expect(config.enabled).toBe(false); + }); +}); diff --git a/src/main.tsx b/src/main.tsx index d6b96f80f..4dd930d86 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -13,21 +13,31 @@ import { AppProviders } from "@/providers/AppProviders"; import App from "./App"; import { BrowserRouter } from "react-router"; -Sentry.init({ +export const getSentryConfig = ( + appVersion = APP_VERSION, + isDevEnv = IS_DEV_ENV, +) => ({ dsn: "https://774322e0f66e6944afb57769632eca62@o4510662863749120.ingest.de.sentry.io/4510674271338576", - release: APP_VERSION || "local-dev", - environment: IS_DEV_ENV ? "development" : "production", - enabled: !IS_DEV_ENV, + release: appVersion || "local-dev", + environment: isDevEnv ? "development" : "production", + enabled: !isDevEnv, }); -const initApp = async () => { - if (IS_DEV_ENV && IS_MSW_ENABLED) { - const { worker } = await import("@/tests/browser"); - await worker.start(); - } +Sentry.init(getSentryConfig()); + +type WorkerLoader = () => Promise<{ + worker: { + start: () => Promise | unknown; + }; +}>; - const container = document.getElementById("root") as HTMLElement; - const root = createRoot(container); +const defaultLoadWorker: WorkerLoader = () => import("@/tests/browser"); + +export const renderApp = ( + createAppRoot: typeof createRoot = createRoot, + container = document.getElementById("root") as HTMLElement, +) => { + const root = createAppRoot(container); root.render( @@ -42,4 +52,33 @@ const initApp = async () => { ); }; -initApp(); +interface StartAppOptions { + mode?: string; + isDevEnv?: boolean; + isMswEnabled?: boolean; + loadWorker?: WorkerLoader; + render?: () => void; +} + +export const startApp = async ({ + mode = import.meta.env.MODE, + isDevEnv = IS_DEV_ENV, + isMswEnabled = IS_MSW_ENABLED, + loadWorker = defaultLoadWorker, + render = () => { + renderApp(); + }, +}: StartAppOptions = {}) => { + if (mode === "test") { + return; + } + + if (isDevEnv && isMswEnabled) { + const { worker } = await loadWorker(); + await worker.start(); + } + + render(); +}; + +void startApp(); diff --git a/src/pages/PageNotFound.test.tsx b/src/pages/PageNotFound.test.tsx new file mode 100644 index 000000000..f1990113f --- /dev/null +++ b/src/pages/PageNotFound.test.tsx @@ -0,0 +1,21 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { PATHS } from "@/libs/routes"; +import { renderWithProviders } from "@/tests/render"; +import PageNotFound from "./PageNotFound"; + +describe("PageNotFound", () => { + it("renders not found content and home link", () => { + renderWithProviders(); + + expect(screen.getByText("Page not found")).toBeInTheDocument(); + expect( + screen.getByText("It seems that page you're looking for doesn't exist."), + ).toBeInTheDocument(); + + const homeLink = screen.getByRole("link", { + name: "Go back to the home page", + }); + expect(homeLink).toHaveAttribute("href", PATHS.root.root); + }); +}); diff --git a/src/pages/dashboard/account/AccountPage.test.tsx b/src/pages/dashboard/account/AccountPage.test.tsx new file mode 100644 index 000000000..348e70eee --- /dev/null +++ b/src/pages/dashboard/account/AccountPage.test.tsx @@ -0,0 +1,29 @@ +import { screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ROUTES } from "@/libs/routes"; +import { renderWithProviders } from "@/tests/render"; +import AccountPage from "./AccountPage"; + +const navigate = vi.fn(); + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useNavigate: () => navigate, + }; +}); + +describe("AccountPage", () => { + it("redirects to account general settings", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.account.general(), { + replace: true, + }); + }); + + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/dashboard/account/alerts/AlertsPage.test.tsx b/src/pages/dashboard/account/alerts/AlertsPage.test.tsx new file mode 100644 index 000000000..49f700a11 --- /dev/null +++ b/src/pages/dashboard/account/alerts/AlertsPage.test.tsx @@ -0,0 +1,26 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { PATHS, ROUTES } from "@/libs/routes"; +import { renderWithProviders } from "@/tests/render"; +import AlertsPage from "./AlertsPage"; + +describe("AlertsPage", () => { + it("renders alerts table content", async () => { + renderWithProviders( + , + undefined, + ROUTES.account.alerts(), + `/${PATHS.account.root}/${PATHS.account.alerts}`, + ); + + expect(screen.getByRole("heading", { name: "Alerts" })).toBeInTheDocument(); + expect( + await screen.findByText("Computer Duplicate Alert"), + ).toBeInTheDocument(); + expect(screen.getAllByRole("switch").length).toBeGreaterThan(0); + expect( + screen.getAllByRole("combobox", { name: "Select tags" }).length, + ).toBeGreaterThan(0); + expect(screen.queryByText("License Seats Alert")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/dashboard/account/alerts/AlertsPage.tsx b/src/pages/dashboard/account/alerts/AlertsPage.tsx index 47d848239..e2ea2d2a1 100644 --- a/src/pages/dashboard/account/alerts/AlertsPage.tsx +++ b/src/pages/dashboard/account/alerts/AlertsPage.tsx @@ -23,12 +23,11 @@ const AlertsPage: FC = () => { [getAlertsQueryResult], ); - const tagOptions: MultiSelectItem[] = - tags.map((tag) => ({ - label: tag, - value: tag, - group: "Tags", - })) ?? []; + const tagOptions: MultiSelectItem[] = tags.map((tag) => ({ + label: tag, + value: tag, + group: "Tags", + })); const availableTagOptions = [ { label: "All instances", value: "All" }, @@ -40,7 +39,7 @@ const AlertsPage: FC = () => { {isLoading && } - {!isLoading && alerts && ( + {!isLoading && ( { + it("renders API credentials table when user and credentials are loaded", async () => { + renderWithProviders(); + + expect( + screen.getByRole("heading", { name: "API credentials" }), + ).toBeInTheDocument(); + await expectLoadingState(); + + const numberOfAccounts = userDetails.accounts.length; + + expect(screen.getAllByText("Generate API credentials")).toHaveLength( + numberOfAccounts, + ); + }); +}); diff --git a/src/pages/dashboard/activities/ActivitiesPage.test.tsx b/src/pages/dashboard/activities/ActivitiesPage.test.tsx new file mode 100644 index 000000000..f13fc7e39 --- /dev/null +++ b/src/pages/dashboard/activities/ActivitiesPage.test.tsx @@ -0,0 +1,38 @@ +import { screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { setEndpointStatus } from "@/tests/controllers/controller"; +import { expectLoadingState } from "@/tests/helpers"; +import { renderWithProviders } from "@/tests/render"; +import ActivitiesPage from "./ActivitiesPage"; + +describe("ActivitiesPage", () => { + beforeEach(() => { + setEndpointStatus("default"); + }); + + it("renders activities page content with list data", async () => { + renderWithProviders(); + + await expectLoadingState(); + + expect( + screen.getByRole("heading", { name: "Activities" }), + ).toBeInTheDocument(); + expect(screen.getByRole("table")).toBeInTheDocument(); + expect(screen.queryByText("No activities found")).not.toBeInTheDocument(); + }); + + it("shows empty state when activities endpoint is empty", async () => { + setEndpointStatus({ status: "empty", path: "activities" }); + + renderWithProviders(); + + await expectLoadingState(); + + expect(screen.getByText("No activities found")).toBeInTheDocument(); + expect( + screen.getByText("There are no activities yet."), + ).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/dashboard/instances/InstancesPage/helpers.test.ts b/src/pages/dashboard/instances/InstancesPage/helpers.test.ts new file mode 100644 index 000000000..bfa5c64e8 --- /dev/null +++ b/src/pages/dashboard/instances/InstancesPage/helpers.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { FILTERS } from "@/features/instances"; +import { getOptionQuery, getQuery } from "./helpers"; + +const windowsOption = FILTERS.os.options.find( + (option) => option.value === "windows", +); +const offlineStatusOption = FILTERS.status.options.find( + (option) => option.value === "computer-offline", +); +const contractOption = FILTERS.contractExpiryDays.options.find( + (option) => option.value === "30", +); + +assert(windowsOption); +assert(offlineStatusOption); +assert(contractOption); + +describe("InstancesPage helpers", () => { + it("returns option query for select filters", () => { + expect(getOptionQuery(FILTERS.os, windowsOption.value)).toBe( + windowsOption.query, + ); + }); + + it("returns empty string when select option does not exist", () => { + expect(getOptionQuery(FILTERS.os, "missing-option")).toBe(""); + }); + + it("returns empty query for non-select filters", () => { + expect(getOptionQuery(FILTERS.wsl, "parent")).toBe(""); + }); + + it("builds combined search query from all active filters", () => { + const query = getQuery({ + os: windowsOption.value, + status: offlineStatusOption.value, + contractExpiryDays: contractOption.value, + query: "name:web,annotation:prod", + tags: ["db", "prod"], + accessGroups: ["global", "ops"], + availabilityZones: ["eu-west-1a", "eu-west-1b"], + }); + + expect(query).toBe( + [ + windowsOption.query, + getOptionQuery(FILTERS.status, offlineStatusOption.value), + getOptionQuery(FILTERS.contractExpiryDays, contractOption.value), + "name:web", + "annotation:prod", + "tag:db OR tag:prod", + "access-group:global OR access-group:ops", + "availability-zone:eu-west-1a OR availability-zone:eu-west-1b", + ].join(" "), + ); + }); + + it("uses null availability zone query when none is selected", () => { + const query = getQuery({ + os: "", + status: "", + contractExpiryDays: "", + query: "", + tags: [], + accessGroups: [], + availabilityZones: ["none"], + }); + + expect(query).toBe("availability-zone:null"); + }); + + it("omits availability zone when none are selected", () => { + const query = getQuery({ + os: windowsOption.value, + status: "", + contractExpiryDays: "", + query: "", + tags: [], + accessGroups: [], + availabilityZones: [], + }); + + expect(query).toBe(windowsOption.query); + }); +}); diff --git a/src/pages/dashboard/instances/ReportForm/ReportForm.test.tsx b/src/pages/dashboard/instances/ReportForm/ReportForm.test.tsx new file mode 100644 index 000000000..b31ff54fc --- /dev/null +++ b/src/pages/dashboard/instances/ReportForm/ReportForm.test.tsx @@ -0,0 +1,61 @@ +import { setEndpointStatus } from "@/tests/controllers/controller"; +import { renderWithProviders } from "@/tests/render"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import ReportForm from "./ReportForm"; + +describe("ReportForm", () => { + it("renders report controls", () => { + renderWithProviders(); + + expect(screen.getByLabelText("Report by CVE")).toBeInTheDocument(); + expect(screen.getByLabelText("Range")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Download" }), + ).toBeInTheDocument(); + }); + + it("downloads CSV when submitting the form", async () => { + const user = userEvent.setup(); + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); + const createObjectUrl = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:report"); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Download" })); + + expect(createObjectUrl).toHaveBeenCalledTimes(1); + + const [csvBlob] = createObjectUrl.mock.calls[0] ?? []; + expect(csvBlob).toBeInstanceOf(Blob); + expect(await (csvBlob as Blob).text()).toContain("name,status"); + + expect(clickSpy).toHaveBeenCalledTimes(1); + + clickSpy.mockRestore(); + createObjectUrl.mockRestore(); + }); + + it("downloads empty CSV when endpoint is empty", async () => { + const user = userEvent.setup(); + const createObjectUrl = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:report"); + setEndpointStatus({ status: "empty", path: "reports" }); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Download" })); + + const [csvBlob] = createObjectUrl.mock.calls[0] ?? []; + expect(csvBlob).toBeInstanceOf(Blob); + expect(await (csvBlob as Blob).text()).toBe(""); + + createObjectUrl.mockRestore(); + }); +}); diff --git a/src/pages/dashboard/instances/[single]/SingleInstanceContainer/SingleInstanceContainer.test.tsx b/src/pages/dashboard/instances/[single]/SingleInstanceContainer/SingleInstanceContainer.test.tsx index 7a70c8f13..8c6b630ae 100644 --- a/src/pages/dashboard/instances/[single]/SingleInstanceContainer/SingleInstanceContainer.test.tsx +++ b/src/pages/dashboard/instances/[single]/SingleInstanceContainer/SingleInstanceContainer.test.tsx @@ -1,35 +1,141 @@ -import { ubuntuCoreInstance } from "@/tests/mocks/instance"; +import { renderWithProviders } from "@/tests/render"; +import { PATHS, ROUTES } from "@/libs/routes"; +import { render, screen } from "@testing-library/react"; import { describe } from "vitest"; -import { - isInstancePackagesQueryEnabled, - isLivepatchInfoQueryEnabled, - isUsnQueryEnabled, -} from "./helpers"; +import userEvent from "@testing-library/user-event"; +import SingleInstanceContainer from "./SingleInstanceContainer"; +import { AuthContext } from "@/context/auth"; +import { authUser } from "@/tests/mocks/auth"; +import { AppProviders } from "@/providers/AppProviders"; +import { useState } from "react"; +import { MemoryRouter, Route, Routes } from "react-router"; + +const INSTANCE_ID = 11; +const INSTANCE_WITHOUT_DISTRIBUTION_ID = 12; +const MISSING_INSTANCE_ID = 99999; describe("SingleInstanceContainer", () => { - describe("isInstancePackagesQueryEnabled", () => { - it("should be false for an ubuntu core instance", () => { - assert( - !isInstancePackagesQueryEnabled( - ubuntuCoreInstance, - undefined, - undefined, - ), + describe("component", () => { + it("renders tabs for a found instance", async () => { + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(INSTANCE_ID, { tab: "activities" }), + `/${PATHS.instances.root}/${PATHS.instances.single}`, ); + + expect( + await screen.findByRole("tab", { name: "Info" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("tab", { name: "Activities" }), + ).toBeInTheDocument(); }); - }); - describe("isLivepatchInfoQueryEnabled", () => { - it("should be false for an ubuntu core instance", () => { - assert( - !isLivepatchInfoQueryEnabled(ubuntuCoreInstance, undefined, undefined), + it("renders empty state when instance is not found", async () => { + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(MISSING_INSTANCE_ID, { tab: "info" }), + `/${PATHS.instances.root}/${PATHS.instances.single}`, ); + + expect(await screen.findByText("Instance not found")).toBeInTheDocument(); }); - }); - describe("isUsnQueryEnabled", () => { - it("should be false for an ubuntu core instance", () => { - assert(!isUsnQueryEnabled(ubuntuCoreInstance, undefined, undefined)); + it("renders details for instances without distribution data", async () => { + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(INSTANCE_WITHOUT_DISTRIBUTION_ID, { + tab: "activities", + }), + `/${PATHS.instances.root}/${PATHS.instances.single}`, + ); + + expect( + await screen.findByRole("tab", { name: "Info" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("tab", { name: "Activities" }), + ).toBeInTheDocument(); + }); + + it("redirects to root route when current account changes", async () => { + const user = userEvent.setup(); + + const AccountSwitchHarness = () => { + const [currentAccount, setCurrentAccount] = useState("account-a"); + + const userWithCurrentAccount = { + ...authUser, + current_account: currentAccount, + accounts: [ + { + ...authUser.accounts[0], + name: currentAccount, + title: currentAccount, + subdomain: null, + classic_dashboard_url: "", + }, + ], + }; + + return ( + true, + logout: vi.fn(), + redirectToExternalUrl: vi.fn(), + safeRedirect: vi.fn(), + setUser: vi.fn(), + user: userWithCurrentAccount, + }} + > + + + + ); + }; + + render( + + + + Instances root page

} + /> + } + /> +
+
+
, + ); + + expect( + await screen.findByRole("tab", { name: "Info" }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Switch account" })); + + expect( + await screen.findByText("Instances root page"), + ).toBeInTheDocument(); }); }); }); diff --git a/src/pages/dashboard/instances/[single]/SingleInstanceContainer/helpers.test.ts b/src/pages/dashboard/instances/[single]/SingleInstanceContainer/helpers.test.ts new file mode 100644 index 000000000..bcc755039 --- /dev/null +++ b/src/pages/dashboard/instances/[single]/SingleInstanceContainer/helpers.test.ts @@ -0,0 +1,203 @@ +import { ROUTES } from "@/libs/routes"; +import { + instances, + ubuntuCoreInstance, + ubuntuInstance, + windowsInstance, +} from "@/tests/mocks/instance"; +import { describe, expect, it } from "vitest"; +import { + getBreadcrumbs, + getInstanceRequestId, + getInstanceTitle, + getKernelCount, + getPackageCount, + getQueryComputerIdsParam, + getQueryInstanceIdParam, + getUsnCount, + isInstanceFound, + isInstancePackagesQueryEnabled, + isInstanceQueryEnabled, + isLivepatchInfoQueryEnabled, + isUsnQueryEnabled, +} from "./helpers"; + +const INSTANCE_ID = 11; +const CHILD_INSTANCE_ID = 22; +const childWithParent = instances.find( + (instance) => instance.parent?.id === windowsInstance.id, +); + +assert(childWithParent); +assert(childWithParent.parent); +const parentInstance = childWithParent.parent; + +describe("SingleInstanceContainer helpers", () => { + it("returns false for package-like query enablement on ubuntu core instances", () => { + expect( + isInstancePackagesQueryEnabled(ubuntuCoreInstance, undefined, undefined), + ).toBe(false); + expect( + isLivepatchInfoQueryEnabled(ubuntuCoreInstance, undefined, undefined), + ).toBe(false); + expect(isUsnQueryEnabled(ubuntuCoreInstance, undefined, undefined)).toBe( + false, + ); + }); + + it("returns child instance id when child id is provided", () => { + expect( + getInstanceRequestId(String(INSTANCE_ID), String(CHILD_INSTANCE_ID)), + ).toBe(CHILD_INSTANCE_ID); + }); + + it("returns parent instance id when child id is not provided", () => { + expect(getInstanceRequestId(String(INSTANCE_ID), undefined)).toBe( + INSTANCE_ID, + ); + }); + + it("returns empty title when instance is undefined", () => { + expect(getInstanceTitle(undefined)).toBe(""); + }); + + it("returns breadcrumbs for a top-level instance", () => { + expect(getBreadcrumbs(ubuntuInstance)).toEqual([ + { label: "Instances", path: ROUTES.instances.root() }, + { label: ubuntuInstance.title, current: true }, + ]); + }); + + it("returns breadcrumbs for a child instance", () => { + expect(getBreadcrumbs(childWithParent)).toEqual([ + { label: "Instances", path: ROUTES.instances.root() }, + { + label: parentInstance.title, + path: ROUTES.instances.details.single(parentInstance.id), + }, + { label: childWithParent.title, current: true }, + ]); + }); + + it("returns undefined breadcrumbs when instance is missing", () => { + expect(getBreadcrumbs(undefined)).toBeUndefined(); + }); + + it("returns kernel count when livepatch fixes are available", () => { + expect( + getKernelCount([ + { + Livepatch: { + Fixes: [{}, {}], + }, + }, + ] as never), + ).toBe(2); + }); + + it("returns undefined kernel count for missing livepatch data", () => { + expect(getKernelCount(undefined)).toBeUndefined(); + expect(getKernelCount([])).toBeUndefined(); + expect( + getKernelCount([ + { + Livepatch: {}, + }, + ] as never), + ).toBeUndefined(); + }); + + it("returns request package and usn counts only when instance has a distribution", () => { + const noDistributionInstance = { ...ubuntuInstance, distribution: "" }; + + expect(getPackageCount(ubuntuInstance, 7)).toBe(7); + expect(getUsnCount(ubuntuInstance, 5)).toBe(5); + expect(getPackageCount(noDistributionInstance, 7)).toBe(0); + expect(getUsnCount(noDistributionInstance, 5)).toBe(0); + }); + + it("evaluates instance-query enablement based on account consistency", () => { + expect(isInstanceQueryEnabled(undefined, undefined, undefined)).toBe(false); + expect(isInstanceQueryEnabled("11", undefined, "account-a")).toBe(true); + expect(isInstanceQueryEnabled("11", "account-a", "account-a")).toBe(true); + expect(isInstanceQueryEnabled("11", "account-a", "account-b")).toBe(false); + }); + + it("returns query params derived from instance", () => { + expect(getQueryComputerIdsParam(ubuntuInstance)).toEqual([ + ubuntuInstance.id, + ]); + expect(getQueryComputerIdsParam(undefined)).toEqual([]); + expect(getQueryInstanceIdParam(ubuntuInstance)).toBe(ubuntuInstance.id); + expect(getQueryInstanceIdParam(undefined)).toBe(0); + }); + + it("enables package-like queries only when child-parent relation matches", () => { + expect(isInstancePackagesQueryEnabled(ubuntuInstance, "1", undefined)).toBe( + true, + ); + expect(isLivepatchInfoQueryEnabled(ubuntuInstance, "1", undefined)).toBe( + true, + ); + expect(isUsnQueryEnabled(ubuntuInstance, "1", undefined)).toBe(true); + expect( + isInstancePackagesQueryEnabled( + childWithParent, + String(windowsInstance.id), + "22", + ), + ).toBe(false); + expect( + isInstancePackagesQueryEnabled( + childWithParent, + String(ubuntuInstance.id), + "22", + ), + ).toBe(false); + expect( + isLivepatchInfoQueryEnabled( + childWithParent, + String(windowsInstance.id), + "22", + ), + ).toBe(false); + expect( + isUsnQueryEnabled(childWithParent, String(windowsInstance.id), "22"), + ).toBe(false); + }); + + it("returns null when child instance id is provided but instance has no parent", () => { + expect(isInstanceFound(ubuntuInstance, String(INSTANCE_ID), "22")).toBe( + null, + ); + }); + + it("returns true when child belongs to parent instance", () => { + expect( + isInstanceFound( + childWithParent, + String(windowsInstance.id), + String(INSTANCE_ID), + ), + ).toBe(true); + }); + + it("returns false when child does not belong to parent instance", () => { + expect( + isInstanceFound( + childWithParent, + String(ubuntuInstance.id), + String(INSTANCE_ID), + ), + ).toBe(false); + }); + + it("returns instance itself when there is no child constraint", () => { + expect( + isInstanceFound(ubuntuInstance, String(ubuntuInstance.id), undefined), + ).toBe(true); + expect( + isInstanceFound(undefined, String(ubuntuInstance.id), undefined), + ).toBe(undefined); + }); +}); diff --git a/src/pages/dashboard/instances/[single]/tabs/info/AssignEmployeeToInstanceForm/AssignEmployeeToInstanceForm.test.tsx b/src/pages/dashboard/instances/[single]/tabs/info/AssignEmployeeToInstanceForm/AssignEmployeeToInstanceForm.test.tsx index 85cba3614..21a71c04c 100644 --- a/src/pages/dashboard/instances/[single]/tabs/info/AssignEmployeeToInstanceForm/AssignEmployeeToInstanceForm.test.tsx +++ b/src/pages/dashboard/instances/[single]/tabs/info/AssignEmployeeToInstanceForm/AssignEmployeeToInstanceForm.test.tsx @@ -1,19 +1,28 @@ import { renderWithProviders } from "@/tests/render"; +import { PATHS, ROUTES } from "@/libs/routes"; import { screen } from "@testing-library/react"; import type { ComponentProps } from "react"; import { describe, expect } from "vitest"; import AssignEmployeeToInstanceForm from "./AssignEmployeeToInstanceForm"; import userEvent from "@testing-library/user-event"; +import { setEndpointStatus } from "@/tests/controllers/controller"; const props: ComponentProps = { instanceTitle: "Test Instance", }; +const routePattern = `/${PATHS.instances.root}/${PATHS.instances.single}`; + describe("AssignEmployeeToInstanceForm", () => { const user = userEvent.setup(); beforeEach(async () => { - renderWithProviders(); + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(1), + routePattern, + ); }); it("renders form correctly", () => { @@ -39,4 +48,56 @@ describe("AssignEmployeeToInstanceForm", () => { const errorMessage = screen.getByText(/This field is required./i); expect(errorMessage).toBeInTheDocument(); }); + + it("clears validation error after selecting an employee", async () => { + await user.click(screen.getByRole("button", { name: /Associate/i })); + expect(screen.getByText(/This field is required./i)).toBeInTheDocument(); + + const search = screen.getByRole("searchbox", { + name: /search for an employee/i, + }); + + await user.type(search, "John"); + await user.click(await screen.findByTestId("dropdownElement")); + + expect( + screen.queryByText(/This field is required./i), + ).not.toBeInTheDocument(); + }); + + it("shows success notification when associating an employee", async () => { + const search = screen.getByRole("searchbox", { + name: /search for an employee/i, + }); + + await user.type(search, "John"); + await user.click(await screen.findByTestId("dropdownElement")); + await user.click(screen.getByRole("button", { name: /Associate/i })); + + expect( + await screen.findByText( + "John Doe has been successfully associated with Test Instance.", + ), + ).toBeInTheDocument(); + expect(screen.queryByRole("form")).not.toBeInTheDocument(); + }); + + it("shows an error notification when association fails", async () => { + const search = screen.getByRole("searchbox", { + name: /search for an employee/i, + }); + + setEndpointStatus({ + status: "error", + path: "associateEmployeeWithInstance", + }); + + await user.type(search, "John"); + await user.click(await screen.findByTestId("dropdownElement")); + await user.click(screen.getByRole("button", { name: /Associate/i })); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); }); diff --git a/src/pages/dashboard/instances/[single]/tabs/info/EditInstance/EditInstance.test.tsx b/src/pages/dashboard/instances/[single]/tabs/info/EditInstance/EditInstance.test.tsx new file mode 100644 index 000000000..35ed9ee91 --- /dev/null +++ b/src/pages/dashboard/instances/[single]/tabs/info/EditInstance/EditInstance.test.tsx @@ -0,0 +1,200 @@ +import { setEndpointStatus } from "@/tests/controllers/controller"; +import { ubuntuInstance } from "@/tests/mocks/instance"; +import { PATHS, ROUTES } from "@/libs/routes"; +import { renderWithProviders } from "@/tests/render"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import EditInstance from "./EditInstance"; + +const routePattern = `/${PATHS.instances.root}/${PATHS.instances.single}`; +const routePath = ROUTES.instances.details.single(1); + +describe("EditInstance", () => { + it("renders editable fields and access group options", async () => { + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + expect(await screen.findByRole("textbox", { name: "Title" })).toHaveValue( + "Application Server 1", + ); + expect( + screen.getByRole("combobox", { name: "Access group" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Global access" }), + ).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Comment" })).toHaveValue( + "my awesome instance comment", + ); + }); + + it("disables title editing for WSL instances", async () => { + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + expect( + await screen.findByRole("textbox", { name: "Title" }), + ).toBeDisabled(); + }); + + it("shows tag confirmation modal when adding a new tag", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await screen.findByRole("textbox", { name: "Title" }); + + await user.click(screen.getByPlaceholderText("Add tags")); + await user.click(await screen.findByRole("button", { name: "asd" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect( + await screen.findByText( + "Adding tags could trigger irreversible changes to your instances.", + ), + ).toBeInTheDocument(); + }); + + it("submits directly when no tags were added", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + const titleInput = await screen.findByRole("textbox", { name: "Title" }); + await user.clear(titleInput); + await user.type(titleInput, "Renamed instance"); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect(await screen.findByText("Instance updated")).toBeInTheDocument(); + expect( + screen.queryByText( + "Adding tags could trigger irreversible changes to your instances.", + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Add tags" }), + ).not.toBeInTheDocument(); + }); + + it("submits from tag confirmation modal", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await screen.findByRole("textbox", { name: "Title" }); + await user.click(screen.getByPlaceholderText("Add tags")); + await user.click(await screen.findByRole("button", { name: "asd" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + await user.click(await screen.findByRole("button", { name: "Add tags" })); + + expect(await screen.findByText("Instance updated")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Add tags" }), + ).not.toBeInTheDocument(); + }); + + it("shows error notification when edit fails", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "error", path: "editInstance" }); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await screen.findByRole("textbox", { name: "Title" }); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); + + it("shows endpoint error when profile-diff request fails", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "error", path: "tags/profile-diff" }); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await screen.findByRole("textbox", { name: "Title" }); + await user.click(screen.getByPlaceholderText("Add tags")); + await user.click(await screen.findByRole("button", { name: "asd" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); + + it("submits directly when profile-diff result is empty", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "empty", path: "tags/profile-diff" }); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await screen.findByRole("textbox", { name: "Title" }); + await user.click(screen.getByPlaceholderText("Add tags")); + await user.click(await screen.findByRole("button", { name: "asd" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect(await screen.findByText("Instance updated")).toBeInTheDocument(); + expect( + screen.queryByText( + "Adding tags could trigger irreversible changes to your instances.", + ), + ).not.toBeInTheDocument(); + }); + + it("renders when access groups request fails", async () => { + setEndpointStatus({ status: "error", path: "GetAccessGroups" }); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + expect( + await screen.findByRole("combobox", { name: "Access group" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("option", { name: "Global access" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/dashboard/instances/[single]/tabs/packages/PackagesPanel/helpers.test.ts b/src/pages/dashboard/instances/[single]/tabs/packages/PackagesPanel/helpers.test.ts new file mode 100644 index 000000000..645417da8 --- /dev/null +++ b/src/pages/dashboard/instances/[single]/tabs/packages/PackagesPanel/helpers.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { getEmptyMessage } from "./helpers"; + +describe("PackagesPanel helpers", () => { + it("returns empty-state messages by package filter", () => { + expect(getEmptyMessage("", "")).toBe("No packages found."); + expect(getEmptyMessage("upgrade", "")).toBe("No available upgrades found."); + expect(getEmptyMessage("security", "")).toBe( + "No available security upgrades found.", + ); + expect(getEmptyMessage("installed", "")).toBe( + "No installed packages found.", + ); + expect(getEmptyMessage("held", "")).toBe("No held packages found."); + }); + + it("appends package search term when provided", () => { + expect(getEmptyMessage("upgrade", "openssl")).toBe( + 'No available upgrades found with the search "openssl".', + ); + }); +}); diff --git a/src/pages/dashboard/instances/[single]/tabs/users/EditUserForm/EditUserForm.test.tsx b/src/pages/dashboard/instances/[single]/tabs/users/EditUserForm/EditUserForm.test.tsx index 065e0888f..40b5d616b 100644 --- a/src/pages/dashboard/instances/[single]/tabs/users/EditUserForm/EditUserForm.test.tsx +++ b/src/pages/dashboard/instances/[single]/tabs/users/EditUserForm/EditUserForm.test.tsx @@ -1,24 +1,34 @@ +import { PATHS, ROUTES } from "@/libs/routes"; +import { setEndpointStatus } from "@/tests/controllers/controller"; import { users } from "@/tests/mocks/user"; +import { userGroups } from "@/tests/mocks/userGroup"; import { renderWithProviders } from "@/tests/render"; -import { screen, within } from "@testing-library/react"; +import type { User } from "@/types/User"; +import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import EditUserForm from "./EditUserForm"; -const props = { - instanceId: 1, - user: users[0], -}; +const routePattern = `/${PATHS.instances.root}/${PATHS.instances.single}`; + +const renderEditUserForm = (user: User = users[0]) => + renderWithProviders( + , + undefined, + ROUTES.instances.details.single(1), + routePattern, + ); describe("EditUserForm", () => { - beforeEach(() => { - renderWithProviders(); - }); it("renders the form", () => { + renderEditUserForm(); + const form = screen.getByRole("form"); expect(form).toBeInTheDocument(); }); it("renders form fields", () => { + renderEditUserForm(); + const form = screen.getByRole("form"); expect(form).toHaveTexts([ "Username", @@ -34,27 +44,52 @@ describe("EditUserForm", () => { }); it("renders form fields with user data", () => { + renderEditUserForm(); + + const form = screen.getByRole("form"); + expect(form).toHaveInputValues([ + users[0].username, + users[0].name ?? "", + users[0].location ?? "", + users[0].home_phone ?? "", + users[0].work_phone ?? "", + ]); + }); + + it("renders empty optional profile fields when missing", () => { + const userWithoutProfileDetails: User = { + ...users[8], + name: undefined, + location: undefined, + home_phone: undefined, + work_phone: undefined, + }; + + renderEditUserForm(userWithoutProfileDetails); + const form = screen.getByRole("form"); expect(form).toHaveInputValues([ - props.user.username, - props.user.name ?? "", - props.user.location ?? "", - props.user.home_phone ?? "", - props.user.work_phone ?? "", + userWithoutProfileDetails.username, + "", + "", + "", + "", ]); }); it("can edit user data", async () => { + renderEditUserForm(); + const form = screen.getByRole("form"); let username; - if (props.user.name === props.user.username) { + if (users[0].name === users[0].username) { const inputs = await within(form).findAllByDisplayValue( - props.user.username, + users[0].username, ); [username] = inputs; assert(username !== undefined); } else { - username = await within(form).findByDisplayValue(props.user.username); + username = await within(form).findByDisplayValue(users[0].username); } await userEvent.clear(username); @@ -62,4 +97,96 @@ describe("EditUserForm", () => { expect(form).toHaveInputValues(["newusername"]); }); + + it("shows validation error when confirm password does not match", async () => { + const user = userEvent.setup(); + renderEditUserForm(); + + await user.type(screen.getByLabelText("Password"), "new-password"); + await user.type( + screen.getByLabelText("Confirm password"), + "different-password", + ); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect(await screen.findByText("Passwords must match")).toBeInTheDocument(); + }); + + it("submits and shows success notification", async () => { + const user = userEvent.setup(); + renderEditUserForm(); + + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect( + await screen.findByText("User updated successfully"), + ).toBeInTheDocument(); + }); + + it("shows endpoint error notification on submit failure", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "error", path: "users" }); + renderEditUserForm(); + + await user.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => { + expect( + screen.getByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); + }); + + it("adds a newly selected additional group", async () => { + const user = userEvent.setup(); + const daemonGroup = userGroups.find((entry) => entry.name === "daemon"); + const binGroup = userGroups.find((entry) => entry.name === "bin"); + assert(daemonGroup); + assert(binGroup); + + setEndpointStatus({ status: "default", path: "users/groups:daemon" }); + renderEditUserForm(); + + await user.click( + screen.getByRole("combobox", { name: "Additional Groups" }), + ); + await user.click( + await screen.findByRole("checkbox", { name: binGroup.name }), + ); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect( + await screen.findByText("User updated successfully"), + ).toBeInTheDocument(); + }); + + it("submits when no additional groups are assigned", async () => { + const user = userEvent.setup(); + + setEndpointStatus({ status: "empty", path: "users/groups" }); + renderEditUserForm(); + + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect( + await screen.findByText("User updated successfully"), + ).toBeInTheDocument(); + }); + + it("allows changing additional groups selection", async () => { + const user = userEvent.setup(); + const group = userGroups.find((entry) => entry.name === "daemon"); + assert(group); + renderEditUserForm(); + + await user.click( + screen.getByRole("combobox", { name: "Additional Groups" }), + ); + await user.click(await screen.findByRole("checkbox", { name: group.name })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect( + await screen.findByText("User updated successfully"), + ).toBeInTheDocument(); + }); }); diff --git a/src/pages/dashboard/instances/[single]/tabs/users/UserList/UserList.test.tsx b/src/pages/dashboard/instances/[single]/tabs/users/UserList/UserList.test.tsx index 60baa1ad7..ca5dd2837 100644 --- a/src/pages/dashboard/instances/[single]/tabs/users/UserList/UserList.test.tsx +++ b/src/pages/dashboard/instances/[single]/tabs/users/UserList/UserList.test.tsx @@ -1,5 +1,5 @@ -import NoData from "@/components/layout/NoData"; -import { expectLoadingState, setScreenSize } from "@/tests/helpers"; +import NoData, { NO_DATA_TEXT } from "@/components/layout/NoData"; +import { setScreenSize } from "@/tests/helpers"; import { users } from "@/tests/mocks/user"; import { userGroups } from "@/tests/mocks/userGroup"; import { renderWithProviders } from "@/tests/render"; @@ -67,6 +67,18 @@ describe("UserList", () => { expect(checkedCheckboxes).toHaveLength(userIds.length + 1); }); + it("should clear all selected users when clicking ToggleAll with selected users", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const toggleAllCheckbox = await screen.findByRole("checkbox", { + name: /toggle all/i, + }); + await user.click(toggleAllCheckbox); + + expect(props.setSelected).toHaveBeenCalledWith([]); + }); + it("should select user when clicking on its row checkbox", async () => { renderWithProviders(); @@ -80,12 +92,29 @@ describe("UserList", () => { expect(props.setSelected).toHaveBeenCalledWith([selectedUser.uid]); }); + + it("should unselect user when clicking an already selected row checkbox", async () => { + const user = userEvent.setup(); + const [selectedUser] = users; + assert(selectedUser); + + renderWithProviders( + , + ); + + const userCheckbox = await screen.findByRole("checkbox", { + name: `Select user ${selectedUser.username}`, + }); + await user.click(userCheckbox); + + expect(props.setSelected).toHaveBeenCalledWith([]); + }); }); describe("User details sidepanel", () => { beforeEach(() => { - renderWithProviders(); setScreenSize("lg"); + renderWithProviders(); }); it("should open side panel when user in table is clicked", async () => { const user = await screen.findByRole("button", { @@ -109,12 +138,11 @@ describe("UserList", () => { await userEvent.click(userTableButton); const form = await screen.findByRole("complementary"); - - await expectLoadingState(); - + await within(form).findByRole("button", { name: "Unlock" }); buttonNames.forEach((buttonName) => { - const button = within(form).getByText(buttonName); - expect(button).toBeInTheDocument(); + expect( + within(form).getByRole("button", { name: buttonName }), + ).toBeInTheDocument(); }); }); @@ -128,9 +156,11 @@ describe("UserList", () => { const form = await screen.findByRole("complementary"); const buttonsNames = ["Lock", "Edit", "Delete"]; + await within(form).findByRole("button", { name: "Lock" }); buttonsNames.forEach((buttonName) => { - const button = within(form).getByText(buttonName); - expect(button).toBeInTheDocument(); + expect( + within(form).getByRole("button", { name: buttonName }), + ).toBeInTheDocument(); }); }); @@ -148,7 +178,11 @@ describe("UserList", () => { userGroups.find((group) => group.gid === user.primary_gid)?.name ?? ""; const groupsData = userGroups.map((group) => group.name).join(", "); - const loaded = await screen.findByText(primaryGroup); + const loaded = await within(form).findByText( + primaryGroup, + {}, + { timeout: 5000 }, + ); expect(loaded).toBeInTheDocument(); const getFieldsToCheck = (item: User) => { return [ @@ -167,5 +201,42 @@ describe("UserList", () => { expect(form).toHaveInfoItem(field.label, field.value); }); }); + + it("should open edit user side panel from list actions", async () => { + const user = userEvent.setup(); + const [firstUser] = users; + assert(firstUser); + + const actionsToggle = await screen.findByRole("button", { + name: `"${firstUser.name}" user actions`, + }); + await user.click(actionsToggle); + + await user.click(await screen.findByText("Edit")); + + const sidePanel = await screen.findByRole("complementary"); + expect(within(sidePanel).getByText("Edit user")).toBeInTheDocument(); + }); + }); + + it("shows no-data marker when a user has no full name", async () => { + const userWithoutName = { + ...users[0], + uid: 999, + username: "no-name-user", + name: "", + }; + + renderWithProviders( + , + ); + + const detailsButton = await screen.findByRole("button", { + name: `Show details of user ${userWithoutName.username}`, + }); + const row = detailsButton.closest("tr"); + assert(row); + + expect(within(row).getByText(NO_DATA_TEXT)).toBeInTheDocument(); }); }); diff --git a/src/pages/dashboard/instances/[single]/tabs/users/UserList/helpers.test.ts b/src/pages/dashboard/instances/[single]/tabs/users/UserList/helpers.test.ts new file mode 100644 index 000000000..bb2501b4e --- /dev/null +++ b/src/pages/dashboard/instances/[single]/tabs/users/UserList/helpers.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import type { Cell } from "react-table"; +import type { User } from "@/types/User"; +import { handleCellProps } from "./helpers"; + +const toCell = (id: string) => { + return { column: { id } } as unknown as Cell; +}; + +describe("UserList helpers", () => { + it("adds rowheader role for username cells", () => { + expect(handleCellProps(toCell("username"))).toEqual({ + role: "rowheader", + }); + }); + + it("adds accessibility labels for known columns", () => { + expect(handleCellProps(toCell("enabled"))).toEqual({ + "aria-label": "Status", + }); + expect(handleCellProps(toCell("uid"))).toEqual({ + "aria-label": "User id", + }); + expect(handleCellProps(toCell("name"))).toEqual({ + "aria-label": "Full name", + }); + }); + + it("returns empty props for unknown columns", () => { + expect(handleCellProps(toCell("shell"))).toEqual({}); + }); +}); diff --git a/src/pages/dashboard/instances/[single]/tabs/users/UserPanelActionButtons/UserPanelActionButtons.test.tsx b/src/pages/dashboard/instances/[single]/tabs/users/UserPanelActionButtons/UserPanelActionButtons.test.tsx index 0e83a620b..56831a747 100644 --- a/src/pages/dashboard/instances/[single]/tabs/users/UserPanelActionButtons/UserPanelActionButtons.test.tsx +++ b/src/pages/dashboard/instances/[single]/tabs/users/UserPanelActionButtons/UserPanelActionButtons.test.tsx @@ -1,9 +1,12 @@ import { resetScreenSize, setScreenSize } from "@/tests/helpers"; import "@/tests/matcher"; +import { PATHS, ROUTES } from "@/libs/routes"; +import { setEndpointStatus } from "@/tests/controllers/controller"; import { users } from "@/tests/mocks/user"; import { renderWithProviders } from "@/tests/render"; -import { screen } from "@testing-library/react"; -import { describe, vi } from "vitest"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, vi } from "vitest"; import { getSelectedUsers } from "../UserPanelHeader/helpers"; import UserPanelActionButtons from "./UserPanelActionButtons"; @@ -27,6 +30,8 @@ const mixedSelectedUsers = [ const tableUserButtons = ["Add user", "Lock", "Unlock", "Delete"]; const formLockedUserButtons = ["Unlock", "Edit", "Delete"]; const formUnlockedUserButtons = ["Lock", "Edit", "Delete"]; +const routePattern = `/${PATHS.instances.root}/${PATHS.instances.single}`; +const routePath = ROUTES.instances.details.single(1); describe("UserPanelActionButtons", () => { beforeEach(() => { @@ -44,10 +49,33 @@ describe("UserPanelActionButtons", () => { selectedUsers={getSelectedUsers(users, userData.empty)} handleClearSelection={vi.fn()} />, + undefined, + routePath, + routePattern, ); expect(container).toHaveTexts(tableUserButtons); }); + it("opens add user side panel from table action", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await user.click(screen.getByRole("button", { name: "Add user" })); + + expect( + await screen.findByText("Require password reset"), + ).toBeInTheDocument(); + }); + describe("Check button disabled statuses", () => { it("renders buttons disabled when no users selected", () => { renderWithProviders( @@ -55,6 +83,9 @@ describe("UserPanelActionButtons", () => { selectedUsers={getSelectedUsers(users, userData.empty)} handleClearSelection={vi.fn()} />, + undefined, + routePath, + routePattern, ); for (const button of tableUserButtons) { @@ -77,6 +108,9 @@ describe("UserPanelActionButtons", () => { )} handleClearSelection={vi.fn()} />, + undefined, + routePath, + routePattern, ); const unlockButton = screen.getByRole("button", { name: "Unlock" }); expect(unlockButton).toHaveAttribute("aria-disabled"); @@ -91,6 +125,9 @@ describe("UserPanelActionButtons", () => { )} handleClearSelection={vi.fn()} />, + undefined, + routePath, + routePattern, ); const lockButton = screen.getByRole("button", { name: "Lock" }); expect(lockButton).toHaveAttribute("aria-disabled"); @@ -102,6 +139,9 @@ describe("UserPanelActionButtons", () => { selectedUsers={getSelectedUsers(users, mixedSelectedUsers)} handleClearSelection={vi.fn()} />, + undefined, + routePath, + routePattern, ); const lockButton = screen.getByRole("button", { name: "Lock" }); const unlockButton = screen.getByRole("button", { name: "Unlock" }); @@ -121,6 +161,9 @@ describe("UserPanelActionButtons", () => { handleClearSelection={vi.fn()} sidePanel />, + undefined, + routePath, + routePattern, ); expect(container).toHaveTexts(formLockedUserButtons); @@ -135,9 +178,283 @@ describe("UserPanelActionButtons", () => { handleClearSelection={vi.fn()} sidePanel />, + undefined, + routePath, + routePattern, ); expect(container).toHaveTexts(formUnlockedUserButtons); }); + + it("does not render edit button for multiple selected users in sidepanel", () => { + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + expect( + screen.queryByRole("button", { name: "Edit" }), + ).not.toBeInTheDocument(); + }); + + it("does not render add user button in sidepanel", () => { + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + expect( + screen.queryByRole("button", { name: "Add user" }), + ).not.toBeInTheDocument(); + }); + + it("opens edit user form from sidepanel action", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await user.click(screen.getByRole("button", { name: "Edit" })); + + expect(await screen.findByRole("form")).toBeInTheDocument(); + expect(screen.getByText("Confirm password")).toBeInTheDocument(); + }); + + it("opens lock confirmation and submits lock action", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await user.click(screen.getByRole("button", { name: "Lock" })); + expect( + screen.getByRole("heading", { name: /lock user/i }), + ).toBeInTheDocument(); + + await user.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Lock", + }), + ); + expect( + await screen.findByText("Successfully requested to be locked"), + ).toBeInTheDocument(); + }); + + it("submits lock action when clear-selection handler is omitted", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await user.click(screen.getByRole("button", { name: "Lock" })); + await user.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Lock", + }), + ); + + expect( + await screen.findByText("Successfully requested to be locked"), + ).toBeInTheDocument(); + }); + + it("opens unlock confirmation and submits unlock action", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await user.click(screen.getByRole("button", { name: "Unlock" })); + expect( + screen.getByRole("heading", { name: /unlock user/i }), + ).toBeInTheDocument(); + + await user.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Unlock", + }), + ); + expect( + await screen.findByText("Successfully requested to be unlocked"), + ).toBeInTheDocument(); + }); + + it("opens delete confirmation and submits remove action", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await user.click(screen.getByRole("button", { name: "Delete" })); + expect( + screen.getByRole("heading", { name: /delete user/i }), + ).toBeInTheDocument(); + + await user.click( + screen.getByRole("checkbox", { + name: "Delete the home folders as well", + }), + ); + await user.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Delete", + }), + ); + + expect( + await screen.findByText("Successfully requested to be removed"), + ).toBeInTheDocument(); + }); + + it("shows error notification when user action fails", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "error", path: "lockUser" }); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await user.click(screen.getByRole("button", { name: "Lock" })); + await user.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Lock", + }), + ); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); + + it("renders multi-user modal copy and supports closing lock/unlock/delete dialogs", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await user.click(screen.getByRole("button", { name: "Lock" })); + expect( + screen.getByRole("heading", { + name: `Lock ${mixedSelectedUsers.length} users`, + }), + ).toBeInTheDocument(); + await user.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Cancel", + }), + ); + expect( + screen.queryByRole("heading", { + name: `Lock ${mixedSelectedUsers.length} users`, + }), + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Unlock" })); + expect( + screen.getByRole("heading", { + name: `Unlock ${mixedSelectedUsers.length} users`, + }), + ).toBeInTheDocument(); + await user.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Cancel", + }), + ); + expect( + screen.queryByRole("heading", { + name: `Unlock ${mixedSelectedUsers.length} users`, + }), + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Delete" })); + expect( + screen.getByRole("heading", { name: "Delete users" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + "This will delete selected users. You can delete their home folders as well.", + ), + ).toBeInTheDocument(); + await user.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Cancel", + }), + ); + expect( + screen.queryByRole("heading", { name: "Delete users" }), + ).not.toBeInTheDocument(); + }); }); }); diff --git a/src/pages/dashboard/instances/[single]/tabs/users/UserPanelActionButtons/helpers.test.tsx b/src/pages/dashboard/instances/[single]/tabs/users/UserPanelActionButtons/helpers.test.tsx new file mode 100644 index 000000000..6ef7776b5 --- /dev/null +++ b/src/pages/dashboard/instances/[single]/tabs/users/UserPanelActionButtons/helpers.test.tsx @@ -0,0 +1,138 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { users } from "@/tests/mocks/user"; +import { + UserAction, + getSelectedUsernames, + getUserLockStatusCounts, + renderModalBody, +} from "./helpers"; + +describe("UserPanelActionButtons helpers", () => { + it("returns selected usernames", () => { + expect(getSelectedUsernames(users.slice(0, 3))).toEqual([ + "user1", + "user2", + "user3", + ]); + }); + + it("counts locked and unlocked users", () => { + expect(getUserLockStatusCounts(users.slice(0, 5))).toEqual({ + locked: 2, + unlocked: 3, + }); + }); + + it("renders single-user copy when a specific user is provided", () => { + const node = renderModalBody({ + user: users[0], + selectedUsers: [users[0]], + userAction: UserAction.Lock, + }); + + render(<>{node}); + + expect( + screen.getByText(/prevent this user from logging into this account/i), + ).toBeInTheDocument(); + }); + + it("renders same-state copy when all selected users are unlocked and lock is requested", () => { + const selectedUsers = users.filter((user) => user.enabled); + + const node = renderModalBody({ + user: undefined, + selectedUsers, + userAction: UserAction.Lock, + }); + + render(<>{node}); + + expect( + screen.getByText(/prevent users from logging into these accounts/i), + ).toBeInTheDocument(); + }); + + it("renders mixed-state summary with hold/leave counts", () => { + const selectedUsers = users.slice(0, 4); + + const node = renderModalBody({ + user: undefined, + selectedUsers, + userAction: UserAction.Lock, + }); + + const { container } = render(<>{node}); + + expect( + screen.getByText(/locking users removes their login access/i), + ).toBeInTheDocument(); + expect(container).toHaveTextContent("You selected 4 users."); + expect(container).toHaveTextContent("lock 3 users"); + expect(container).toHaveTextContent("leave 1 user locked"); + }); + + it("renders unlock copy for a specific user", () => { + const lockedUser = users.find((user) => !user.enabled); + assert(lockedUser); + + const node = renderModalBody({ + user: lockedUser, + selectedUsers: [lockedUser], + userAction: UserAction.Unlock, + }); + + render(<>{node}); + + expect( + screen.getByText(/restore login access for the user/i), + ).toBeInTheDocument(); + }); + + it("renders same-state copy when all selected users are locked and unlock is requested", () => { + const selectedUsers = users.filter((user) => !user.enabled); + + const node = renderModalBody({ + user: undefined, + selectedUsers, + userAction: UserAction.Unlock, + }); + + render(<>{node}); + + expect( + screen.getByText(/restore login access for the users of these accounts/i), + ).toBeInTheDocument(); + }); + + it("returns an empty node for unknown actions", () => { + const node = renderModalBody({ + user: users[0], + selectedUsers: [users[0]], + userAction: "unknown" as UserAction, + }); + + const { container } = render(<>{node}); + expect(container).toHaveTextContent(""); + }); + + it("renders mixed-state summary for unlock action", () => { + const selectedUsers = users.slice(0, 4); + + const node = renderModalBody({ + user: undefined, + selectedUsers, + userAction: UserAction.Unlock, + }); + + const { container } = render(<>{node}); + + expect( + screen.getByText(/unlocking users removes their login access/i), + ).toBeInTheDocument(); + expect(container).toHaveTextContent("You selected 4 users."); + expect(container).toHaveTextContent("unlock 1 user"); + expect(container).toHaveTextContent("leave 3 users unlocked"); + }); +}); diff --git a/src/pages/dashboard/instances/[single]/tabs/users/UserPanelActionButtons/helpers.tsx b/src/pages/dashboard/instances/[single]/tabs/users/UserPanelActionButtons/helpers.tsx index 5e7c971f3..2fef8a547 100644 --- a/src/pages/dashboard/instances/[single]/tabs/users/UserPanelActionButtons/helpers.tsx +++ b/src/pages/dashboard/instances/[single]/tabs/users/UserPanelActionButtons/helpers.tsx @@ -24,17 +24,16 @@ const getSingleUserMessage = (userAction: UserAction): string => { } }; -const getUsersWithSameStateMessage = (userAction: UserAction): string => { - switch (userAction) { - case UserAction.Lock: - return "This will prevent users from logging into these accounts without deleting the files belonging to the accounts."; - case UserAction.Unlock: - return "This will restore login access for the users of these accounts."; - default: - return ""; - } +const usersWithSameStateMessages: Record = { + [UserAction.Lock]: + "This will prevent users from logging into these accounts without deleting the files belonging to the accounts.", + [UserAction.Unlock]: + "This will restore login access for the users of these accounts.", }; +const getUsersWithSameStateMessage = (userAction: UserAction): string => + usersWithSameStateMessages[userAction]; + export const getUserLockStatusCounts = ( users: User[], ): { locked: number; unlocked: number } => { diff --git a/src/pages/dashboard/overview/OverviewPage.test.tsx b/src/pages/dashboard/overview/OverviewPage.test.tsx new file mode 100644 index 000000000..613cae330 --- /dev/null +++ b/src/pages/dashboard/overview/OverviewPage.test.tsx @@ -0,0 +1,26 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "@/tests/render"; +import OverviewPage from "./OverviewPage"; + +vi.mock("@/features/overview", async () => { + const actual = await vi.importActual("@/features/overview"); + + return { + ...actual, + ChartContainer: () =>
Chart container
, + }; +}); + +describe("OverviewPage", () => { + it("renders overview heading and key dashboard sections", async () => { + renderWithProviders(); + + expect( + screen.getByRole("heading", { name: "Overview" }), + ).toBeInTheDocument(); + expect(await screen.findByText("Chart container")).toBeInTheDocument(); + expect(screen.getByText("Requires approval")).toBeInTheDocument(); + expect(screen.getByText("In progress")).toBeInTheDocument(); + }); +}); diff --git a/src/pages/dashboard/repositories/RepositoryPage.tsx b/src/pages/dashboard/repositories/RepositoryPage.tsx deleted file mode 100644 index b9be62c03..000000000 --- a/src/pages/dashboard/repositories/RepositoryPage.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { ROUTES } from "@/libs/routes"; -import type { FC } from "react"; -import { Navigate } from "react-router"; - -const RepositoryPage: FC = () => { - return ; -}; - -export default RepositoryPage; diff --git a/src/pages/dashboard/repositories/local-repositories/LocalRepositoriesPage.tsx b/src/pages/dashboard/repositories/local-repositories/LocalRepositoriesPage.tsx index d861a67a3..f76f73c49 100644 --- a/src/pages/dashboard/repositories/local-repositories/LocalRepositoriesPage.tsx +++ b/src/pages/dashboard/repositories/local-repositories/LocalRepositoriesPage.tsx @@ -8,6 +8,7 @@ import useSidePanel from "@/hooks/useSidePanel"; import { Button } from "@canonical/react-components"; import type { FC } from "react"; import { lazy, Suspense } from "react"; +import { GPG_KEYS_DOCS_URL } from "./constants"; const NewAPTSourceForm = lazy(async () => import("@/features/apt-sources").then((module) => ({ @@ -56,7 +57,7 @@ const APTSourcesPage: FC = () => { You haven’t added any APT sources yet.

diff --git a/src/pages/dashboard/repositories/local-repositories/constants.ts b/src/pages/dashboard/repositories/local-repositories/constants.ts new file mode 100644 index 000000000..5b5d7c09f --- /dev/null +++ b/src/pages/dashboard/repositories/local-repositories/constants.ts @@ -0,0 +1,2 @@ +export const GPG_KEYS_DOCS_URL = + "https://ubuntu.com/landscape/docs/repositories"; diff --git a/src/pages/dashboard/repositories/mirrors/DistributionsPage.test.tsx b/src/pages/dashboard/repositories/mirrors/DistributionsPage.test.tsx new file mode 100644 index 000000000..c64ee5f56 --- /dev/null +++ b/src/pages/dashboard/repositories/mirrors/DistributionsPage.test.tsx @@ -0,0 +1,97 @@ +import { screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { PATHS, ROUTES } from "@/libs/routes"; +import { setEndpointStatus } from "@/tests/controllers/controller"; +import { + expectLoadingState, + resetScreenSize, + setScreenSize, +} from "@/tests/helpers"; +import { renderWithProviders } from "@/tests/render"; +import DistributionsPage from "./DistributionsPage"; + +describe("DistributionsPage", () => { + afterEach(() => { + resetScreenSize(); + }); + + it("renders distributions and large-screen actions", async () => { + setScreenSize("lg"); + + renderWithProviders(); + + expect( + screen.getByRole("heading", { name: "Mirrors" }), + ).toBeInTheDocument(); + await expectLoadingState(); + + expect(await screen.findByText("Distribution 1")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Add distribution" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add mirror" })).toBeEnabled(); + }); + + it("disables add mirror when there are no distributions", async () => { + setScreenSize("lg"); + setEndpointStatus("empty"); + + renderWithProviders(); + + await expectLoadingState(); + expect( + screen.getByText("No mirrors have been added yet."), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add mirror" })).toHaveAttribute( + "aria-disabled", + "true", + ); + }); + + it("shows actions menu on small screens", async () => { + setScreenSize("xs"); + + renderWithProviders(); + + expect(screen.getByRole("button", { name: "Actions" })).toBeInTheDocument(); + }); + + it("opens add distribution side panel", async () => { + const user = userEvent.setup(); + setScreenSize("lg"); + + renderWithProviders( + , + undefined, + ROUTES.repositories.mirrors(), + `/${PATHS.repositories.root}/${PATHS.repositories.mirrors}`, + ); + + await expectLoadingState(); + await user.click(screen.getByRole("button", { name: "Add distribution" })); + + expect( + await screen.findByRole("heading", { name: "Add distribution" }), + ).toBeInTheDocument(); + }); + + it("opens add mirror side panel when distributions exist", async () => { + const user = userEvent.setup(); + setScreenSize("lg"); + + renderWithProviders( + , + undefined, + ROUTES.repositories.mirrors(), + `/${PATHS.repositories.root}/${PATHS.repositories.mirrors}`, + ); + + await expectLoadingState(); + await user.click(screen.getByRole("button", { name: "Add mirror" })); + + expect( + await screen.findByRole("heading", { name: "Add new mirror" }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/pages/dashboard/repositories/publications/PublicationsPage.tsx b/src/pages/dashboard/repositories/publications/PublicationsPage.tsx index d861a67a3..b635c9ce2 100644 --- a/src/pages/dashboard/repositories/publications/PublicationsPage.tsx +++ b/src/pages/dashboard/repositories/publications/PublicationsPage.tsx @@ -8,6 +8,7 @@ import useSidePanel from "@/hooks/useSidePanel"; import { Button } from "@canonical/react-components"; import type { FC } from "react"; import { lazy, Suspense } from "react"; +import { APT_SOURCES_DOCS_URL } from "./constants"; const NewAPTSourceForm = lazy(async () => import("@/features/apt-sources").then((module) => ({ @@ -56,7 +57,7 @@ const APTSourcesPage: FC = () => { You haven’t added any APT sources yet.

diff --git a/src/pages/dashboard/settings/SettingsPage.test.tsx b/src/pages/dashboard/settings/SettingsPage.test.tsx new file mode 100644 index 000000000..e918868ad --- /dev/null +++ b/src/pages/dashboard/settings/SettingsPage.test.tsx @@ -0,0 +1,27 @@ +import { waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ROUTES } from "@/libs/routes"; +import { renderWithProviders } from "@/tests/render"; +import SettingsPage from "./SettingsPage"; + +const navigate = vi.fn(); + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useNavigate: () => navigate, + }; +}); + +describe("SettingsPage", () => { + it("redirects to settings general page", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.settings.general(), { + replace: true, + }); + }); + }); +}); diff --git a/src/pages/dashboard/settings/access-group/AccessGroupsPage.test.tsx b/src/pages/dashboard/settings/access-group/AccessGroupsPage.test.tsx new file mode 100644 index 000000000..bc3c780cf --- /dev/null +++ b/src/pages/dashboard/settings/access-group/AccessGroupsPage.test.tsx @@ -0,0 +1,59 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { setEndpointStatus } from "@/tests/controllers/controller"; +import { PATHS, ROUTES } from "@/libs/routes"; +import { renderWithProviders } from "@/tests/render"; +import userEvent from "@testing-library/user-event"; +import AccessGroupsPage from "./AccessGroupsPage"; + +describe("AccessGroupsPage", () => { + it("renders access groups table content", async () => { + renderWithProviders( + , + undefined, + ROUTES.settings.accessGroups(), + `/${PATHS.settings.root}/${PATHS.settings.accessGroups}`, + ); + + expect( + screen.getByRole("heading", { name: "Access groups" }), + ).toBeInTheDocument(); + expect( + await screen.findByRole("rowheader", { name: "Global access" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Add access group" }), + ).toBeInTheDocument(); + }); + + it("renders empty access groups state", async () => { + setEndpointStatus("empty"); + + renderWithProviders( + , + undefined, + ROUTES.settings.accessGroups(), + `/${PATHS.settings.root}/${PATHS.settings.accessGroups}`, + ); + + expect( + await screen.findByText("No access groups found"), + ).toBeInTheDocument(); + }); + + it("opens add access group side panel", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + ROUTES.settings.accessGroups(), + `/${PATHS.settings.root}/${PATHS.settings.accessGroups}`, + ); + + await user.click(screen.getByRole("button", { name: "Add access group" })); + + expect(await screen.findByText("Title")).toBeInTheDocument(); + expect(screen.getByText("Parent")).toBeInTheDocument(); + }); +}); diff --git a/src/pages/dashboard/settings/roles/EditRoleForm/EditRoleForm.test.tsx b/src/pages/dashboard/settings/roles/EditRoleForm/EditRoleForm.test.tsx index a74be59fb..8eccae8fa 100644 --- a/src/pages/dashboard/settings/roles/EditRoleForm/EditRoleForm.test.tsx +++ b/src/pages/dashboard/settings/roles/EditRoleForm/EditRoleForm.test.tsx @@ -1,20 +1,160 @@ import { roles } from "@/tests/mocks/roles"; +import { setEndpointStatus } from "@/tests/controllers/controller"; import { renderWithProviders } from "@/tests/render"; import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; +import { PATHS, ROUTES } from "@/libs/routes"; import EditRoleForm from "./EditRoleForm"; const props: ComponentProps = { role: roles[0], }; +const routePattern = `/${PATHS.settings.root}/${PATHS.settings.roles}`; +const routePath = ROUTES.settings.roles(); describe("EditRoleForm", () => { - it("renders EditRoleForm", () => { - renderWithProviders(); + it("renders EditRoleForm", async () => { + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await screen.findAllByRole("checkbox"); expect(screen.getByText("Global permissions")).toBeInTheDocument(); expect(screen.getByText("Permissions")).toBeInTheDocument(); expect(screen.getByText("Access Groups")).toBeInTheDocument(); expect(screen.getByText(/save changes/i)).toBeInTheDocument(); }); + + it("preselects role permissions and access groups", async () => { + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + const checkedOptions = await screen.findAllByRole("checkbox", { + checked: true, + }); + + expect(checkedOptions.length).toBeGreaterThan(0); + }); + + it("closes form when saving without changes", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await screen.findAllByRole("checkbox"); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect( + screen.queryByText("Role changes have been saved"), + ).not.toBeInTheDocument(); + }); + + it("submits role updates and shows success notification", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await screen.findAllByRole("checkbox"); + + const removePermissionCheckbox = screen.getByRole("checkbox", { + name: "Manage instances", + }); + const addPermissionCheckbox = screen.getByRole("checkbox", { + name: "Manage scripts", + }); + const accessGroupCheckbox = screen.getByRole("checkbox", { + name: "Server machines", + }); + await user.click(removePermissionCheckbox); + await user.click(addPermissionCheckbox); + await user.click(accessGroupCheckbox); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect( + await screen.findByText("Role changes have been saved"), + ).toBeInTheDocument(); + expect( + screen.getByText("You modified the role 'GlobalAdmin'"), + ).toBeInTheDocument(); + }); + + it("updates global permissions and existing permissions in one submit", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await screen.findAllByRole("checkbox"); + + await user.click( + screen.getByRole("checkbox", { + name: "Manage account", + }), + ); + await user.click( + screen.getByRole("checkbox", { + name: "Manage instances", + }), + ); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect( + await screen.findByText("Role changes have been saved"), + ).toBeInTheDocument(); + }); + + it("shows endpoint error when role update fails", async () => { + const user = userEvent.setup(); + setEndpointStatus({ status: "error", path: "editRole" }); + + renderWithProviders( + , + undefined, + routePath, + routePattern, + ); + + await screen.findAllByRole("checkbox"); + + const removePermissionCheckbox = screen.getByRole("checkbox", { + name: "Manage instances", + }); + const addPermissionCheckbox = screen.getByRole("checkbox", { + name: "Manage scripts", + }); + const accessGroupCheckbox = screen.getByRole("checkbox", { + name: "Server machines", + }); + await user.click(removePermissionCheckbox); + await user.click(addPermissionCheckbox); + await user.click(accessGroupCheckbox); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect( + await screen.findByText('The endpoint status is set to "error".'), + ).toBeInTheDocument(); + }); }); diff --git a/src/pages/dashboard/settings/roles/EditRoleForm/helpers.test.ts b/src/pages/dashboard/settings/roles/EditRoleForm/helpers.test.ts index cb09d9e04..e5c37dd2a 100644 --- a/src/pages/dashboard/settings/roles/EditRoleForm/helpers.test.ts +++ b/src/pages/dashboard/settings/roles/EditRoleForm/helpers.test.ts @@ -1,9 +1,14 @@ import { accessGroups } from "@/tests/mocks/accessGroup"; -import { permissions } from "@/tests/mocks/roles"; +import { permissions, roles } from "@/tests/mocks/roles"; import type { Role } from "@/types/Role"; -import type { PermissionOption } from "../types"; +import type { AccessGroupOption, PermissionOption } from "../types"; import { getAccessGroupOptions, getPermissionOptions } from "../helpers"; -import { addImpliedViewPermissions, getValuesToEditRole } from "./helpers"; +import { + addImpliedViewPermissions, + getPromisesToEditRole, + getRoleFormProps, + getValuesToEditRole, +} from "./helpers"; const manageComputerPermission = permissions.find( (p) => p.name === "ManageComputer", @@ -14,6 +19,36 @@ const viewComputerPermission = permissions.find( assert(manageComputerPermission, "Mock 'ManageComputer' permission not found"); assert(viewComputerPermission, "Mock 'ViewComputer' permission not found"); +const helperAccessGroupOptions: AccessGroupOption[] = [ + { + value: "global", + label: "global", + children: [], + depth: 0, + parents: [], + }, + { + value: "Server machines", + label: "Server machines", + children: [], + depth: 0, + parents: [], + }, +]; + +const helperPermissionOptions: PermissionOption[] = [ + { + values: { manage: "ManageComputers", view: "ViewComputers" }, + label: "Computers", + global: false, + }, + { + values: { manage: "ManageScripts", view: "ViewScripts" }, + label: "Scripts", + global: false, + }, +]; + describe("Role Helpers", () => { describe("addImpliedViewPermissions", () => { const permissionOptions = getPermissionOptions(permissions); @@ -62,8 +97,8 @@ describe("Role Helpers", () => { }); describe("getValuesToEditRole", () => { - const permissionOptions = getPermissionOptions(permissions); - const accessGroupOptions = getAccessGroupOptions(accessGroups); + const rolePermissionOptions = getPermissionOptions(permissions); + const roleAccessGroupOptions = getAccessGroupOptions(accessGroups); const mockRole: Role = { name: "TestRole", permissions: [], @@ -89,8 +124,8 @@ describe("Role Helpers", () => { const { permissionsToAdd, permissionsToRemove } = getValuesToEditRole( formValues, role, - accessGroupOptions, - permissionOptions, + roleAccessGroupOptions, + rolePermissionOptions, ); expect(permissionsToAdd).toEqual([]); @@ -110,8 +145,8 @@ describe("Role Helpers", () => { const { permissionsToAdd, permissionsToRemove } = getValuesToEditRole( formValues, role, - accessGroupOptions, - permissionOptions, + roleAccessGroupOptions, + rolePermissionOptions, ); expect(permissionsToAdd).toEqual([]); @@ -136,12 +171,108 @@ describe("Role Helpers", () => { const { permissionsToAdd, permissionsToRemove } = getValuesToEditRole( formValues, role, - accessGroupOptions, - permissionOptions, + roleAccessGroupOptions, + rolePermissionOptions, ); expect(permissionsToAdd).toEqual([]); expect(permissionsToRemove).toEqual([]); }); + + it("computes permission and access-group deltas", () => { + const role = { + ...mockRole, + permissions: ["ManageComputers"], + global_permissions: ["ViewComputers"], + access_groups: ["global"], + }; + const values = { + permissions: ["ManageScripts"], + accessGroups: ["global", "Server machines"], + }; + + expect( + getValuesToEditRole( + values, + role, + helperAccessGroupOptions, + helperPermissionOptions, + ), + ).toEqual({ + accessGroupsToAdd: ["Server machines"], + accessGroupsToRemove: [], + permissionsToAdd: ["ManageScripts", "ViewScripts"], + permissionsToRemove: ["ManageComputers", "ViewComputers"], + }); + }); + }); + + describe("getPromisesToEditRole", () => { + it("builds mutation promises only for changed values", () => { + const addAccessGroups = vi.fn().mockResolvedValue({} as never); + const addPermissions = vi.fn().mockResolvedValue({} as never); + const removeAccessGroups = vi.fn().mockResolvedValue({} as never); + const removePermissions = vi.fn().mockResolvedValue({} as never); + + const promises = getPromisesToEditRole( + { + permissions: ["ManageScripts"], + accessGroups: ["global", "Server machines"], + }, + { + ...roles[0], + permissions: ["ManageComputers"], + global_permissions: ["ViewComputers"], + access_groups: ["global"], + }, + helperAccessGroupOptions, + helperPermissionOptions, + { + addAccessGroups, + addPermissions, + removeAccessGroups, + removePermissions, + }, + ); + + expect(promises.addAccessGroupsPromise).toBeDefined(); + expect(promises.addPermissionsPromise).toBeDefined(); + expect(promises.removePermissionsPromise).toBeDefined(); + expect(promises.removeAccessGroupsPromise).toBeUndefined(); + }); + }); + + describe("getRoleFormProps", () => { + it("builds form props with implied permissions and nested access groups", () => { + const formProps = getRoleFormProps( + { + ...roles[0], + access_groups: ["global"], + permissions: ["ManageComputers"], + global_permissions: [], + }, + [ + { + value: "global", + label: "global", + children: ["child-group"], + depth: 0, + parents: [], + }, + ], + [ + { + values: { manage: "ManageComputers", view: "ViewComputers" }, + label: "Computers", + global: false, + }, + ], + ); + + expect(formProps).toEqual({ + accessGroups: ["global", "child-group"], + permissions: ["ManageComputers", "ViewComputers"], + }); + }); }); }); diff --git a/src/routes/AuthRoutes.test.tsx b/src/routes/AuthRoutes.test.tsx new file mode 100644 index 000000000..481e586bd --- /dev/null +++ b/src/routes/AuthRoutes.test.tsx @@ -0,0 +1,74 @@ +import { + Children, + isValidElement, + type ReactElement, + type ReactNode, +} from "react"; +import { Outlet } from "react-router"; +import { describe, expect, it } from "vitest"; +import { GuestGuard } from "@/components/guards/GuestGuard"; +import { FeatureGuard } from "@/components/guards/FeatureGuard"; +import { PATHS } from "@/libs/routes"; +import { AuthRoutes } from "./AuthRoutes"; + +interface RouteLikeProps { + children?: ReactNode; + element?: ReactElement; + path?: string; +} + +const isRouteElement = ( + value: unknown, +): value is ReactElement => + isValidElement(value); + +const getRouteChildren = (element: ReactElement) => { + return Children.toArray(element.props.children).filter(isRouteElement); +}; + +describe("AuthRoutes", () => { + it("wraps auth routes with guest guard and outlet", () => { + const wrapper = (AuthRoutes as ReactElement).props.element; + assert(wrapper); + const guardWrapper = wrapper as ReactElement<{ children: ReactElement }>; + expect(guardWrapper.type).toBe(GuestGuard); + + const wrappedChild = guardWrapper.props.children; + expect(wrappedChild.type).toBe(Outlet); + }); + + it("defines expected auth paths", () => { + const childRoutes = getRouteChildren( + AuthRoutes as ReactElement, + ); + const paths = childRoutes.map((route) => route.props.path); + + expect(paths).toContain(PATHS.auth.login); + expect(paths).toContain(PATHS.auth.invitation); + expect(paths).toContain(PATHS.auth.createAccount); + expect(paths).toContain(PATHS.auth.noAccess); + expect(paths).toContain(PATHS.auth.handleOidc); + expect(paths).toContain(PATHS.auth.handleUbuntuOne); + expect(paths).toContain(PATHS.auth.attach); + expect(paths).toContain(PATHS.auth.supportLogin); + }); + + it("uses feature guard for attach and support login routes", () => { + const childRoutes = getRouteChildren( + AuthRoutes as ReactElement, + ); + + const attachRoute = childRoutes.find( + (route) => route.props.path === PATHS.auth.attach, + ); + const supportLoginRoute = childRoutes.find( + (route) => route.props.path === PATHS.auth.supportLogin, + ); + + assert(attachRoute?.props.element); + assert(supportLoginRoute?.props.element); + + expect(attachRoute.props.element.type).toBe(FeatureGuard); + expect(supportLoginRoute.props.element.type).toBe(FeatureGuard); + }); +}); diff --git a/src/routes/elements.test.tsx b/src/routes/elements.test.tsx new file mode 100644 index 000000000..2545d3c54 --- /dev/null +++ b/src/routes/elements.test.tsx @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import type { FC, ReactElement } from "react"; +import * as Pages from "./elements"; + +const getLazyElementType = ( + Page: FC, +): { + _payload: unknown; + _init: (payload: unknown) => unknown; +} => { + const element = Page({}) as ReactElement<{ children: ReactElement }>; + const lazyElement = element.props.children; + return lazyElement.type as unknown as { + _payload: unknown; + _init: (payload: unknown) => unknown; + }; +}; + +const resolveLoadableImport = async (Page: FC) => { + const lazyType = getLazyElementType(Page); + try { + lazyType._init(lazyType._payload); + } catch (error) { + if (error instanceof Promise) { + await error; + return; + } + + throw error; + } +}; + +describe("route elements", () => { + it("sets loadable display names", () => { + expect(Pages.LoginPage.displayName).toContain("Loadable("); + expect(Pages.PageNotFound.displayName).toContain("Loadable("); + expect(Pages.DashboardPage.displayName).toContain("Loadable("); + }); + + it("exports wrapped route components", () => { + const exportedPages = [ + Pages.OidcAuthPage, + Pages.UbuntuOneAuthPage, + Pages.InvitationPage, + Pages.AccountCreationPage, + Pages.NoAccessPage, + Pages.LoginPage, + Pages.SupportLoginPage, + Pages.AttachPage, + Pages.PageNotFound, + Pages.EnvError, + Pages.DashboardPage, + Pages.OverviewPage, + Pages.ActivitiesPage, + Pages.ScriptsPage, + Pages.EventsLogPage, + Pages.AlertNotificationsPage, + Pages.InstancesPage, + Pages.SingleInstance, + Pages.DistributionsPage, + Pages.RepositoryProfilesPage, + Pages.GPGKeysPage, + Pages.APTSourcesPage, + Pages.PackageProfilesPage, + Pages.RemovalProfilesPage, + Pages.UpgradeProfilesPage, + Pages.WslProfilesPage, + Pages.SecurityProfilesPage, + Pages.RebootProfilesPage, + Pages.AccessGroupsPage, + Pages.AdministratorsPage, + Pages.EmployeesPage, + Pages.RolesPage, + Pages.GeneralOrganisationSettings, + Pages.IdentityProvidersPage, + Pages.GeneralSettings, + Pages.Alerts, + Pages.ApiCredentials, + ]; + + for (const exportedPage of exportedPages) { + expect(typeof exportedPage).toBe("function"); + } + }); + + it("resolves lazy imports for exported route components", async () => { + const exportedPages = [ + Pages.OidcAuthPage, + Pages.UbuntuOneAuthPage, + Pages.InvitationPage, + Pages.AccountCreationPage, + Pages.NoAccessPage, + Pages.LoginPage, + Pages.SupportLoginPage, + Pages.AttachPage, + Pages.PageNotFound, + Pages.EnvError, + Pages.DashboardPage, + Pages.OverviewPage, + Pages.ActivitiesPage, + Pages.ScriptsPage, + Pages.EventsLogPage, + Pages.AlertNotificationsPage, + Pages.InstancesPage, + Pages.SingleInstance, + Pages.DistributionsPage, + Pages.RepositoryProfilesPage, + Pages.GPGKeysPage, + Pages.APTSourcesPage, + Pages.PackageProfilesPage, + Pages.RemovalProfilesPage, + Pages.UpgradeProfilesPage, + Pages.WslProfilesPage, + Pages.SecurityProfilesPage, + Pages.RebootProfilesPage, + Pages.AccessGroupsPage, + Pages.AdministratorsPage, + Pages.EmployeesPage, + Pages.RolesPage, + Pages.GeneralOrganisationSettings, + Pages.IdentityProvidersPage, + Pages.GeneralSettings, + Pages.Alerts, + Pages.ApiCredentials, + ] as const; + + for (const exportedPage of exportedPages) { + await resolveLoadableImport(exportedPage); + } + }); +}); diff --git a/src/tests/browser.ts b/src/tests/browser.ts index 7e486456b..b46a3b2b4 100644 --- a/src/tests/browser.ts +++ b/src/tests/browser.ts @@ -17,11 +17,7 @@ const handlers: RequestHandler[] = [ return passthrough(); } - if ( - MSW_ENDPOINTS_TO_INTERCEPT.some((url: string) => - request.url.includes(url), - ) - ) { + if (MSW_ENDPOINTS_TO_INTERCEPT.some((url) => request.url.includes(url))) { return; } diff --git a/src/tests/mocks/alerts.ts b/src/tests/mocks/alerts.ts index 3efc302f6..df870acbc 100644 --- a/src/tests/mocks/alerts.ts +++ b/src/tests/mocks/alerts.ts @@ -126,6 +126,18 @@ export const alerts = [ }, ] as const satisfies Alert[]; +export const licenseAlert: Alert = { + id: 9999, + alert_type: "LicenseSeatsAlert", + description: "Alert when available seats are low", + subscribed: false, + status: "OK", + scope: "account", + all_computers: false, + tags: [], + label: "License Seats Alert", +}; + export const alertsSummary = [ { id: 1, diff --git a/src/tests/mocks/instance.ts b/src/tests/mocks/instance.ts index 71e12922a..e3ce4f4fd 100644 --- a/src/tests/mocks/instance.ts +++ b/src/tests/mocks/instance.ts @@ -1,5 +1,6 @@ import type { Instance, + InstanceWithoutRelation, PendingInstance, WindowsInstance, } from "@/types/Instance"; @@ -211,6 +212,7 @@ export const windowsInstance: WindowsInstance = { employee_id: null, archived: false, registered_at: "2023-11-29T18:29:25Z", + has_release_upgrades: true, }, { id: 65, @@ -340,8 +342,9 @@ export const windowsInstance: WindowsInstance = { employee_id: null, archived: false, registered_at: "2023-11-29T18:29:25Z", + has_release_upgrades: true, }, - ], + ] as const satisfies InstanceWithoutRelation[], parent: null, distribution_info: { code_name: "windows", diff --git a/src/tests/mocks/wsl.ts b/src/tests/mocks/wsl.ts index df302c086..1a7df2210 100644 --- a/src/tests/mocks/wsl.ts +++ b/src/tests/mocks/wsl.ts @@ -43,7 +43,7 @@ export const uninstalledInstanceChild: InstanceChild = { default: null, }; -export const instanceChildren: InstanceChild[] = [ +export const instanceChildren = [ compliantInstanceChild, uninstalledInstanceChild, { @@ -102,4 +102,4 @@ export const instanceChildren: InstanceChild[] = [ registered: true, default: false, }, -]; +] as const satisfies InstanceChild[]; diff --git a/src/tests/monacoMock.tsx b/src/tests/monacoMock.tsx index 15b40904f..b8982855c 100644 --- a/src/tests/monacoMock.tsx +++ b/src/tests/monacoMock.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useEffect } from "react"; interface EditorProps { readonly value?: string; @@ -7,31 +8,71 @@ interface EditorProps { readonly language?: string; readonly theme?: string; readonly options?: Record; - readonly beforeMount?: unknown; + readonly beforeMount?: (monaco: unknown) => void; readonly onMount?: unknown; readonly loading?: React.ReactNode; readonly className?: string; } +interface MonacoTextareaProps { + readonly beforeMount?: (monaco: unknown) => void; + readonly className?: string; + readonly defaultValue?: string; + readonly language?: string; + readonly onChange?: (value: string) => void; + readonly theme?: string; + readonly value?: string; +} + +function MonacoTextarea({ + beforeMount, + className, + defaultValue, + language, + onChange, + theme, + value, +}: MonacoTextareaProps) { + useEffect(() => { + beforeMount?.({ mocked: true }); + }, [beforeMount]); + + return ( +