diff --git a/service/lib/agama/software/callbacks/base.rb b/service/lib/agama/software/callbacks/base.rb new file mode 100644 index 0000000000..807b8d8f44 --- /dev/null +++ b/service/lib/agama/software/callbacks/base.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# 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 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" + +module Agama + module Software + module Callbacks + # Base class for libzypp callbacks for sharing some common texts. + class Base + include Yast::I18n + + # Constructor + def initialize + textdomain "agama" + end + + # label for the "retry" action + def retry_label + # TRANSLATORS: button label, try downloading the failed package again + _("Try again") + end + + # label for the "continue" action + def continue_label + # TRANSLATORS: button label, ignore the failed download, skip package installation + _("Continue anyway") + end + + # label for the "abort" action + def abort_label + # TRANSLATORS: button label, abort the installation completely after an error + _("Abort installation") + end + end + end + end +end diff --git a/service/lib/agama/software/callbacks/media.rb b/service/lib/agama/software/callbacks/media.rb index 6eb1ac8d1c..7cce627f6d 100644 --- a/service/lib/agama/software/callbacks/media.rb +++ b/service/lib/agama/software/callbacks/media.rb @@ -19,8 +19,10 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. +require "logger" require "yast" require "agama/question" +require "agama/software/callbacks/base" Yast.import "Pkg" @@ -28,14 +30,17 @@ module Agama module Software module Callbacks # Callbacks related to media handling - class Media + class Media < Base # Constructor # # @param questions_client [Agama::DBus::Clients::Questions] # @param logger [Logger] def initialize(questions_client, logger) + super() + + textdomain "agama" @questions_client = questions_client - @logger = logger + @logger = logger || ::Logger.new($stdout) end # Register the callbacks @@ -74,14 +79,14 @@ def media_change(error_code, error, url, product, current, current_label, wanted ) question = Agama::Question.new( - qclass: "software.medium_error", + qclass: "software.package_error.medium_error", text: error, - options: [:Retry, :Skip], - default_option: :Skip, + options: [retry_label.to_sym, continue_label.to_sym], + default_option: retry_label.to_sym, data: { "url" => url } ) questions_client.ask(question) do |question_client| - (question_client.answer == :Retry) ? "" : "S" + (question_client.answer == retry_label.to_sym) ? "" : "S" end end # rubocop:enable Metrics/ParameterLists diff --git a/service/lib/agama/software/callbacks/progress.rb b/service/lib/agama/software/callbacks/progress.rb index 84f13ad846..24686f2199 100644 --- a/service/lib/agama/software/callbacks/progress.rb +++ b/service/lib/agama/software/callbacks/progress.rb @@ -23,6 +23,7 @@ require "yast" require "agama/question" require "agama/dbus/clients/questions" +require "agama/software/callbacks/base" Yast.import "Pkg" @@ -30,9 +31,7 @@ module Agama module Software module Callbacks # This class represents the installer status - class Progress - include Yast::I18n - + class Progress < Base class << self def setup(pkg_count, progress, logger) new(pkg_count, progress, logger).setup @@ -40,6 +39,8 @@ def setup(pkg_count, progress, logger) end def initialize(pkg_count, progress, logger) + super() + textdomain "agama" @total = pkg_count @@ -88,24 +89,21 @@ def done_package(error_code, description) logger.error("Package #{current_package} failed: #{description}") - # TRANSLATORS: %s is a package name - text = _("Package %s could not be installed.") % current_package - question = Agama::Question.new( - qclass: "software.install_error", - text: text, - options: [:Retry, :Cancel, :Ignore], - default_option: :Retry, - data: { "description" => description } + qclass: "software.package_error.install_error", + text: description, + options: [retry_label.to_sym, continue_label.to_sym, abort_label.to_sym], + default_option: retry_label.to_sym, + data: { "package" => current_package } ) questions_client.ask(question) do |question_client| case question_client.answer - when :Retry + when retry_label.to_sym "R" - when :Cancel + when abort_label.to_sym "C" - when :Ignore + when continue_label.to_sym "I" else logger.error("Unexpected response #{question_client.answer.inspect}, " \ diff --git a/service/lib/agama/software/callbacks/provide.rb b/service/lib/agama/software/callbacks/provide.rb index eccbd635d4..870aa7328d 100644 --- a/service/lib/agama/software/callbacks/provide.rb +++ b/service/lib/agama/software/callbacks/provide.rb @@ -22,6 +22,7 @@ require "logger" require "yast" require "agama/question" +require "agama/software/callbacks/base" Yast.import "Pkg" @@ -29,9 +30,7 @@ module Agama module Software module Callbacks # Provide callbacks - class Provide - include Yast::I18n - + class Provide < Base # From https://github.com/openSUSE/libzypp/blob/d90a93fc2a248e6592bd98114f82a0b88abadb72/zypp/ZYppCallbacks.h#L111 NO_ERROR = 0 NOT_FOUND = 1 @@ -43,6 +42,8 @@ class Provide # @param questions_client [Agama::DBus::Clients::Questions] # @param logger [Logger] def initialize(questions_client, logger) + super() + textdomain "agama" @questions_client = questions_client @logger = logger || ::Logger.new($stdout) @@ -57,36 +58,37 @@ def setup # DoneProvide callback # - # @return [String] "I" for ignore, "R" for retry and "C" for abort (not implemented) + # @return [String, nil] "I" for ignore, "R" for retry and "C" for abort (not implemented) # @see https://github.com/yast/yast-yast2/blob/19180445ab935a25edd4ae0243aa7a3bcd09c9de/library/packages/src/modules/PackageCallbacks.rb#L620 def done_provide(error, reason, name) args = [error, reason, name] logger.debug "DoneProvide callback: #{args.inspect}" - message = case error + error_code = case error when NO_ERROR, NOT_FOUND # "Not found" (error 1) is handled by the MediaChange callback. nil when IO_ERROR - Yast::Builtins.sformat(_("Package %1 could not be downloaded (input/output error)."), - name) + "IO_ERROR" when INVALID - Yast::Builtins.sformat(_("Package %1 is broken, integrity check has failed."), name) + "INVALID" else logger.warn "DoneProvide: unknown error: '#{error}'" + nil end - return if message.nil? + return nil if error_code.nil? question = Agama::Question.new( - qclass: "software.provide_error", - text: message, - options: [:Retry, :Ignore], - default_option: :Retry, - data: { "reason" => reason } + qclass: "software.package_error.provide_error", + text: reason, + options: [retry_label.to_sym, continue_label.to_sym], + default_option: retry_label.to_sym, + data: { "package" => name, "error_code" => error_code } ) + questions_client.ask(question) do |question_client| - (question_client.answer == :Retry) ? "R" : "I" + (question_client.answer == retry_label.to_sym) ? "R" : "I" end end diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index ee1bd1f21c..ba666014d5 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Wed Feb 19 13:35:07 UTC 2025 - Ladislav Slezák + +- UX: Improve the libzypp callbacks (gh#agama-project/agama#1985) + ------------------------------------------------------------------- Tue Feb 18 17:19:08 UTC 2025 - Knut Anderssen diff --git a/service/test/agama/software/callbacks/media_test.rb b/service/test/agama/software/callbacks/media_test.rb index 1864ba4b6c..fdeb4b68f6 100644 --- a/service/test/agama/software/callbacks/media_test.rb +++ b/service/test/agama/software/callbacks/media_test.rb @@ -40,7 +40,7 @@ let(:question_client) { instance_double(Agama::DBus::Clients::Question) } context "when the user answers :Retry" do - let(:answer) { :Retry } + let(:answer) { subject.retry_label.to_sym } it "returns ''" do ret = subject.media_change( @@ -51,7 +51,7 @@ end context "when the user answers :Skip" do - let(:answer) { :Skip } + let(:answer) { subject.continue_label.to_sym } it "returns 'S'" do ret = subject.media_change( diff --git a/service/test/agama/software/callbacks/provide_test.rb b/service/test/agama/software/callbacks/provide_test.rb index 03146c34cb..fdb4a8920a 100644 --- a/service/test/agama/software/callbacks/provide_test.rb +++ b/service/test/agama/software/callbacks/provide_test.rb @@ -31,7 +31,7 @@ let(:logger) { Logger.new($stdout, level: :warn) } - let(:answer) { :Retry } + let(:answer) { subject.retry_label.to_sym } describe "#done_provide" do before do @@ -50,24 +50,26 @@ context "when the there is an I/O error" do it "registers a question informing of the error" do + reason = "could not be downloaded" expect(questions_client).to receive(:ask) do |q| - expect(q.text).to include("could not be downloaded") + expect(q.text).to include(reason) end - subject.done_provide(2, "Some dummy reason", "dummy-package") + subject.done_provide(2, reason, "dummy-package") end end context "when the there is an I/O error" do it "registers a question informing of the error" do + reason = "integrity check has failed" expect(questions_client).to receive(:ask) do |q| - expect(q.text).to include("integrity check has failed") + expect(q.text).to include(reason) end - subject.done_provide(3, "Some dummy reason", "dummy-package") + subject.done_provide(3, "integrity check has failed", "dummy-package") end end context "when the user answers :Retry" do - let(:answer) { :Retry } + let(:answer) { subject.retry_label.to_sym } it "returns 'R'" do ret = subject.done_provide( @@ -78,7 +80,7 @@ end context "when the user answers :Skip" do - let(:answer) { :Ignore } + let(:answer) { subject.continue_label.to_sym } it "returns 'I'" do ret = subject.done_provide( diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 18a80e7aab..da8f9db2e6 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Fri Feb 14 09:46:02 UTC 2025 - Ladislav Slezák + +- Implement specific question dialogs for the libzypp callbacks + (gh#agama-project/agama#1985) + ------------------------------------------------------------------- Fri Feb 7 14:43:08 UTC 2025 - Imobach Gonzalez Sosa diff --git a/web/src/components/questions/PackageErrorQuestion.test.tsx b/web/src/components/questions/PackageErrorQuestion.test.tsx new file mode 100644 index 0000000000..57a18ff7cf --- /dev/null +++ b/web/src/components/questions/PackageErrorQuestion.test.tsx @@ -0,0 +1,60 @@ +/* + * 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 React from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { Question } from "~/types/questions"; +import PackageErrorQuestion from "~/components/questions/PackageErrorQuestion"; + +const answerFn = jest.fn(); +const question: Question = { + id: 1, + class: "software.package_error.provide_error", + text: "Package download failed", + options: ["Retry", "Skip"], + defaultOption: "Retry", + data: { package: "foo", error_code: "INVALID" }, +}; + +const renderQuestion = () => + plainRender(); + +describe("PackageErrorQuestion", () => { + it("renders the question text", () => { + renderQuestion(); + + screen.queryByText(question.text); + }); + + describe("when the user clicks Retry", () => { + it("calls the callback with Retry value", async () => { + const { user } = renderQuestion(); + + const retryButton = await screen.findByRole("button", { name: "Retry" }); + await user.click(retryButton); + + expect(question).toEqual(expect.objectContaining({ answer: "Retry" })); + expect(answerFn).toHaveBeenCalledWith(question); + }); + }); +}); diff --git a/web/src/components/questions/PackageErrorQuestion.tsx b/web/src/components/questions/PackageErrorQuestion.tsx new file mode 100644 index 0000000000..867bacda9d --- /dev/null +++ b/web/src/components/questions/PackageErrorQuestion.tsx @@ -0,0 +1,78 @@ +/* + * 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 React from "react"; +import { Stack, Text } from "@patternfly/react-core"; +import { Popup } from "~/components/core"; +import { Icon } from "~/components/layout"; +import { AnswerCallback, Question } from "~/types/questions"; +import QuestionActions from "~/components/questions/QuestionActions"; +import { _ } from "~/i18n"; + +/** + * Component for rendering libzypp error callbacks + * + * @param question - the question to be answered + * @param answerCallback - the callback to be triggered on answer + */ +export default function PackageErrorQuestion({ + question, + answerCallback, +}: { + question: Question; + answerCallback: AnswerCallback; +}): React.ReactNode { + const actionCallback = (option: string) => { + question.answer = option; + answerCallback(question); + }; + + const warning = + question.class === "software.package_error.provide_error" && + question.data.error_code === "INVALID" + ? // TRANSLATORS: a special warning message for installing broken package + _("Installing a broken package affects system stability and is a big security risk!") + : // TRANSLATORS: a generic warning message, consequences of skipping a package installation + _( + "Continuing without installing the package can result in a broken system. In some cases the system might not even boot.", + ); + + return ( + } + > + + {question.text} + {warning} + + + + + + ); +} diff --git a/web/src/components/questions/Questions.tsx b/web/src/components/questions/Questions.tsx index a8cbb47706..28636e2f73 100644 --- a/web/src/components/questions/Questions.tsx +++ b/web/src/components/questions/Questions.tsx @@ -24,6 +24,7 @@ import React from "react"; import GenericQuestion from "~/components/questions/GenericQuestion"; import QuestionWithPassword from "~/components/questions/QuestionWithPassword"; import LuksActivationQuestion from "~/components/questions/LuksActivationQuestion"; +import PackageErrorQuestion from "~/components/questions/PackageErrorQuestion"; import UnsupportedAutoYaST from "~/components/questions/UnsupportedAutoYaST"; import { useQuestions, useQuestionsConfig, useQuestionsChanges } from "~/queries/questions"; import { AnswerCallback, QuestionType } from "~/types/questions"; @@ -58,5 +59,10 @@ export default function Questions(): React.ReactNode { QuestionComponent = UnsupportedAutoYaST; } + // special popup for package errors (libzypp callbacks) + if (currentQuestion.class?.startsWith("software.package_error.")) { + QuestionComponent = PackageErrorQuestion; + } + return ; }