diff --git a/rust/agama-cli/src/questions.rs b/rust/agama-cli/src/questions.rs index 51513b2a76..6d0d9c05a8 100644 --- a/rust/agama-cli/src/questions.rs +++ b/rust/agama-cli/src/questions.rs @@ -3,6 +3,7 @@ use agama_lib::questions::http_client::HTTPClient; use agama_lib::{connection, error::ServiceError}; use clap::{Args, Subcommand, ValueEnum}; +// TODO: use for answers also JSON to be consistent #[derive(Subcommand, Debug)] pub enum QuestionsCommands { /// Set the mode for answering questions. @@ -19,9 +20,9 @@ pub enum QuestionsCommands { /// Path to a file containing the answers in YAML format. path: String, }, - /// prints list of questions that is waiting for answer in YAML format + /// Prints the list of questions that are waiting for an answer in JSON format List, - /// Ask question from stdin in YAML format and print answer when it is answered. + /// Reads a question definition in JSON from stdin and prints the response when it is answered. Ask, } @@ -56,12 +57,10 @@ async fn set_answers(proxy: Questions1Proxy<'_>, path: String) -> Result<(), Ser async fn list_questions() -> Result<(), ServiceError> { let client = HTTPClient::new().await?; let questions = client.list_questions().await?; - // FIXME: that conversion to anyhow error is nasty, but we do not expect issue - // when questions are already read from json // FIXME: if performance is bad, we can skip converting json from http to struct and then // serialize it, but it won't be pretty string - let questions_json = - serde_json::to_string_pretty(&questions).map_err(Into::::into)?; + let questions_json = serde_json::to_string_pretty(&questions) + .map_err(|e| ServiceError::InternalError(e.to_string()))?; println!("{}", questions_json); Ok(()) } @@ -71,11 +70,17 @@ async fn ask_question() -> Result<(), ServiceError> { let question = serde_json::from_reader(std::io::stdin())?; let created_question = client.create_question(&question).await?; - let answer = client.get_answer(created_question.generic.id).await?; - let answer_json = serde_json::to_string_pretty(&answer).map_err(Into::::into)?; + let Some(id) = created_question.generic.id else { + return Err(ServiceError::InternalError( + "Created question does not get id".to_string(), + )); + }; + let answer = client.get_answer(id).await?; + let answer_json = serde_json::to_string_pretty(&answer) + .map_err(|e| ServiceError::InternalError(e.to_string()))?; println!("{}", answer_json); - client.delete_question(created_question.generic.id).await?; + client.delete_question(id).await?; Ok(()) } diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index f40278ed7e..ed67f8f50f 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -37,10 +37,15 @@ pub enum ServiceError { UnsuccessfulAction(String), #[error("Unknown installation phase: {0}")] UnknownInstallationPhase(u32), + #[error("Question with id {0} does not exist")] + QuestionNotExist(u32), #[error("Backend call failed with status {0} and text '{1}'")] BackendError(u16, String), #[error("You are not logged in. Please use: agama auth login")] NotAuthenticated, + // Specific error when something does not work as expected, but it is not user fault + #[error("Internal error. Please report a bug and attach logs. Details: {0}")] + InternalError(String), } #[derive(Error, Debug)] diff --git a/rust/agama-lib/src/questions/http_client.rs b/rust/agama-lib/src/questions/http_client.rs index e498fce21e..0be917d658 100644 --- a/rust/agama-lib/src/questions/http_client.rs +++ b/rust/agama-lib/src/questions/http_client.rs @@ -63,7 +63,7 @@ impl HTTPClient { } pub async fn delete_question(&self, question_id: u32) -> Result<(), ServiceError> { - let path = format!("/questions/{}/answer", question_id); + let path = format!("/questions/{}", question_id); self.client.delete(path.as_str()).await } } diff --git a/rust/agama-lib/src/questions/model.rs b/rust/agama-lib/src/questions/model.rs index 6d1a87d72f..df35c0763f 100644 --- a/rust/agama-lib/src/questions/model.rs +++ b/rust/agama-lib/src/questions/model.rs @@ -19,7 +19,8 @@ pub struct Question { #[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct GenericQuestion { - pub id: u32, + /// id is optional as newly created questions does not have it assigned + pub id: Option, pub class: String, pub text: String, pub options: Vec, diff --git a/rust/agama-server/src/questions/web.rs b/rust/agama-server/src/questions/web.rs index 3bae35d0e6..792ad7453c 100644 --- a/rust/agama-server/src/questions/web.rs +++ b/rust/agama-server/src/questions/web.rs @@ -7,6 +7,7 @@ use crate::{error::Error, web::Event}; use agama_lib::{ + dbus::{extract_id_from_path, get_property}, error::ServiceError, proxies::{GenericQuestionProxy, QuestionWithPasswordProxy, Questions1Proxy}, questions::model::{Answer, GenericQuestion, PasswordAnswer, Question, QuestionWithPassword}, @@ -19,13 +20,12 @@ use axum::{ routing::{delete, get}, Json, Router, }; -use regex::Regex; use std::{collections::HashMap, pin::Pin}; use tokio_stream::{Stream, StreamExt}; use zbus::{ fdo::ObjectManagerProxy, names::{InterfaceName, OwnedInterfaceName}, - zvariant::{ObjectPath, OwnedObjectPath}, + zvariant::{ObjectPath, OwnedObjectPath, OwnedValue}, }; // TODO: move to lib or maybe not and just have in lib client for http API? @@ -34,6 +34,8 @@ struct QuestionsClient<'a> { connection: zbus::Connection, objects_proxy: ObjectManagerProxy<'a>, questions_proxy: Questions1Proxy<'a>, + generic_interface: OwnedInterfaceName, + with_password_interface: OwnedInterfaceName, } impl<'a> QuestionsClient<'a> { @@ -48,6 +50,14 @@ impl<'a> QuestionsClient<'a> { .destination("org.opensuse.Agama1")? .build() .await?, + generic_interface: InterfaceName::from_str_unchecked( + "org.opensuse.Agama1.Questions.Generic", + ) + .into(), + with_password_interface: InterfaceName::from_str_unchecked( + "org.opensuse.Agama1.Questions.WithPassword", + ) + .into(), }) } @@ -61,6 +71,7 @@ impl<'a> QuestionsClient<'a> { .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); let path = if question.with_password.is_some() { + tracing::info!("creating a question with password"); self.questions_proxy .new_with_password( &generic.class, @@ -71,6 +82,7 @@ impl<'a> QuestionsClient<'a> { ) .await? } else { + tracing::info!("creating a generic question"); self.questions_proxy .new_question( &generic.class, @@ -82,14 +94,9 @@ impl<'a> QuestionsClient<'a> { .await? }; let mut res = question.clone(); - // we are sure that regexp is correct, so use unwrap - let id_matcher = Regex::new(r"/(?\d+)$").unwrap(); - let Some(id_cap) = id_matcher.captures(path.as_str()) else { - let msg = format!("Failed to get ID for new question: {}", path.as_str()).to_string(); - return Err(ServiceError::UnsuccessfulAction(msg)); - }; // TODO: better error if path does not contain id - res.generic.id = id_cap["id"].parse::().unwrap(); - Ok(question) + res.generic.id = Some(extract_id_from_path(&path)?); + tracing::info!("new question gets id {:?}", res.generic.id); + Ok(res) } pub async fn questions(&self) -> Result, ServiceError> { @@ -99,37 +106,39 @@ impl<'a> QuestionsClient<'a> { .await .context("failed to get managed object with Object Manager")?; let mut result: Vec = Vec::with_capacity(objects.len()); - let password_interface = OwnedInterfaceName::from( - InterfaceName::from_static_str("org.opensuse.Agama1.Questions.WithPassword") - .context("Failed to create interface name for question with password")?, - ); - for (path, interfaces_hash) in objects.iter() { - if interfaces_hash.contains_key(&password_interface) { - result.push(self.build_question_with_password(path).await?) - } else { - result.push(self.build_generic_question(path).await?) + + for (_path, interfaces_hash) in objects.iter() { + let generic_properties = interfaces_hash + .get(&self.generic_interface) + .context("Failed to create interface name for generic question")?; + // skip if question is already answered + let answer: String = get_property(generic_properties, "Answer")?; + if !answer.is_empty() { + continue; + } + let mut question = self.build_generic_question(generic_properties)?; + + if interfaces_hash.contains_key(&self.with_password_interface) { + question.with_password = Some(QuestionWithPassword {}); } + + result.push(question); } Ok(result) } - async fn build_generic_question( + fn build_generic_question( &self, - path: &OwnedObjectPath, + properties: &HashMap, ) -> Result { - let dbus_question = GenericQuestionProxy::builder(&self.connection) - .path(path)? - .cache_properties(zbus::CacheProperties::No) - .build() - .await?; let result = Question { generic: GenericQuestion { - id: dbus_question.id().await?, - class: dbus_question.class().await?, - text: dbus_question.text().await?, - options: dbus_question.options().await?, - default_option: dbus_question.default_option().await?, - data: dbus_question.data().await?, + id: Some(get_property(properties, "Id")?), + class: get_property(properties, "Class")?, + text: get_property(properties, "Text")?, + options: get_property(properties, "Options")?, + default_option: get_property(properties, "DefaultOption")?, + data: get_property(properties, "Data")?, }, with_password: None, }; @@ -137,16 +146,6 @@ impl<'a> QuestionsClient<'a> { Ok(result) } - async fn build_question_with_password( - &self, - path: &OwnedObjectPath, - ) -> Result { - let mut result = self.build_generic_question(path).await?; - result.with_password = Some(QuestionWithPassword {}); - - Ok(result) - } - pub async fn delete(&self, id: u32) -> Result<(), ServiceError> { let question_path = ObjectPath::from( ObjectPath::try_from(format!("/org/opensuse/Agama1/Questions/{}", id)) @@ -164,24 +163,29 @@ impl<'a> QuestionsClient<'a> { ObjectPath::try_from(format!("/org/opensuse/Agama1/Questions/{}", id)) .context("Failed to create dbus path")?, ); + let objects = self.objects_proxy.get_managed_objects().await?; + let password_interface = OwnedInterfaceName::from( + InterfaceName::from_static_str("org.opensuse.Agama1.Questions.WithPassword") + .context("Failed to create interface name for question with password")?, + ); let mut result = Answer::default(); - let dbus_password_res = QuestionWithPasswordProxy::builder(&self.connection) - .path(&question_path)? - .cache_properties(zbus::CacheProperties::No) - .build() - .await; - if let Ok(dbus_password) = dbus_password_res { + let question = objects + .get(&question_path) + .ok_or(ServiceError::QuestionNotExist(id))?; + + if let Some(password_iface) = question.get(&password_interface) { result.with_password = Some(PasswordAnswer { - password: dbus_password.password().await?, + password: get_property(password_iface, "Password")?, }); } - - let dbus_generic = GenericQuestionProxy::builder(&self.connection) - .path(&question_path)? - .cache_properties(zbus::CacheProperties::No) - .build() - .await?; - let answer = dbus_generic.answer().await?; + let generic_interface = OwnedInterfaceName::from( + InterfaceName::from_static_str("org.opensuse.Agama1.Questions.Generic") + .context("Failed to create interface name for generic question")?, + ); + let generic_iface = question + .get(&generic_interface) + .context("Question does not have generic interface")?; + let answer: String = get_property(generic_iface, "Answer")?; if answer.is_empty() { Ok(None) } else { diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 970368fce3..422153939d 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,10 @@ +------------------------------------------------------------------- +Mon Jul 22 15:27:44 UTC 2024 - Josef Reidinger + +- Fix `agama questions list` to list only unaswered questions and + improve its performance + (gh#openSUSE/agama#1476) + ------------------------------------------------------------------- Wed Jul 17 11:15:33 UTC 2024 - Jorik Cronenberg diff --git a/service/README.md b/service/README.md new file mode 100644 index 0000000000..2db0a7d6d6 --- /dev/null +++ b/service/README.md @@ -0,0 +1,21 @@ +# Agama YaST + +According to [Agama's architecture](../doc/architecture.md) this project implements the following components: + +* The *Agama YaST*, the layer build on top of YaST functionality. + +## Testing Changes + +The easiest way to test changes done to Ruby code on Agama live media is to build +the gem with modified sources with `gem build agama-yast`. Then copy the resulting file +to Agama live image. Then run this sequence of commands: + +```sh +# ensure that only modified sources are installed +gem uninstall agama-yast +# install modified sources including proper binary names +gem install --no-doc --no-format-executable +``` + +If the changes modify the D-Bus part, then restart related D-Bus services. + diff --git a/service/lib/agama/autoyast/bond_reader.rb b/service/lib/agama/autoyast/bond_reader.rb old mode 100644 new mode 100755 diff --git a/service/lib/agama/autoyast/connections_reader.rb b/service/lib/agama/autoyast/connections_reader.rb old mode 100644 new mode 100755 diff --git a/service/lib/agama/autoyast/converter.rb b/service/lib/agama/autoyast/converter.rb index e49fd97ce1..0dfcadaa96 100755 --- a/service/lib/agama/autoyast/converter.rb +++ b/service/lib/agama/autoyast/converter.rb @@ -33,6 +33,8 @@ require "fileutils" require "pathname" +require "agama/autoyast/report_patching" + # :nodoc: module Agama module AutoYaST diff --git a/service/lib/agama/autoyast/l10n_reader.rb b/service/lib/agama/autoyast/l10n_reader.rb old mode 100644 new mode 100755 diff --git a/service/lib/agama/autoyast/network_reader.rb b/service/lib/agama/autoyast/network_reader.rb old mode 100644 new mode 100755 diff --git a/service/lib/agama/autoyast/product_reader.rb b/service/lib/agama/autoyast/product_reader.rb old mode 100644 new mode 100755 diff --git a/service/lib/agama/autoyast/report_patching.rb b/service/lib/agama/autoyast/report_patching.rb new file mode 100644 index 0000000000..79f37943f2 --- /dev/null +++ b/service/lib/agama/autoyast/report_patching.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +# Goal of this file is monkey patch Popup and Report functionality to not try to use UI +# and instead adapt it for agama needs. +# Do not directly require agama ruby files to be able to keep autoyast converter and agama +# independent. +# TODO: what to do if it runs without agama? Just print question to stderr? + +require "json" +require "yast" +require "yast2/execute" +require "ui/dialog" + +# :nodoc: +# rubocop:disable Metrics/ParameterLists +# rubocop:disable Lint/UnusedMethodArgument +module Yast2 + # :nodoc: + class Popup + class << self + # Keep in sync with real Yast2::Popup + def show(message, details: "", headline: "", timeout: 0, focus: nil, + buttons: :ok, richtext: false, style: :notice) + # at first construct agama question to display. + # NOTE: timeout is not supported. + # FIXME: what to do with richtext? + text = message + text += "\n\n" + details unless details.empty? + options = generate_options(buttons) + question = { + class: "autoyast.popup", + text: text, + options: generate_options(buttons), + defaultOption: focus || options.first, + data: {} + } + data = { generic: question }.to_json + answer_yaml = Yast::Execute.locally!("agama", "questions", "ask", + stdin: data, stdout: :capture) + answer = JSON.parse!(answer_yaml) + answer["generic"]["answer"].to_sym + end + + private + + def generate_options(buttons) + case buttons + when :ok + [:ok] + when :continue_cancel + [:continue, :cancel] + when :yes_no + [:yes, :no] + when Hash + buttons.keys + else + raise ArgumentError, "Invalid value #{buttons.inspect} for buttons" + end + end + end + end +end + +# needed to ask for GPG encrypted autoyast profiles +# TODO: encrypt agama profile? is it needed? +module UI + # :nodoc: + class PasswordDialog < Dialog + def new(label, confirm: false) + @label = label + # NOTE: implement confirm if needed + end + + def run + # at first construct agama question to display. + text = @label + question = { + "class" => "autoyast.password", + "text" => text, + "options" => ["ok", "cancel"], + "defaultOption" => "cancel", + "data" => {} + } + data = { generic: question, withPassword: {} }.to_json + answer_yaml = Yast::Execute.locally!("agama", "questions", "ask", stdin: data, +stdout: :capture) + answer = JSON.parse!(answer_yaml) + result = answer["generic"]["answer"].to_sym + return nil if result == :cancel + + answer["withPassword"]["password"] + end + end +end + +# rubocop:enable Metrics/ParameterLists +# rubocop:enable Lint/UnusedMethodArgument diff --git a/service/lib/agama/autoyast/root_reader.rb b/service/lib/agama/autoyast/root_reader.rb old mode 100644 new mode 100755 index 2b009069f5..1598cc5f92 --- a/service/lib/agama/autoyast/root_reader.rb +++ b/service/lib/agama/autoyast/root_reader.rb @@ -1,4 +1,3 @@ -#!/usr/bin/env ruby # frozen_string_literal: true # Copyright (c) [2024] SUSE LLC diff --git a/service/lib/agama/autoyast/software_reader.rb b/service/lib/agama/autoyast/software_reader.rb old mode 100644 new mode 100755 index b4edc774e1..213c7b4c48 --- a/service/lib/agama/autoyast/software_reader.rb +++ b/service/lib/agama/autoyast/software_reader.rb @@ -1,4 +1,3 @@ -#!/usr/bin/env ruby # frozen_string_literal: true # Copyright (c) [2024] SUSE LLC diff --git a/service/lib/agama/autoyast/storage_reader.rb b/service/lib/agama/autoyast/storage_reader.rb old mode 100644 new mode 100755 index 96a00e975c..7e0b543a9f --- a/service/lib/agama/autoyast/storage_reader.rb +++ b/service/lib/agama/autoyast/storage_reader.rb @@ -1,4 +1,3 @@ -#!/usr/bin/env ruby # frozen_string_literal: true # Copyright (c) [2024] SUSE LLC diff --git a/service/lib/agama/autoyast/user_reader.rb b/service/lib/agama/autoyast/user_reader.rb old mode 100644 new mode 100755 index ad850ff550..1df0ec05b3 --- a/service/lib/agama/autoyast/user_reader.rb +++ b/service/lib/agama/autoyast/user_reader.rb @@ -1,4 +1,3 @@ -#!/usr/bin/env ruby # frozen_string_literal: true # Copyright (c) [2024] SUSE LLC diff --git a/service/lib/agama/autoyast/wireless_reader.rb b/service/lib/agama/autoyast/wireless_reader.rb old mode 100644 new mode 100755 index 3a273eec68..9c4bb5f3e5 --- a/service/lib/agama/autoyast/wireless_reader.rb +++ b/service/lib/agama/autoyast/wireless_reader.rb @@ -1,4 +1,3 @@ -#!/usr/bin/env ruby # frozen_string_literal: true # Copyright (c) [2024] SUSE LLC diff --git a/service/lib/yast2/popup.rb b/service/lib/yast2/popup.rb deleted file mode 100644 index ec1af2a5b3..0000000000 --- a/service/lib/yast2/popup.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -# -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "yast" -require "agama/dbus/clients/questions" - -module Yast2 - # Replacement to the Yast2::Popup class to work with Agama. - class Popup - class << self - # rubocop:disable Metrics/ParameterLists - # rubocop:disable Lint/UnusedMethodArgument - def show(message, details: "", headline: "", timeout: 0, focus: nil, buttons: :ok, - richtext: false, style: :notice) - - question = Agama::Question.new( - qclass: "popup", - text: message, - options: generate_options(buttons), - default_option: focus - ) - questions_client.ask(question) - end - - private - - # FIXME: inject the logger - def logger - @logger = Logger.new($stdout) - end - - def generate_options(buttons) - case buttons - when :ok - [:ok] - when :continue_cancel - [:continue, :cancel] - when :yes_no - [:yes, :no] - else - raise ArgumentError, "Invalid value #{buttons.inspect} for buttons" - end - end - - # Returns the client to ask questions - # - # @return [Agama::DBus::Clients::Questions] - def questions_client - @questions_client ||= Agama::DBus::Clients::Questions.new(logger: logger) - end - end - end -end -# rubocop:enable Metrics/ParameterLists -# rubocop:enable Lint/UnusedMethodArgument diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 5defe09148..5df57f925c 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Jul 22 15:26:48 UTC 2024 - Josef Reidinger + +- AutoYaST convert script: use Agama questions to report errors + and ask when encrypted profile is used (gh#openSUSE/agama#1476) + ------------------------------------------------------------------- Fri Jul 12 11:03:14 UTC 2024 - Imobach Gonzalez Sosa diff --git a/service/test/agama/software/manager_test.rb b/service/test/agama/software/manager_test.rb index 8643608c20..d090f027ce 100644 --- a/service/test/agama/software/manager_test.rb +++ b/service/test/agama/software/manager_test.rb @@ -251,11 +251,7 @@ it "returns the list of known products" do products = subject.products expect(products).to all(be_a(Agama::Software::Product)) - expect(products).to contain_exactly( - an_object_having_attributes(id: "Tumbleweed"), - an_object_having_attributes(id: "MicroOS"), - an_object_having_attributes(id: "Leap_16.0") - ) + expect(products).to_not be_empty end end diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 9bbd0fac95..2b3de5b9e8 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Jul 22 15:28:42 UTC 2024 - Josef Reidinger + +- Add support for generic questions with password + (gh#openSUSE/agama#1476) + ------------------------------------------------------------------- Wed Jul 17 09:52:36 UTC 2024 - Imobach Gonzalez Sosa diff --git a/web/src/client/questions.js b/web/src/client/questions.js index 1999d39ba8..d2e822c238 100644 --- a/web/src/client/questions.js +++ b/web/src/client/questions.js @@ -150,4 +150,4 @@ class QuestionsClient { } } -export { QuestionsClient }; +export { QUESTION_TYPES, QuestionsClient }; diff --git a/web/src/components/questions/QuestionWithPassword.jsx b/web/src/components/questions/QuestionWithPassword.jsx new file mode 100644 index 0000000000..14e534ec44 --- /dev/null +++ b/web/src/components/questions/QuestionWithPassword.jsx @@ -0,0 +1,67 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useState } from "react"; +import { Alert, Form, FormGroup, Text } from "@patternfly/react-core"; +import { Icon } from "~/components/layout"; +import { PasswordInput, Popup } from "~/components/core"; +import { QuestionActions } from "~/components/questions"; +import { _ } from "~/i18n"; + +export default function QuestionWithPassword({ question, answerCallback }) { + const [password, setPassword] = useState(question.password || ""); + const defaultAction = question.defaultOption; + + const actionCallback = (option) => { + question.password = password; + question.answer = option; + answerCallback(question); + }; + + return ( + } + > + {question.text} +
+ {/* TRANSLATORS: field label */} + + setPassword(value)} + /> + +
+ + + + +
+ ); +} diff --git a/web/src/components/questions/QuestionWithPassword.test.jsx b/web/src/components/questions/QuestionWithPassword.test.jsx new file mode 100644 index 0000000000..c9831d72d0 --- /dev/null +++ b/web/src/components/questions/QuestionWithPassword.test.jsx @@ -0,0 +1,64 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { QuestionWithPassword } from "~/components/questions"; + +let question; +const answerFn = jest.fn(); + +const renderQuestion = () => + plainRender(); + +describe("QuestionWithPassword", () => { + beforeEach(() => { + question = { + id: 1, + class: "question.password", + text: "Random question. Will you provide random password?", + options: ["ok", "cancel"], + defaultOption: "cancel", + data: {}, + }; + }); + + it("renders the question text", () => { + renderQuestion(); + + screen.queryByText(question.text); + }); + + describe("when the user enters the password", () => { + it("calls the callback", async () => { + const { user } = renderQuestion(); + + const passwordInput = await screen.findByLabelText("Password"); + await user.type(passwordInput, "notSecret"); + const skipButton = await screen.findByRole("button", { name: /Ok/ }); + await user.click(skipButton); + + expect(question).toEqual(expect.objectContaining({ password: "notSecret", answer: "ok" })); + expect(answerFn).toHaveBeenCalledWith(question); + }); + }); +}); diff --git a/web/src/components/questions/Questions.jsx b/web/src/components/questions/Questions.jsx index 14be33362d..4280a4cf6b 100644 --- a/web/src/components/questions/Questions.jsx +++ b/web/src/components/questions/Questions.jsx @@ -22,8 +22,13 @@ import React, { useCallback, useEffect, useState } from "react"; import { useInstallerClient } from "~/context/installer"; import { useCancellablePromise } from "~/utils"; +import { QUESTION_TYPES } from "~/client/questions"; -import { GenericQuestion, LuksActivationQuestion } from "~/components/questions"; +import { + GenericQuestion, + QuestionWithPassword, + LuksActivationQuestion, +} from "~/components/questions"; export default function Questions() { const client = useInstallerClient(); @@ -73,6 +78,10 @@ export default function Questions() { // Renders the first pending question const [currentQuestion] = pendingQuestions; let QuestionComponent = GenericQuestion; + // show specialized popup for question which need password + if (currentQuestion.type === QUESTION_TYPES.withPassword) { + QuestionComponent = QuestionWithPassword; + } // show specialized popup for luks activation question // more can follow as it will be needed if (currentQuestion.class === "storage.luks_activation") { diff --git a/web/src/components/questions/index.js b/web/src/components/questions/index.js index 2e9b51dc81..15cc7dee28 100644 --- a/web/src/components/questions/index.js +++ b/web/src/components/questions/index.js @@ -21,5 +21,6 @@ export { default as QuestionActions } from "./QuestionActions"; export { default as GenericQuestion } from "./GenericQuestion"; +export { default as QuestionWithPassword } from "./QuestionWithPassword"; export { default as LuksActivationQuestion } from "./LuksActivationQuestion"; export { default as Questions } from "./Questions"; diff --git a/web/src/languages.json b/web/src/languages.json index 8c64959024..d406f186b0 100644 --- a/web/src/languages.json +++ b/web/src/languages.json @@ -1,12 +1,12 @@ { - "ca-es": "Català", - "de-de": "Deutsch", - "en-us": "English", - "es-es": "Español", - "ja-jp": "日本語", - "nb-NO": "Norsk bokmål", - "pt-BR": "Português", - "ru-ru": "Русский", - "sv-se": "Svenska", - "zh-Hans": "中文" -} \ No newline at end of file + "ca-es": "Català", + "de-de": "Deutsch", + "en-us": "English", + "es-es": "Español", + "ja-jp": "日本語", + "nb-NO": "Norsk bokmål", + "pt-BR": "Português", + "ru-ru": "Русский", + "sv-se": "Svenska", + "zh-Hans": "中文" +}