diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index ed78bc03f5..6c839e5663 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -20,7 +20,10 @@ use agama_utils::{ actor::Message, - api::{Action, Config, IssueMap, Proposal, Status, SystemInfo}, + api::{ + manager::{LanguageTag, LicenseContent}, + Action, Config, IssueMap, Proposal, Status, SystemInfo, + }, }; use serde_json::Value; @@ -104,6 +107,21 @@ impl Message for GetIssues { type Reply = IssueMap; } +pub struct GetLicense { + pub id: String, + pub lang: LanguageTag, +} + +impl Message for GetLicense { + type Reply = Option; +} + +impl GetLicense { + pub fn new(id: String, lang: LanguageTag) -> Self { + Self { id, lang } + } +} + /// Runs the given action. #[derive(Debug)] pub struct RunAction { diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 4b79a97ce0..ed8e117c70 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -22,8 +22,10 @@ use crate::{l10n, message, network, software, storage}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ - self, event, manager, status::State, Action, Config, Event, Issue, IssueMap, IssueSeverity, - Proposal, Scope, Status, SystemInfo, + self, event, + manager::{self, LicenseContent}, + status::State, + Action, Config, Event, Issue, IssueMap, IssueSeverity, Proposal, Scope, Status, SystemInfo, }, issue, licenses, products::{self, ProductSpec}, @@ -325,11 +327,13 @@ impl MessageHandler for Service { let manager = self.system.clone(); let storage = self.storage.call(storage::message::GetSystem).await?; let network = self.network.get_system().await?; + let software = self.software.call(software::message::GetSystem).await?; Ok(SystemInfo { l10n, manager, network, storage, + software, }) } } @@ -425,6 +429,16 @@ impl MessageHandler for Service { } } +#[async_trait] +impl MessageHandler for Service { + async fn handle( + &mut self, + message: message::GetLicense, + ) -> Result, Error> { + Ok(self.licenses.find(&message.id, &message.lang)) + } +} + #[async_trait] impl MessageHandler for Service { /// It runs the given action. diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index a6a78c48f3..4bd86c823d 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -27,7 +27,9 @@ use agama_software::Resolvable; use agama_utils::{ actor::Handler, api::{ - event, query, + event, + manager::LicenseContent, + query, question::{Question, QuestionSpec, UpdateQuestion}, Action, Config, IssueMap, Patch, Status, SystemInfo, }, @@ -40,7 +42,7 @@ use axum::{ Json, Router, }; use hyper::StatusCode; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; #[derive(thiserror::Error, Debug)] @@ -106,6 +108,7 @@ pub async fn server_service( "/questions", get(get_questions).post(ask_question).patch(update_question), ) + .route("/licenses/:id", get(get_license)) .route( "/private/storage_model", get(get_storage_model).put(set_storage_model), @@ -324,6 +327,48 @@ async fn update_question( Ok(()) } +#[derive(Deserialize, utoipa::IntoParams)] +struct LicenseQuery { + lang: Option, +} + +/// Returns the license content. +/// +/// Optionally it can receive a language tag (RFC 5646). Otherwise, it returns +/// the license in English. +#[utoipa::path( + get, + path = "/licenses/:id", + context_path = "/api/software", + params(LicenseQuery), + responses( + (status = 200, description = "License with the given ID", body = LicenseContent), + (status = 400, description = "The specified language tag is not valid"), + (status = 404, description = "There is not license with the given ID") + ) +)] +async fn get_license( + State(state): State, + Path(id): Path, + Query(query): Query, +) -> Result { + let lang = query.lang.unwrap_or("en".to_string()); + + let Ok(lang) = lang.as_str().try_into() else { + return Ok(StatusCode::BAD_REQUEST.into_response()); + }; + + let license = state + .manager + .call(message::GetLicense::new(id.to_string(), lang)) + .await?; + if let Some(license) = license { + Ok(Json(license).into_response()) + } else { + Ok(StatusCode::NOT_FOUND.into_response()) + } +} + #[utoipa::path( post, path = "/actions", diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index 4f47bcc348..cb70f11d52 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -21,7 +21,7 @@ use agama_utils::{ actor::Handler, api::{ - software::{Pattern, SoftwareProposal}, + software::{Pattern, SoftwareProposal, SystemInfo}, Issue, }, products::{ProductSpec, UserPattern}, @@ -47,8 +47,8 @@ pub use packages::{Resolvable, ResolvableType}; /// tests. #[async_trait] pub trait ModelAdapter: Send + Sync + 'static { - /// List of available patterns. - async fn patterns(&self) -> Result, service::Error>; + /// Returns the software system information. + async fn system_info(&self) -> Result; async fn compute_proposal(&self) -> Result; @@ -97,6 +97,27 @@ impl Model { question, }) } + + async fn patterns(&self) -> Result, service::Error> { + let Some(product) = &self.selected_product else { + return Err(service::Error::MissingProduct); + }; + + let names = product + .software + .user_patterns + .iter() + .map(|user_pattern| match user_pattern { + UserPattern::Plain(name) => name.clone(), + UserPattern::Preselected(preselected) => preselected.name.clone(), + }) + .collect(); + + let (tx, rx) = oneshot::channel(); + self.zypp_sender + .send(SoftwareAction::GetPatternsMetadata(names, tx))?; + Ok(rx.await??) + } } #[async_trait] @@ -119,25 +140,13 @@ impl ModelAdapter for Model { Ok(rx.await??) } - async fn patterns(&self) -> Result, service::Error> { - let Some(product) = &self.selected_product else { - return Err(service::Error::MissingProduct); - }; - - let names = product - .software - .user_patterns - .iter() - .map(|user_pattern| match user_pattern { - UserPattern::Plain(name) => name.clone(), - UserPattern::Preselected(preselected) => preselected.name.clone(), - }) - .collect(); - - let (tx, rx) = oneshot::channel(); - self.zypp_sender - .send(SoftwareAction::GetPatternsMetadata(names, tx))?; - Ok(rx.await??) + /// Returns the software system information. + async fn system_info(&self) -> Result { + Ok(SystemInfo { + patterns: self.patterns().await?, + repositories: vec![], + addons: vec![], + }) } async fn refresh(&mut self) -> Result<(), service::Error> { diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 2cedf3c880..33593c5708 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -76,15 +76,15 @@ pub struct Service { issues: Handler, progress: Handler, events: event::Sender, - state: State, + state: Arc>, selection: SoftwareSelection, } #[derive(Default)] -struct State { +struct ServiceState { config: Config, system: SystemInfo, - proposal: Arc>, + proposal: Proposal, } impl Service { @@ -94,12 +94,13 @@ impl Service { progress: Handler, events: event::Sender, ) -> Service { + let state = Arc::new(RwLock::new(Default::default())); Self { model: Arc::new(Mutex::new(model)), issues, progress, events, - state: Default::default(), + state, selection: Default::default(), } } @@ -107,7 +108,8 @@ impl Service { pub async fn setup(&mut self) -> Result<(), Error> { if let Some(install_repo) = find_install_repository() { tracing::info!("Found repository at {}", install_repo.url); - self.state.system.repositories.push(install_repo); + let mut state = self.state.write().await; + state.system.repositories.push(install_repo); } Ok(()) } @@ -129,14 +131,16 @@ impl Actor for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetSystem) -> Result { - Ok(self.state.system.clone()) + let state = self.state.read().await; + Ok(state.system.clone()) } } #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetConfig) -> Result { - Ok(self.state.config.clone()) + let state = self.state.read().await; + Ok(state.config.clone()) } } @@ -145,40 +149,46 @@ impl MessageHandler> for Service { async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { let product = message.product.read().await; - self.state.config = message.config.clone().unwrap_or_default(); + let software = { + let mut state = self.state.write().await; + state.config = message.config.clone().unwrap_or_default(); + SoftwareState::build_from(&product, &state.config, &state.system, &self.selection) + }; + self.events.send(Event::ConfigChanged { scope: Scope::Software, })?; - let software = SoftwareState::build_from( - &product, - &self.state.config, - &self.state.system, - &self.selection, - ); tracing::info!("Wanted software state: {software:?}"); let model = self.model.clone(); let issues = self.issues.clone(); let events = self.events.clone(); let progress = self.progress.clone(); - let proposal = self.state.proposal.clone(); let product_spec = product.clone(); + let state = self.state.clone(); tokio::task::spawn(async move { - let (new_proposal, found_issues) = - match compute_proposal(model, product_spec, software, progress).await { - Ok((new_proposal, found_issues)) => (Some(new_proposal), found_issues), - Err(error) => { - let new_issue = Issue::new( - "software.proposal_failed", - "It was not possible to create a software proposal", - IssueSeverity::Error, - ) - .with_details(&error.to_string()); - (None, vec![new_issue]) - } - }; - proposal.write().await.software = new_proposal; + let found_issues = match compute_proposal(model, product_spec, software, progress).await + { + Ok((new_proposal, system_info, found_issues)) => { + let mut state = state.write().await; + state.proposal.software = Some(new_proposal); + state.system = system_info; + found_issues + } + Err(error) => { + let new_issue = Issue::new( + "software.proposal_failed", + "It was not possible to create a software proposal", + IssueSeverity::Error, + ) + .with_details(&error.to_string()); + let mut state = state.write().await; + state.proposal.software = None; + vec![new_issue] + } + }; + _ = issues.cast(issue::message::Set::new(Scope::Software, found_issues)); _ = events.send(Event::ProposalChanged { scope: Scope::Software, @@ -194,18 +204,20 @@ async fn compute_proposal( product_spec: ProductSpec, wanted: SoftwareState, progress: Handler, -) -> Result<(SoftwareProposal, Vec), Error> { +) -> Result<(SoftwareProposal, SystemInfo, Vec), Error> { let mut my_model = model.lock().await; my_model.set_product(product_spec); let issues = my_model.write(wanted, progress).await?; let proposal = my_model.compute_proposal().await?; - Ok((proposal, issues)) + let system = my_model.system_info().await?; + Ok((proposal, system, issues)) } #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { - Ok(self.state.proposal.read().await.clone().into_option()) + let state = self.state.read().await; + Ok(state.proposal.clone().into_option()) } } diff --git a/rust/agama-utils/src/api/software/system_info.rs b/rust/agama-utils/src/api/software/system_info.rs index 75c04dda2b..6ed077930b 100644 --- a/rust/agama-utils/src/api/software/system_info.rs +++ b/rust/agama-utils/src/api/software/system_info.rs @@ -22,7 +22,7 @@ use serde::Serialize; /// Localization-related information of the system where the installer /// is running. -#[derive(Clone, Debug, Default, Serialize)] +#[derive(Clone, Debug, Default, Serialize, utoipa::ToSchema)] pub struct SystemInfo { /// List of known patterns. pub patterns: Vec, diff --git a/rust/agama-utils/src/api/status.rs b/rust/agama-utils/src/api/status.rs index 38768ec870..156560c942 100644 --- a/rust/agama-utils/src/api/status.rs +++ b/rust/agama-utils/src/api/status.rs @@ -27,7 +27,6 @@ use serde::Serialize; pub struct Status { /// State of the installation pub state: State, - #[serde(skip_serializing_if = "Vec::is_empty")] /// Active progresses pub progresses: Vec, } diff --git a/rust/agama-utils/src/api/system_info.rs b/rust/agama-utils/src/api/system_info.rs index e864a531d2..40bc21ecf1 100644 --- a/rust/agama-utils/src/api/system_info.rs +++ b/rust/agama-utils/src/api/system_info.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::api::{l10n, manager, network}; +use crate::api::{l10n, manager, network, software}; use serde::Serialize; use serde_json::Value; @@ -28,6 +28,7 @@ pub struct SystemInfo { #[serde(flatten)] pub manager: manager::SystemInfo, pub l10n: l10n::SystemInfo, + pub software: software::SystemInfo, #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub storage: Option, diff --git a/rust/agama-utils/src/licenses.rs b/rust/agama-utils/src/licenses.rs index 9c75d82421..4335c59d9c 100644 --- a/rust/agama-utils/src/licenses.rs +++ b/rust/agama-utils/src/licenses.rs @@ -20,10 +20,8 @@ //! Implements support for reading software licenses. -use crate::api::manager::{InvalidLanguageCode, LanguageTag, License}; +use crate::api::manager::{InvalidLanguageCode, LanguageTag, License, LicenseContent}; use agama_locale_data::get_territories; -use serde::Serialize; -use serde_with::{serde_as, DisplayFromStr}; use std::{ collections::HashMap, fs::read_dir, @@ -39,23 +37,6 @@ pub enum Error { IO(#[from] std::io::Error), } -/// Represents a license content. -/// -/// It contains the license ID and the body. -/// -/// TODO: in the future it might contain a title, extracted from the text. -#[serde_as] -#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] -pub struct LicenseContent { - /// License ID. - pub id: String, - /// License text. - pub body: String, - /// License language. - #[serde_as(as = "DisplayFromStr")] - pub language: LanguageTag, -} - /// Represents a repository of software licenses. /// /// The repository consists of a directory in the file system which contains the licenses in diff --git a/rust/agama-utils/src/products.rs b/rust/agama-utils/src/products.rs index 507ee19035..6268ec51e2 100644 --- a/rust/agama-utils/src/products.rs +++ b/rust/agama-utils/src/products.rs @@ -108,7 +108,7 @@ impl Registry { description: p.description.clone(), icon: p.icon.clone(), registration: p.registration, - license: None, + license: p.license.clone(), }) .collect() } @@ -133,6 +133,7 @@ pub struct ProductSpec { #[serde(default)] pub registration: bool, pub version: Option, + pub license: Option, pub software: SoftwareSpec, pub storage: StorageSpec, } diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index b796dba67d..74f0b7f523 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -22,62 +22,39 @@ import React from "react"; import { screen } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import App from "./App"; -import { InstallationPhase } from "./types/status"; +import { installerRender, mockRoutes } from "~/test-utils"; import { createClient } from "~/client"; -import { Product } from "./types/software"; +import { useExtendedConfig, useStatus, useSystem } from "~/hooks/api"; +import { Product } from "~/types/software"; +import { Config } from "~/api"; +import { Progress, State } from "~/api/status"; +import { PATHS } from "~/router"; +import { PRODUCT } from "~/routes/paths"; +import App from "./App"; jest.mock("~/client"); const tumbleweed: Product = { id: "openSUSE", name: "openSUSE Tumbleweed", registration: false }; const microos: Product = { id: "Leap Micro", name: "openSUSE Micro", registration: false }; -// list of available products -let mockProducts: Product[]; -let mockSelectedProduct: Product; - -jest.mock("~/queries/software", () => ({ - ...jest.requireActual("~/queries/software"), - useProduct: () => { - return { - products: mockProducts, - selectedProduct: mockSelectedProduct, - }; - }, - useProductChanges: () => jest.fn(), -})); - -jest.mock("~/queries/l10n", () => ({ - ...jest.requireActual("~/queries/l10n"), - useL10nConfigChanges: () => jest.fn(), -})); - -jest.mock("~/queries/issues", () => ({ - ...jest.requireActual("~/queries/issues"), - useIssuesChanges: () => jest.fn(), - useAllIssues: () => ({ isEmpty: true }), -})); - -jest.mock("~/queries/storage", () => ({ - ...jest.requireActual("~/queries/storage"), - useDeprecatedChanges: () => jest.fn(), -})); +const mockProgresses: jest.Mock = jest.fn(); +const mockState: jest.Mock = jest.fn(); +const mockSelectedProduct: jest.Mock = jest.fn(); -const mockClientStatus = { - phase: InstallationPhase.Startup, - isBusy: true, -}; +jest.mock("~/hooks/api", () => ({ + ...jest.requireActual("~/hooks/api"), + useSystem: (): ReturnType => ({ + products: [tumbleweed, microos], + }), -jest.mock("~/queries/status", () => ({ - ...jest.requireActual("~/queries/status"), - useInstallerStatus: () => mockClientStatus, - useInstallerStatusChanges: () => jest.fn(), -})); + useStatus: (): ReturnType => ({ + state: mockState(), + progresses: mockProgresses(), + }), -jest.mock("~/context/installer", () => ({ - ...jest.requireActual("~/context/installer"), - useInstallerClientStatus: () => ({ connected: true, error: false }), + useExtendedConfig: (): ReturnType => ({ + product: mockSelectedProduct(), + }), })); // Mock some components, @@ -95,8 +72,7 @@ describe("App", () => { isConnected: () => true, }; }); - - mockProducts = [tumbleweed, microos]; + mockProgresses.mockReturnValue([]); }); afterEach(() => { @@ -104,62 +80,69 @@ describe("App", () => { document.cookie = "agamaLang=; path=/; expires=" + new Date(0).toUTCString(); }); - describe("when the software context is not initialized", () => { + describe("on the configuration phase with a product already selected", () => { beforeEach(() => { - mockProducts = undefined; + mockState.mockReturnValue("configuring"); + mockSelectedProduct.mockReturnValue({ id: tumbleweed.id }); }); - it("renders the Loading screen", async () => { + it("renders the application content", async () => { installerRender(); - await screen.findByText("Loading Mock"); + await screen.findByText(/Outlet Content/); }); }); - describe("when the service is busy during startup", () => { + describe("on the configuration phase without a product selected yet", () => { beforeEach(() => { - mockClientStatus.phase = InstallationPhase.Startup; - mockClientStatus.isBusy = true; + mockState.mockReturnValue("configuring"); + mockSelectedProduct.mockReturnValue(undefined); }); - it("renders the Loading screen", async () => { - installerRender(); - await screen.findByText("Loading Mock"); - }); - }); - - describe("on the configuration phase", () => { - beforeEach(() => { - mockClientStatus.phase = InstallationPhase.Config; - }); - - describe("if the service is busy", () => { + describe("if there is an ongoin progress", () => { beforeEach(() => { - mockClientStatus.isBusy = true; - mockSelectedProduct = tumbleweed; + mockProgresses.mockReturnValue([ + { index: 1, scope: "software", size: 3, steps: ["one", "two", "three"], step: "two" }, + ]); }); - it("redirects to product selection progress", async () => { + it("renders the application content", async () => { installerRender(); - await screen.findByText("Navigating to /products/progress"); + await screen.findByText(/Outlet Content/); }); }); - describe("if the service is not busy", () => { + describe("if there is no progress", () => { beforeEach(() => { - mockClientStatus.isBusy = false; + mockProgresses.mockReturnValue([]); }); - it("renders the application content", async () => { - installerRender(); - await screen.findByText(/Outlet Content/); + describe("and in the product selection already", () => { + beforeEach(() => { + mockRoutes(PRODUCT.root); + }); + + it("renders the application content", async () => { + installerRender(); + await screen.findByText(/Outlet Content/); + }); + }); + + describe("and not in the product selection yet", () => { + beforeEach(() => { + mockRoutes(PATHS.root); + }); + + it("navigates to product selection", async () => { + installerRender(); + await screen.findByText("Navigating to /products"); + }); }); }); }); describe("on the installation phase", () => { beforeEach(() => { - mockClientStatus.phase = InstallationPhase.Install; - mockSelectedProduct = tumbleweed; + mockState.mockReturnValue("installing"); }); it("navigates to installation progress", async () => { @@ -170,8 +153,7 @@ describe("App", () => { describe("on the finish phase", () => { beforeEach(() => { - mockClientStatus.phase = InstallationPhase.Finish; - mockSelectedProduct = tumbleweed; + mockState.mockReturnValue("finished"); }); it("navigates to installation finished", async () => { diff --git a/web/src/App.tsx b/web/src/App.tsx index 960c36988f..d2f5d10ce2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2024] SUSE LLC + * Copyright (c) [2022-2025] SUSE LLC * * All Rights Reserved. * @@ -22,14 +22,19 @@ import React, { useEffect } from "react"; import { Navigate, Outlet, useLocation } from "react-router"; -import { Loading } from "~/components/layout"; -import { useProduct, useProductChanges } from "~/queries/software"; -import { useSystemChanges, useProposalChanges, useIssuesChanges } from "~/hooks/api"; -import { useInstallerStatus, useInstallerStatusChanges } from "~/queries/status"; +import { useProductChanges } from "~/queries/software"; +import { + useSystemChanges, + useProposalChanges, + useIssuesChanges, + useStatus, + useExtendedConfig, +} from "~/hooks/api"; +import { useInstallerStatusChanges } from "~/queries/status"; import { ROOT, PRODUCT } from "~/routes/paths"; -import { InstallationPhase } from "~/types/status"; import { useQueryClient } from "@tanstack/react-query"; import AlertOutOfSync from "~/components/core/AlertOutOfSync"; +import { isEmpty } from "radashi"; /** * Main application component. @@ -42,12 +47,12 @@ function App() { useInstallerStatusChanges(); const location = useLocation(); - const { isBusy, phase } = useInstallerStatus({ suspense: true }); - const { selectedProduct, products } = useProduct({ - suspense: phase !== InstallationPhase.Install, - }); + const { product } = useExtendedConfig({ suspense: true }); + const { progresses, state } = useStatus({ suspense: true }); const queryClient = useQueryClient(); + const isBusy = !isEmpty(progresses); + // FIXME: check if still needed useEffect(() => { // Invalidate the queries when unmounting this component. return () => { @@ -56,43 +61,28 @@ function App() { }, [queryClient]); console.log("App component", { - phase, - isBusy, - products, - selectedProduct, + progresses, + state, + product, location: location.pathname, }); const Content = () => { - if (phase === InstallationPhase.Install) { + if (state === "installing") { console.log("Navigating to the installation progress page"); return ; } - if (phase === InstallationPhase.Finish) { + if (state === "finished") { console.log("Navigating to the finished page"); return ; } - if (!products) { - return ; - } - - if (phase === InstallationPhase.Startup && isBusy) { - console.log("Loading screen: Installer start phase"); - return ; - } - - if (selectedProduct === undefined && !isBusy && location.pathname !== PRODUCT.root) { + if (product?.id === undefined && !isBusy && location.pathname !== PRODUCT.root) { console.log("Navigating to the product selection page"); return ; } - if (phase === InstallationPhase.Config && isBusy && location.pathname !== PRODUCT.progress) { - console.log("Navigating to the probing progress page"); - return ; - } - return ; }; diff --git a/web/src/api/config.ts b/web/src/api/config.ts index a14daf0ca1..87ae360666 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -22,10 +22,12 @@ import * as l10n from "~/api/l10n/config"; import * as storage from "~/api/storage/config"; +import * as product from "~/api/product/config"; type Config = { l10n?: l10n.Config; storage?: storage.Config; + product?: product.Config; }; export { l10n, storage }; diff --git a/web/src/api/proposal.ts b/web/src/api/proposal.ts index 9e7fd99496..2569e11914 100644 --- a/web/src/api/proposal.ts +++ b/web/src/api/proposal.ts @@ -22,10 +22,12 @@ import * as l10n from "~/api/l10n/proposal"; import * as storage from "~/api/storage/proposal"; +import * as software from "~/api/software/proposal"; type Proposal = { l10n?: l10n.Proposal; storage?: storage.Proposal; + software?: software.Proposal; }; export { l10n, storage }; diff --git a/web/src/api/software.ts b/web/src/api/software.ts index b457b5c0e4..68c10e01f5 100644 --- a/web/src/api/software.ts +++ b/web/src/api/software.ts @@ -62,7 +62,7 @@ const fetchLicenses = (): Promise => get("/api/software/licenses"); * Returns the content for given license id */ const fetchLicense = (id: string, lang: string = "en"): Promise => - get(`/api/software/licenses/${id}?lang=${lang}`); + get(`/api/v2/licenses/${id}?lang=${lang}`); /** * Returns an object with the registration info diff --git a/web/src/api/status.ts b/web/src/api/status.ts index 45de115e7e..f6c3ec4be2 100644 --- a/web/src/api/status.ts +++ b/web/src/api/status.ts @@ -36,6 +36,19 @@ const fetchInstallerStatus = async (): Promise => { // TODO: remove export { fetchInstallerStatus }; -type Status = object; +type State = "installing" | "configuring" | "finished"; +type Scope = "manager" | "l10n" | "product" | "software" | "storage" | "iscsci" | "users"; +type Progress = { + index: number; + scope: Scope; + size: number; + steps: string[]; + step: string; +}; + +type Status = { + state: State; + progresses: Progress[]; +}; -export type { Status }; +export type { Status, State, Scope, Progress }; diff --git a/web/src/api/system.ts b/web/src/api/system.ts index a7325b498b..7e9589c538 100644 --- a/web/src/api/system.ts +++ b/web/src/api/system.ts @@ -22,10 +22,12 @@ import * as l10n from "~/api/l10n/system"; import * as storage from "~/api/storage/system"; +import { Product } from "~/types/software"; type System = { l10n?: l10n.System; storage?: storage.System; + products?: Product[]; }; export { l10n, storage }; diff --git a/web/src/components/core/ChangeProductOption.test.tsx b/web/src/components/core/ChangeProductOption.test.tsx index b91e4840f6..9cee0d2205 100644 --- a/web/src/components/core/ChangeProductOption.test.tsx +++ b/web/src/components/core/ChangeProductOption.test.tsx @@ -23,10 +23,10 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; +import { useSystem } from "~/hooks/api"; import { PRODUCT as PATHS } from "~/routes/paths"; import { Product, RegistrationInfo } from "~/types/software"; import ChangeProductOption from "./ChangeProductOption"; -import { useRegistration } from "~/queries/software"; const tumbleweed: Product = { id: "Tumbleweed", @@ -43,18 +43,20 @@ const microos: Product = { registration: false, }; -let mockUseProduct: { products: Product[]; selectedProduct?: Product }; let registrationInfoMock: RegistrationInfo; +const mockSystemProducts: jest.Mock = jest.fn(); -jest.mock("~/queries/software", () => ({ - useProduct: () => mockUseProduct, - useRegistration: (): ReturnType => registrationInfoMock, +jest.mock("~/hooks/api", () => ({ + ...jest.requireActual("~/hooks/api"), + useSystem: (): ReturnType => ({ + products: mockSystemProducts(), + }), })); describe("ChangeProductOption", () => { describe("when there is more than one product available", () => { beforeEach(() => { - mockUseProduct = { products: [tumbleweed, microos] }; + mockSystemProducts.mockReturnValue([tumbleweed, microos]); }); it("renders a menu item for navigating to product selection page", () => { @@ -63,7 +65,8 @@ describe("ChangeProductOption", () => { expect(link).toHaveAttribute("href", PATHS.changeProduct); }); - describe("but a product is registered", () => { + // FIXME: activate it again when registration is ready in api v2 + describe.skip("but a product is registered", () => { beforeEach(() => { registrationInfoMock = { registered: true, @@ -82,7 +85,7 @@ describe("ChangeProductOption", () => { describe("when there is only one product available", () => { beforeEach(() => { - mockUseProduct = { products: [tumbleweed] }; + mockSystemProducts.mockReturnValue([tumbleweed]); }); it("renders nothing", () => { diff --git a/web/src/components/core/ChangeProductOption.tsx b/web/src/components/core/ChangeProductOption.tsx index 2d66d7f362..a32537707a 100644 --- a/web/src/components/core/ChangeProductOption.tsx +++ b/web/src/components/core/ChangeProductOption.tsx @@ -23,21 +23,22 @@ import React from "react"; import { DropdownItem, DropdownItemProps } from "@patternfly/react-core"; import { useHref, useLocation } from "react-router"; -import { useProduct, useRegistration } from "~/queries/software"; +// import { useRegistration } from "~/queries/software"; import { PRODUCT as PATHS, SIDE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; +import { useSystem } from "~/hooks/api"; /** * DropdownItem Option for navigating to the selection product. */ export default function ChangeProductOption({ children, ...props }: Omit) { - const { products } = useProduct(); - const registration = useRegistration(); + const { products } = useSystem({ suspense: true }); + // const registration = useRegistration(); const currentLocation = useLocation(); const to = useHref(PATHS.changeProduct); if (products.length <= 1) return null; - if (registration?.registered) return null; + // if (registration?.registered) return null; if (SIDE_PATHS.includes(currentLocation.pathname)) return null; return ( diff --git a/web/src/components/core/InstallerOptions.test.tsx b/web/src/components/core/InstallerOptions.test.tsx index 7347fb0b82..a4532aa859 100644 --- a/web/src/components/core/InstallerOptions.test.tsx +++ b/web/src/components/core/InstallerOptions.test.tsx @@ -23,15 +23,13 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { installerRender, mockRoutes } from "~/test-utils"; -import { InstallationPhase } from "~/types/status"; +import { useSelectedProduct, useStatus, useSystem } from "~/hooks/api"; +import { Product } from "~/types/software"; +import { Keymap, Locale } from "~/api/l10n/system"; +import { Progress, State } from "~/api/status"; import * as utils from "~/utils"; import { PRODUCT, ROOT } from "~/routes/paths"; import InstallerOptions, { InstallerOptionsProps } from "./InstallerOptions"; -import { Product } from "~/types/software"; -import { Keymap, Locale } from "~/api/system"; - -let phase: InstallationPhase; -let isBusy: boolean; const locales: Locale[] = [ { id: "en_US.UTF-8", name: "English", territory: "United States" }, @@ -51,28 +49,30 @@ const tumbleweed: Product = { registration: false, }; -let mockSelectedProduct: Product; - -const mockUpdateConfigFn = jest.fn(); - const mockChangeUIKeymap = jest.fn(); const mockChangeUILanguage = jest.fn(); - -jest.mock("~/queries/system", () => ({ - ...jest.requireActual("~/queries/system"), - useSystem: () => ({ l10n: { locales, keymaps, locale: "us_US.UTF-8", keymap: "us" } }), +const mockPatchConfigFn = jest.fn(); +const mockConfigureL10nActionFn = jest.fn(); +const mockStateFn: jest.Mock = jest.fn(); +const mockProgressesFn: jest.Mock = jest.fn(); +const mockSelectedProductFn: jest.Mock = jest.fn(); + +jest.mock("~/api", () => ({ + ...jest.requireActual("~/api"), + configureL10nAction: (payload) => mockConfigureL10nActionFn(payload), + patchConfig: (payload) => mockPatchConfigFn(payload), })); -jest.mock("~/api/api", () => ({ - ...jest.requireActual("~/api/api"), - updateConfig: (config) => mockUpdateConfigFn(config), -})); - -jest.mock("~/queries/status", () => ({ - useInstallerStatus: () => ({ - phase, - isBusy, +jest.mock("~/hooks/api", () => ({ + ...jest.requireActual("~/hooks/api"), + useSystem: (): ReturnType => ({ + l10n: { locales, keymaps, locale: "us_US.UTF-8", keymap: "us" }, + }), + useStatus: (): ReturnType => ({ + state: mockStateFn(), + progresses: mockProgressesFn(), }), + useSelectedProduct: (): ReturnType => mockSelectedProductFn(), })); jest.mock("~/context/installerL10n", () => ({ @@ -85,16 +85,6 @@ jest.mock("~/context/installerL10n", () => ({ }), })); -jest.mock("~/queries/software", () => ({ - ...jest.requireActual("~/queries/software"), - useProduct: () => { - return { - products: [tumbleweed], - selectedProduct: mockSelectedProduct, - }; - }, -})); - const renderAndOpen = async (props: InstallerOptionsProps = {}) => { const { user } = installerRender(, { withL10n: true }); const toggle = screen.getByRole("button"); @@ -105,9 +95,9 @@ const renderAndOpen = async (props: InstallerOptionsProps = {}) => { describe("InstallerOptions", () => { beforeEach(() => { jest.spyOn(utils, "localConnection").mockReturnValue(true); - mockSelectedProduct = tumbleweed; - phase = InstallationPhase.Config; - isBusy = false; + mockProgressesFn.mockReturnValue([]); + mockStateFn.mockReturnValue("configuring"); + mockSelectedProductFn.mockReturnValue(tumbleweed); }); it("allows custom toggle", async () => { @@ -187,7 +177,7 @@ describe("InstallerOptions", () => { await user.selectOptions(keymapSelector, "English (UK)"); await user.click(acceptButton); - expect(mockUpdateConfigFn).toHaveBeenCalledWith({ + expect(mockPatchConfigFn).toHaveBeenCalledWith({ l10n: { locale: "es_ES.UTF-8", keymap: "gb", @@ -213,7 +203,7 @@ describe("InstallerOptions", () => { await user.selectOptions(languageSelector, "Español"); await user.selectOptions(keymapSelector, "English (UK)"); await user.click(acceptButton); - expect(mockUpdateConfigFn).not.toHaveBeenCalled(); + expect(mockPatchConfigFn).not.toHaveBeenCalled(); }); it("includes a link to localization page", async () => { @@ -223,7 +213,7 @@ describe("InstallerOptions", () => { describe("but a product is not selected yet", () => { beforeEach(() => { - mockSelectedProduct = undefined; + mockSelectedProductFn.mockReturnValue(undefined); }); it("does not allow reusing setting", async () => { @@ -308,7 +298,7 @@ describe("InstallerOptions", () => { await user.selectOptions(languageSelector, "Español"); await user.click(acceptButton); - expect(mockUpdateConfigFn).toHaveBeenCalledWith({ + expect(mockPatchConfigFn).toHaveBeenCalledWith({ l10n: { locale: "es_ES.UTF-8", }, @@ -329,7 +319,7 @@ describe("InstallerOptions", () => { expect(reuseSettings).not.toBeChecked(); await user.selectOptions(languageSelector, "Español"); await user.click(acceptButton); - expect(mockUpdateConfigFn).not.toHaveBeenCalled(); + expect(mockPatchConfigFn).not.toHaveBeenCalled(); }); it("includes a link to localization page", async () => { @@ -339,7 +329,7 @@ describe("InstallerOptions", () => { describe("but a product is not selected yet", () => { beforeEach(() => { - mockSelectedProduct = undefined; + mockSelectedProductFn.mockReturnValue(undefined); }); it("does not allow reusing setting", async () => { @@ -399,7 +389,7 @@ describe("InstallerOptions", () => { await user.selectOptions(keymapSelector, "English (UK)"); await user.click(acceptButton); - expect(mockUpdateConfigFn).toHaveBeenCalledWith({ + expect(mockPatchConfigFn).toHaveBeenCalledWith({ l10n: { keymap: "gb", }, @@ -422,7 +412,7 @@ describe("InstallerOptions", () => { expect(reuseSettings).not.toBeChecked(); await user.selectOptions(keymapSelector, "English (UK)"); await user.click(acceptButton); - expect(mockUpdateConfigFn).not.toHaveBeenCalled(); + expect(mockPatchConfigFn).not.toHaveBeenCalled(); }); it("includes a link to localization page", async () => { @@ -445,7 +435,7 @@ describe("InstallerOptions", () => { describe("but a product is not selected yet", () => { beforeEach(() => { - mockSelectedProduct = undefined; + mockSelectedProductFn.mockReturnValue(undefined); }); it("does not allow reusing setting", async () => { diff --git a/web/src/components/core/InstallerOptions.tsx b/web/src/components/core/InstallerOptions.tsx index ae31d0a670..0d6e0a29b9 100644 --- a/web/src/components/core/InstallerOptions.tsx +++ b/web/src/components/core/InstallerOptions.tsx @@ -48,15 +48,12 @@ import { import { Popup } from "~/components/core"; import { Icon } from "~/components/layout"; import { Keymap, Locale } from "~/api/l10n/system"; -import { InstallationPhase } from "~/types/status"; import { useInstallerL10n } from "~/context/installerL10n"; -import { useInstallerStatus } from "~/queries/status"; import { localConnection } from "~/utils"; import { _ } from "~/i18n"; import supportedLanguages from "~/languages.json"; import { PRODUCT, ROOT, L10N } from "~/routes/paths"; -import { useProduct } from "~/queries/software"; -import { useSystem } from "~/hooks/api"; +import { useSelectedProduct, useStatus, useSystem } from "~/hooks/api"; import { patchConfig } from "~/api"; /** @@ -556,8 +553,8 @@ export default function InstallerOptions({ l10n: { locales }, } = useSystem({ suspense: true }); const { language, keymap, changeLanguage, changeKeymap } = useInstallerL10n(); - const { phase } = useInstallerStatus({ suspense: true }); - const { selectedProduct } = useProduct({ suspense: true }); + const { state } = useStatus({ suspense: true }); + const selectedProduct = useSelectedProduct(); const initialFormState = { language, keymap, @@ -573,7 +570,7 @@ export default function InstallerOptions({ // Skip rendering if any of the following conditions are met const skip = (variant === "keyboard" && !localConnection()) || - phase === InstallationPhase.Install || + state === "installing" || // FIXME: below condition could be a problem for a question appearing while // product progress [ROOT.login, ROOT.installationProgress, ROOT.installationFinished, PRODUCT.progress].includes( diff --git a/web/src/components/layout/Header.test.tsx b/web/src/components/layout/Header.test.tsx index 5a5cda9ca0..a82a785821 100644 --- a/web/src/components/layout/Header.test.tsx +++ b/web/src/components/layout/Header.test.tsx @@ -23,6 +23,7 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender, installerRender } from "~/test-utils"; +import { useSystem } from "~/hooks/api"; import { Product } from "~/types/software"; import Header from "./Header"; @@ -43,12 +44,10 @@ const microos: Product = { jest.mock("~/components/core/InstallerOptions", () => () =>
Installer Options Mock
); jest.mock("~/components/core/InstallButton", () => () =>
Install Button Mock
); -jest.mock("~/queries/software", () => ({ - useProduct: () => ({ - products: [tumbleweed, microos], - selectedProduct: tumbleweed, - }), - useRegistration: () => undefined, +jest.mock("~/hooks/api", () => ({ + ...jest.requireActual("~/hooks/api"), + useSystem: (): ReturnType => ({ products: [tumbleweed, microos] }), + useSelectedProduct: (): Product => tumbleweed, })); describe("Header", () => { @@ -86,7 +85,7 @@ describe("Header", () => { expect(screen.queryByRole("menu")).toBeNull(); const toggler = screen.getByRole("button", { name: "Options toggle" }); await user.click(toggler); - const menu = screen.getByRole("menu"); + const menu = await screen.findByRole("menu"); within(menu).getByRole("menuitem", { name: "Change product" }); within(menu).getByRole("menuitem", { name: "Download logs" }); }); diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 3538f92f82..4627dfac14 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -41,11 +41,11 @@ import { } from "@patternfly/react-core"; import { useMatches } from "react-router"; import { Icon } from "~/components/layout"; -import { useProduct } from "~/queries/software"; import { Route } from "~/types/routes"; import { ChangeProductOption, InstallButton, InstallerOptions, SkipTo } from "~/components/core"; import { ROOT } from "~/routes/paths"; import { _ } from "~/i18n"; +import { useSelectedProduct } from "~/hooks/api"; export type HeaderProps = { /** Whether the application sidebar should be mounted or not */ @@ -111,11 +111,11 @@ export default function Header({ isSidebarOpen, toggleSidebar, }: HeaderProps): React.ReactNode { - const { selectedProduct } = useProduct(); + const product = useSelectedProduct(); const routeMatches = useMatches() as Route[]; const currentRoute = routeMatches.at(-1); // TODO: translate title - const title = (showProductName && selectedProduct?.name) || currentRoute?.handle?.title; + const title = (showProductName && product?.name) || currentRoute?.handle?.title; return ( diff --git a/web/src/components/layout/Sidebar.test.tsx b/web/src/components/layout/Sidebar.test.tsx index 2a335c9bd3..d59513922f 100644 --- a/web/src/components/layout/Sidebar.test.tsx +++ b/web/src/components/layout/Sidebar.test.tsx @@ -23,9 +23,9 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import Sidebar from "./Sidebar"; +import { useSystem } from "~/hooks/api"; import { Product } from "~/types/software"; -import { useProduct } from "~/queries/software"; +import Sidebar from "./Sidebar"; const tw: Product = { id: "Tumbleweed", @@ -39,16 +39,12 @@ const sle: Product = { registration: true, }; -let selectedProduct: Product; +const mockSelectedProduct: jest.Mock = jest.fn(); -jest.mock("~/queries/software", () => ({ - ...jest.requireActual("~/queries/software"), - useProduct: (): ReturnType => { - return { - products: [tw, sle], - selectedProduct, - }; - }, +jest.mock("~/hooks/api", () => ({ + ...jest.requireActual("~/hooks/api"), + useSystem: (): ReturnType => ({ products: [tw, sle] }), + useSelectedProduct: (): Product => mockSelectedProduct(), })); jest.mock("~/router", () => ({ @@ -67,7 +63,7 @@ jest.mock("~/router", () => ({ describe("Sidebar", () => { describe("when product is registrable", () => { beforeEach(() => { - selectedProduct = sle; + mockSelectedProduct.mockReturnValue(sle); }); it("renders a navigation including all root routes with handle object", () => { @@ -83,7 +79,7 @@ describe("Sidebar", () => { describe("when product is not registrable", () => { beforeEach(() => { - selectedProduct = tw; + mockSelectedProduct.mockReturnValue(tw); }); it("renders a navigation including all root routes with handle object, except ones set as needsRegistrableProduct", () => { diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index bfc2154064..1107914bd4 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -33,10 +33,10 @@ import { import { Icon } from "~/components/layout"; import { rootRoutes } from "~/router"; import { _ } from "~/i18n"; -import { useProduct } from "~/queries/software"; +import { useSelectedProduct } from "~/hooks/api"; const MainNavigation = (): React.ReactNode => { - const { selectedProduct: product } = useProduct(); + const product = useSelectedProduct(); const location = useLocation(); const links = rootRoutes().map((route) => { diff --git a/web/src/components/overview/SoftwareSection.test.tsx b/web/src/components/overview/SoftwareSection.test.tsx index 147376d5f0..4a9e85629c 100644 --- a/web/src/components/overview/SoftwareSection.test.tsx +++ b/web/src/components/overview/SoftwareSection.test.tsx @@ -23,20 +23,14 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import mockTestingPatterns from "~/components/software/patterns.test.json"; import testingProposal from "~/components/software/proposal.test.json"; import SoftwareSection from "~/components/overview/SoftwareSection"; import { SoftwareProposal } from "~/types/software"; let mockTestingProposal: SoftwareProposal; -jest.mock("~/queries/software", () => ({ - usePatterns: () => mockTestingPatterns, - useSoftwareProposal: () => mockTestingProposal, - useSoftwareProposalChanges: () => jest.fn(), -})); - -describe("SoftwareSection", () => { +// FIXME: redo this tests once new overview is done after api v2 +describe.skip("SoftwareSection", () => { describe("when the proposal does not have patterns to select", () => { beforeEach(() => { mockTestingProposal = { patterns: {}, size: "" }; diff --git a/web/src/components/overview/SoftwareSection.tsx b/web/src/components/overview/SoftwareSection.tsx index e55940c4f7..669951acf4 100644 --- a/web/src/components/overview/SoftwareSection.tsx +++ b/web/src/components/overview/SoftwareSection.tsx @@ -23,15 +23,13 @@ import React from "react"; import { Content, List, ListItem } from "@patternfly/react-core"; import { SelectedBy } from "~/types/software"; -import { usePatterns, useSoftwareProposal, useSoftwareProposalChanges } from "~/queries/software"; import { isEmpty } from "radashi"; import { _ } from "~/i18n"; +import { useProposal, useSystem } from "~/hooks/api"; export default function SoftwareSection(): React.ReactNode { - const proposal = useSoftwareProposal(); - const patterns = usePatterns(); - - useSoftwareProposalChanges(); + const { software: proposal } = useProposal({ suspense: true }); + const { patterns = [] } = useSystem(); if (isEmpty(proposal.patterns)) return; diff --git a/web/src/components/product/ProductRegistrationAlert.test.tsx b/web/src/components/product/ProductRegistrationAlert.test.tsx index ef1e01049f..d88155c59e 100644 --- a/web/src/components/product/ProductRegistrationAlert.test.tsx +++ b/web/src/components/product/ProductRegistrationAlert.test.tsx @@ -23,12 +23,11 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender, mockRoutes } from "~/test-utils"; -import ProductRegistrationAlert from "./ProductRegistrationAlert"; -import { Product } from "~/types/software"; -import { useProduct } from "~/queries/software"; -import { useScopeIssues } from "~/hooks/issues"; -import { PRODUCT, REGISTRATION, ROOT } from "~/routes/paths"; +import { useScopeIssues, useSelectedProduct, useSystem } from "~/hooks/api"; import { Issue, IssueSeverity, IssueSource } from "~/api/issue"; +import { PRODUCT, REGISTRATION, ROOT } from "~/routes/paths"; +import { Product } from "~/types/software"; +import ProductRegistrationAlert from "./ProductRegistrationAlert"; const tw: Product = { id: "Tumbleweed", @@ -42,19 +41,18 @@ const sle: Product = { registration: true, }; -let selectedProduct: Product; +const mockSelectedProduct: jest.Mock = jest.fn(); +const mockIssues: jest.Mock = jest.fn(); -jest.mock("~/queries/software", () => ({ - ...jest.requireActual("~/queries/software"), - useProduct: (): ReturnType => { - return { - products: [tw, sle], - selectedProduct, - }; - }, +jest.mock("~/hooks/api", () => ({ + ...jest.requireActual("~/hooks/api"), + useSystem: (): ReturnType => ({ + products: [tw, sle], + }), + useSelectedProduct: (): ReturnType => mockSelectedProduct(), + useScopeIssues: (): ReturnType => mockIssues(), })); -let issues: Issue[] = []; const registrationIssue: Issue = { description: "Product must be registered", details: "", @@ -64,11 +62,6 @@ const registrationIssue: Issue = { scope: "storage", }; -jest.mock("~/queries/issues", () => ({ - ...jest.requireActual("~/queries/issues"), - useIssues: (): ReturnType => issues, -})); - const rendersNothingInSomePaths = () => { describe.each([ ["login", ROOT.login], @@ -91,8 +84,8 @@ const rendersNothingInSomePaths = () => { describe("ProductRegistrationAlert", () => { describe("when the registration is missing", () => { beforeEach(() => { - issues = [registrationIssue]; - selectedProduct = sle; + mockSelectedProduct.mockReturnValue(sle); + mockIssues.mockReturnValue([registrationIssue]); }); rendersNothingInSomePaths(); @@ -123,8 +116,8 @@ describe("ProductRegistrationAlert", () => { describe("when the registration is not needed", () => { beforeEach(() => { - issues = []; - selectedProduct = sle; + mockSelectedProduct.mockReturnValue(tw); + mockIssues.mockReturnValue([]); }); it("renders nothing", () => { diff --git a/web/src/components/product/ProductRegistrationAlert.tsx b/web/src/components/product/ProductRegistrationAlert.tsx index 0ef83dfa13..088bee85ef 100644 --- a/web/src/components/product/ProductRegistrationAlert.tsx +++ b/web/src/components/product/ProductRegistrationAlert.tsx @@ -24,11 +24,10 @@ import React from "react"; import { Alert } from "@patternfly/react-core"; import { useLocation } from "react-router"; import { Link } from "~/components/core"; -import { useProduct } from "~/queries/software"; import { REGISTRATION, SIDE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; -import { useScopeIssues } from "~/hooks/api"; +import { useScopeIssues, useSelectedProduct } from "~/hooks/api"; const LinkToRegistration = ({ text }: { text: string }) => { const location = useLocation(); @@ -44,10 +43,10 @@ const LinkToRegistration = ({ text }: { text: string }) => { export default function ProductRegistrationAlert() { const location = useLocation(); - const { selectedProduct: product } = useProduct(); + const product = useSelectedProduct(); // FIXME: what scope reports these issues with the new API? const issues = useScopeIssues("product"); - const registrationRequired = issues.find((i) => i.kind === "missing_registration"); + const registrationRequired = issues?.find((i) => i.kind === "missing_registration"); // NOTE: it shouldn't be mounted in these paths, but let's prevent rendering // if so just in case. diff --git a/web/src/components/product/ProductSelectionPage.test.tsx b/web/src/components/product/ProductSelectionPage.test.tsx index 2c9ada10f4..505aadf450 100644 --- a/web/src/components/product/ProductSelectionPage.test.tsx +++ b/web/src/components/product/ProductSelectionPage.test.tsx @@ -23,16 +23,14 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender, mockNavigateFn } from "~/test-utils"; -import { ProductSelectionPage } from "~/components/product"; -import { Product, RegistrationInfo } from "~/types/software"; -import { useProduct, useRegistration } from "~/queries/software"; +import { useSelectedProduct, useSystem } from "~/hooks/api"; +import { Product } from "~/types/software"; +import ProductSelectionPage from "./ProductSelectionPage"; jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
ProductRegistrationAlert Mock
)); -const mockConfigMutation = jest.fn(); - const tumbleweed: Product = { id: "Tumbleweed", name: "openSUSE Tumbleweed", @@ -50,31 +48,26 @@ const microOs: Product = { license: "fake.license", }; -let mockSelectedProduct: Product; -let registrationInfoMock: RegistrationInfo; - -jest.mock("~/queries/software", () => ({ - ...jest.requireActual("~/queries/software"), - useProduct: (): ReturnType => { - return { - products: [tumbleweed, microOs], - selectedProduct: mockSelectedProduct, - }; - }, - useProductChanges: () => jest.fn(), - useConfigMutation: () => ({ mutate: mockConfigMutation }), - useRegistration: (): ReturnType => registrationInfoMock, +const mockPatchConfigFn = jest.fn(); +const mockSelectedProduct: jest.Mock = jest.fn(); + +jest.mock("~/api", () => ({ + ...jest.requireActual("~/api"), + patchConfig: (payload) => mockPatchConfigFn(payload), })); -describe("ProductSelectionPage", () => { - beforeEach(() => { - mockSelectedProduct = microOs; - registrationInfoMock = { registered: false, key: "", email: "", url: "" }; - }); +jest.mock("~/hooks/api", () => ({ + ...jest.requireActual("~/hooks/api"), + useSystem: (): ReturnType => ({ + products: [tumbleweed, microOs], + }), + useSelectedProduct: (): ReturnType => mockSelectedProduct(), +})); +describe("ProductSelectionPage", () => { describe("when user select a product with license", () => { beforeEach(() => { - mockSelectedProduct = undefined; + mockSelectedProduct.mockReturnValue(undefined); }); it("force license acceptance for allowing product selection", async () => { @@ -94,7 +87,7 @@ describe("ProductSelectionPage", () => { describe("when there is a product with license previouly selected", () => { beforeEach(() => { - mockSelectedProduct = microOs; + mockSelectedProduct.mockReturnValue(microOs); }); it("does not allow revoking license acceptance", () => { @@ -105,14 +98,15 @@ describe("ProductSelectionPage", () => { }); }); - describe("when product is registered", () => { + // FIXME: re-enable it when registration is ready in v2 + describe.skip("when product is registered", () => { beforeEach(() => { - registrationInfoMock = { - registered: true, - key: "INTERNAL-USE-ONLY-1234-5678", - email: "", - url: "", - }; + // registrationInfoMock = { + // registered: true, + // key: "INTERNAL-USE-ONLY-1234-5678", + // email: "", + // url: "", + // }; }); it("navigates to root path", async () => { @@ -122,6 +116,10 @@ describe("ProductSelectionPage", () => { }); describe("when there is a product already selected", () => { + beforeEach(() => { + mockSelectedProduct.mockReturnValue(microOs); + }); + it("renders the Cancel button", () => { installerRender(); screen.getByRole("button", { name: "Cancel" }); @@ -130,7 +128,7 @@ describe("ProductSelectionPage", () => { describe("when there is not a product selected yet", () => { beforeEach(() => { - mockSelectedProduct = undefined; + mockSelectedProduct.mockReturnValue(undefined); }); it("does not render the Cancel button", () => { @@ -140,24 +138,32 @@ describe("ProductSelectionPage", () => { }); describe("when the user chooses a product and hits the confirmation button", () => { + beforeEach(() => { + mockSelectedProduct.mockReturnValue(undefined); + }); + it("triggers the product selection", async () => { const { user } = installerRender(); const productOption = screen.getByRole("radio", { name: tumbleweed.name }); const selectButton = screen.getByRole("button", { name: "Select" }); await user.click(productOption); await user.click(selectButton); - expect(mockConfigMutation).toHaveBeenCalledWith({ product: tumbleweed.id }); + expect(mockPatchConfigFn).toHaveBeenCalledWith({ product: { id: tumbleweed.id } }); }); }); describe("when the user chooses a product but hits the cancel button", () => { + beforeEach(() => { + mockSelectedProduct.mockReturnValue(microOs); + }); + it("does not trigger the product selection and goes back", async () => { const { user } = installerRender(); const productOption = screen.getByRole("radio", { name: tumbleweed.name }); const cancelButton = screen.getByRole("button", { name: "Cancel" }); await user.click(productOption); await user.click(cancelButton); - expect(mockConfigMutation).not.toHaveBeenCalled(); + expect(mockPatchConfigFn).not.toHaveBeenCalled(); expect(mockNavigateFn).toHaveBeenCalledWith("/"); }); }); diff --git a/web/src/components/product/ProductSelectionPage.tsx b/web/src/components/product/ProductSelectionPage.tsx index c65d723be5..c9ff330ade 100644 --- a/web/src/components/product/ProductSelectionPage.tsx +++ b/web/src/components/product/ProductSelectionPage.tsx @@ -37,17 +37,18 @@ import { Stack, StackItem, } from "@patternfly/react-core"; -import { Navigate, useNavigate } from "react-router"; +import { useNavigate } from "react-router"; import { Page } from "~/components/core"; -import { useConfigMutation, useProduct, useRegistration } from "~/queries/software"; import pfTextStyles from "@patternfly/react-styles/css/utilities/Text/text"; import pfRadioStyles from "@patternfly/react-styles/css/components/Radio/radio"; -import { PATHS } from "~/router"; +// import { PATHS } from "~/router"; import { Product } from "~/types/software"; import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; import LicenseDialog from "./LicenseDialog"; +import { useSelectedProduct, useSystem } from "~/hooks/api"; +import { patchConfig } from "~/api"; const ResponsiveGridItem = ({ children }) => ( @@ -102,9 +103,9 @@ const BackLink = () => { }; function ProductSelectionPage() { - const setConfig = useConfigMutation(); - const registration = useRegistration(); - const { products, selectedProduct } = useProduct({ suspense: true }); + // const registration = useRegistration(); + const { products } = useSystem({ suspense: true }); + const selectedProduct = useSelectedProduct(); const [nextProduct, setNextProduct] = useState(selectedProduct); // FIXME: should not be accepted by default first selectedProduct is accepted // because it's a singleProduct iso. @@ -112,13 +113,13 @@ function ProductSelectionPage() { const [showLicense, setShowLicense] = useState(false); const [isLoading, setIsLoading] = useState(false); - if (registration?.registered && selectedProduct) return ; + // if (registration?.registered && selectedProduct) return ; const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (nextProduct) { - setConfig.mutate({ product: nextProduct.id }); + patchConfig({ product: { id: nextProduct.id } }); setIsLoading(true); } }; diff --git a/web/src/context/installerL10n.test.tsx b/web/src/context/installerL10n.test.tsx index 8493eb7bd7..c0b6f1d9dd 100644 --- a/web/src/context/installerL10n.test.tsx +++ b/web/src/context/installerL10n.test.tsx @@ -22,24 +22,27 @@ import React from "react"; import { render, screen, waitFor } from "@testing-library/react"; - import { InstallerL10nProvider } from "~/context/installerL10n"; import { InstallerClientProvider } from "./installer"; import * as utils from "~/utils"; import { noop } from "radashi"; -const mockFetchConfigFn = jest.fn(); -const mockUpdateConfigFn = jest.fn(); +const mockUseSystemFn = jest.fn(); +const mockConfigureL10nFn = jest.fn(); jest.mock("~/context/installer", () => ({ ...jest.requireActual("~/context/installer"), useInstallerClientStatus: () => ({ connected: true, error: false }), })); -jest.mock("~/api/api", () => ({ - ...jest.requireActual("~/api/api"), - fetchSystem: () => mockFetchConfigFn(), - trigger: (config) => mockUpdateConfigFn(config), +jest.mock("~/api", () => ({ + ...jest.requireActual("~/api"), + configureL10nAction: (config) => mockConfigureL10nFn(config), +})); + +jest.mock("~/hooks/api", () => ({ + ...jest.requireActual("~/hooks/api"), + useSystem: () => mockUseSystemFn(), })); const client = { @@ -81,7 +84,7 @@ describe("InstallerL10nProvider", () => { jest.spyOn(utils, "locationReload").mockImplementation(noop); jest.spyOn(utils, "setLocationSearch"); - mockUpdateConfigFn.mockResolvedValue(true); + mockConfigureL10nFn.mockResolvedValue(true); jest.spyOn(window.navigator, "languages", "get").mockReturnValue(["es-ES", "cs-CZ"]); }); @@ -99,7 +102,7 @@ describe("InstallerL10nProvider", () => { describe("when the language is already set", () => { beforeEach(() => { document.cookie = "agamaLang=en-US; path=/;"; - mockFetchConfigFn.mockResolvedValue({ l10n: { locale: "en_US.UTF-8" } }); + mockUseSystemFn.mockReturnValue({ l10n: { locale: "en_US.UTF-8" } }); }); it("displays the children content and does not reload", async () => { @@ -123,7 +126,7 @@ describe("InstallerL10nProvider", () => { // Ensure both, UI and backend mock languages, are in sync since // client.setUILocale is mocked too. // See navigator.language in the beforeAll at the top of the file. - mockFetchConfigFn.mockResolvedValue({ l10n: { locale: "es_ES.UTF-8" } }); + mockUseSystemFn.mockReturnValue({ l10n: { locale: "es_ES.UTF-8" } }); }); it("sets the language from backend", async () => { @@ -158,7 +161,7 @@ describe("InstallerL10nProvider", () => { describe("when the language is already set to 'cs-CZ'", () => { beforeEach(() => { document.cookie = "agamaLang=cs-CZ; path=/;"; - mockFetchConfigFn.mockResolvedValue({ l10n: { locale: "cs_CZ.UTF-8" } }); + mockUseSystemFn.mockReturnValue({ l10n: { locale: "cs_CZ.UTF-8" } }); }); it("displays the children content and does not reload", async () => { @@ -172,7 +175,7 @@ describe("InstallerL10nProvider", () => { // children are displayed await screen.findByText("ahoj"); - expect(mockUpdateConfigFn).not.toHaveBeenCalled(); + expect(mockConfigureL10nFn).not.toHaveBeenCalled(); expect(document.cookie).toMatch(/agamaLang=cs-CZ/); expect(utils.locationReload).not.toHaveBeenCalled(); @@ -183,10 +186,10 @@ describe("InstallerL10nProvider", () => { describe("when the language is set to 'en-US'", () => { beforeEach(() => { document.cookie = "agamaLang=en-US; path=/;"; - mockFetchConfigFn.mockResolvedValue({ l10n: { locale: "en_US" } }); + mockUseSystemFn.mockReturnValue({ l10n: { locale: "en_US" } }); }); - it.skip("sets the 'cs-CZ' language and reloads", async () => { + it("sets the 'cs-CZ' language and reloads", async () => { render( @@ -205,18 +208,16 @@ describe("InstallerL10nProvider", () => { ); await waitFor(() => screen.getByText("ahoj")); - expect(mockUpdateConfigFn).toHaveBeenCalledWith({ - l10n: { locale: "cs_CZ.UTF-8" }, - }); + expect(mockConfigureL10nFn).toHaveBeenCalledWith({ locale: "cs_CZ.UTF-8" }); }); }); describe("when the language is not set", () => { beforeEach(() => { - mockFetchConfigFn.mockResolvedValue({ uiLocale: "en_US.UTF-8" }); + mockUseSystemFn.mockReturnValue({ l10n: {} }); }); - it.skip("sets the 'cs-CZ' language and reloads", async () => { + it("sets the 'cs-CZ' language and reloads", async () => { render( @@ -235,9 +236,7 @@ describe("InstallerL10nProvider", () => { ); await waitFor(() => screen.getByText("ahoj")); - expect(mockUpdateConfigFn).toHaveBeenCalledWith({ - l10n: { locale: "cs_CZ.UTF-8" }, - }); + expect(mockConfigureL10nFn).toHaveBeenCalledWith({ locale: "cs_CZ.UTF-8" }); }); }); }); diff --git a/web/src/context/installerL10n.tsx b/web/src/context/installerL10n.tsx index 6913c83efd..56de3c8611 100644 --- a/web/src/context/installerL10n.tsx +++ b/web/src/context/installerL10n.tsx @@ -24,7 +24,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { locationReload, setLocationSearch } from "~/utils"; import agama from "~/agama"; import supportedLanguages from "~/languages.json"; -import { useSystem } from "~/hooks/l10n"; +import { useSystem } from "~/hooks/api"; import { configureL10nAction } from "~/api"; const L10nContext = React.createContext(null); @@ -228,15 +228,15 @@ function InstallerL10nProvider({ initialLanguage?: string; children?: React.ReactNode; }) { - const system = useSystem(); + const { l10n } = useSystem({ suspense: true }); const [language, setLanguage] = useState(initialLanguage); const [keymap, setKeymap] = useState(undefined); - const locale = system?.locale; + const locale = l10n?.locale; const backendLanguage = locale ? languageFromLocale(locale) : null; const syncBackendLanguage = useCallback(async () => { - if (!backendLanguage || backendLanguage === language) return; + if (backendLanguage === language) return; // FIXME: fallback to en-US if the language is not supported. await configureL10nAction({ locale: languageToLocale(language) }); @@ -293,8 +293,8 @@ function InstallerL10nProvider({ }, [language, syncBackendLanguage]); useEffect(() => { - setKeymap(system?.keymap); - }, [setKeymap, system]); + setKeymap(l10n?.keymap); + }, [setKeymap, l10n]); const value = { language, changeLanguage, keymap, changeKeymap }; diff --git a/web/src/hooks/api.ts b/web/src/hooks/api.ts index 3f425f4231..0e6024cb72 100644 --- a/web/src/hooks/api.ts +++ b/web/src/hooks/api.ts @@ -30,21 +30,34 @@ import { getStorageModel, getQuestions, getIssues, + getStatus, } from "~/api"; import { useInstallerClient } from "~/context/installer"; import { System } from "~/api/system"; import { Proposal } from "~/api/proposal"; +import { Status } from "~/api/status"; import { Config } from "~/api/config"; import { apiModel } from "~/api/storage"; import { Question } from "~/api/question"; import { IssuesScope, Issue, IssuesMap } from "~/api/issue"; import { QueryHookOptions } from "~/types/queries"; +const statusQuery = () => ({ + queryKey: ["status"], + queryFn: getStatus, +}); + const systemQuery = () => ({ queryKey: ["system"], queryFn: getSystem, }); +function useStatus(options?: QueryHookOptions): Status | null { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func(statusQuery()); + return data; +} + function useSystem(options?: QueryHookOptions): System | null { const func = options?.suspense ? useSuspenseQuery : useQuery; const { data } = func(systemQuery()); @@ -144,6 +157,15 @@ const useQuestionsChanges = () => { }, [client, queryClient]); }; +const useSelectedProduct = (options: QueryHookOptions = { suspense: true }) => { + const { products } = useSystem(options); + const { product } = useExtendedConfig(options); + + if (!product) return undefined; + + return products.find((p) => (p.id = product.id)); +}; + const storageModelQuery = () => ({ queryKey: ["storageModel"], queryFn: getStorageModel, @@ -231,6 +253,7 @@ export { issuesQuery, selectIssues, useSystem, + useStatus, useSystemChanges, useProposal, useProposalChanges, @@ -242,4 +265,5 @@ export { useIssues, useScopeIssues, useIssuesChanges, + useSelectedProduct, }; diff --git a/web/src/hooks/l10n.ts b/web/src/hooks/l10n.ts deleted file mode 100644 index 2b062d49c2..0000000000 --- a/web/src/hooks/l10n.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) [2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import { useSuspenseQuery, useQuery } from "@tanstack/react-query"; -import { System, l10n } from "~/api/system"; -import { QueryHookOptions } from "~/types/queries"; -import { systemQuery } from "~/hooks/api"; - -const selectSystem = (data: System | null): l10n.System | null => data?.l10n; - -function useSystem(options?: QueryHookOptions): l10n.System | null { - const func = options?.suspense ? useSuspenseQuery : useQuery; - const { data } = func({ - ...systemQuery(), - select: selectSystem, - }); - return data; -} - -export { useSystem };