diff --git a/.github/workflows/ci-web.yml b/.github/workflows/ci-web.yml
index 6b15945bf1..a3c448f9be 100644
--- a/.github/workflows/ci-web.yml
+++ b/.github/workflows/ci-web.yml
@@ -74,7 +74,7 @@ jobs:
run: npm run stylelint
- name: Run the tests and generate coverage report
- run: npm test -- --coverage
+ run: npm test -- --coverage --silent
#
# # send the code coverage for the web part to the coveralls.io
# - name: Coveralls GitHub Action
diff --git a/doc/dbus/bus/org.opensuse.Agama.Users1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Users1.bus.xml
index 1d0b0d89de..c5e07f0f2d 100644
--- a/doc/dbus/bus/org.opensuse.Agama.Users1.bus.xml
+++ b/doc/dbus/bus/org.opensuse.Agama.Users1.bus.xml
@@ -57,12 +57,7 @@
-
-
-
-
-
-
-
+
+
diff --git a/doc/dbus/bus/org.opensuse.Agama1.ServiceStatus.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.ServiceStatus.bus.xml
deleted file mode 120000
index 30d354ffd2..0000000000
--- a/doc/dbus/bus/org.opensuse.Agama1.ServiceStatus.bus.xml
+++ /dev/null
@@ -1 +0,0 @@
-org.opensuse.Agama.Users1.bus.xml
\ No newline at end of file
diff --git a/doc/dbus/bus/org.opensuse.Agama1.Validation.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Validation.bus.xml
deleted file mode 120000
index 30d354ffd2..0000000000
--- a/doc/dbus/bus/org.opensuse.Agama1.Validation.bus.xml
+++ /dev/null
@@ -1 +0,0 @@
-org.opensuse.Agama.Users1.bus.xml
\ No newline at end of file
diff --git a/doc/dbus/org.opensuse.Agama1.ServiceStatus.doc.xml b/doc/dbus/org.opensuse.Agama1.ServiceStatus.doc.xml
deleted file mode 100644
index 10840ebefc..0000000000
--- a/doc/dbus/org.opensuse.Agama1.ServiceStatus.doc.xml
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/doc/dbus/org.opensuse.Agama1.Validation.doc.xml b/doc/dbus/org.opensuse.Agama1.Validation.doc.xml
deleted file mode 100644
index 105cb12c6e..0000000000
--- a/doc/dbus/org.opensuse.Agama1.Validation.doc.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/doc/dbus_api.md b/doc/dbus_api.md
index 807c83f24c..577e1fc12a 100644
--- a/doc/dbus_api.md
+++ b/doc/dbus_api.md
@@ -71,7 +71,6 @@ Service for managing storage devices.
.ObjectManager
.Agama1.ServiceStatus
.Agama1.Progress
- .Agama1.Validation
.Agama.Storage1
.Agama.Storage1.Proposal.Calculator
.Agama.Storage1.ISCSI.Initiator
@@ -96,7 +95,6 @@ Service for managing storage devices.
.ObjectManager
.Agama1.ServiceStatus
.Agama1.Progress
- .Agama1.Validation
.Agama.Storage1
.Agama.Storage1.Proposal.Calculator
.Agama.Storage1.ISCSI.Initiator
diff --git a/rust/agama-lib/src/proxies.rs b/rust/agama-lib/src/proxies.rs
index d33efa6921..5a64753915 100644
--- a/rust/agama-lib/src/proxies.rs
+++ b/rust/agama-lib/src/proxies.rs
@@ -178,14 +178,3 @@ trait Issues {
#[dbus_proxy(property)]
fn all(&self) -> zbus::Result>;
}
-
-#[dbus_proxy(interface = "org.opensuse.Agama1.Validation", assume_defaults = true)]
-trait Validation {
- /// Errors property
- #[dbus_proxy(property)]
- fn errors(&self) -> zbus::Result>;
-
- /// Valid property
- #[dbus_proxy(property)]
- fn valid(&self) -> zbus::Result;
-}
diff --git a/rust/agama-server/src/network/web.rs b/rust/agama-server/src/network/web.rs
index 5cc6744625..6375ff45d0 100644
--- a/rust/agama-server/src/network/web.rs
+++ b/rust/agama-server/src/network/web.rs
@@ -315,6 +315,12 @@ async fn connect(
.await
.map_err(|_| NetworkError::CannotApplyConfig)?;
+ state
+ .network
+ .apply()
+ .await
+ .map_err(|_| NetworkError::CannotApplyConfig)?;
+
Ok(StatusCode::NO_CONTENT)
}
@@ -341,6 +347,12 @@ async fn disconnect(
.await
.map_err(|_| NetworkError::CannotApplyConfig)?;
+ state
+ .network
+ .apply()
+ .await
+ .map_err(|_| NetworkError::CannotApplyConfig)?;
+
Ok(StatusCode::NO_CONTENT)
}
diff --git a/rust/agama-server/src/users/web.rs b/rust/agama-server/src/users/web.rs
index ea5aaf2909..487c51add2 100644
--- a/rust/agama-server/src/users/web.rs
+++ b/rust/agama-server/src/users/web.rs
@@ -7,7 +7,7 @@
use crate::{
error::Error,
web::{
- common::{service_status_router, validation_router, EventStreams},
+ common::{issues_router, service_status_router, EventStreams},
Event,
},
};
@@ -125,7 +125,7 @@ pub async fn users_service(dbus: zbus::Connection) -> Result Result Res
)
.await?,
);
+ stream.insert(
+ "users-issues",
+ issues_stream(
+ dbus.clone(),
+ "org.opensuse.Agama.Manager1",
+ "/org/opensuse/Agama/Users1",
+ )
+ .await?,
+ );
tokio::pin!(stream);
let e = events.clone();
diff --git a/rust/agama-server/src/web/common.rs b/rust/agama-server/src/web/common.rs
index 06da4f88e5..b7e88d0686 100644
--- a/rust/agama-server/src/web/common.rs
+++ b/rust/agama-server/src/web/common.rs
@@ -5,7 +5,7 @@ use std::{pin::Pin, task::Poll};
use agama_lib::{
error::ServiceError,
progress::Progress,
- proxies::{IssuesProxy, ProgressProxy, ServiceStatusProxy, ValidationProxy},
+ proxies::{IssuesProxy, ProgressProxy, ServiceStatusProxy},
};
use axum::{extract::State, routing::get, Json, Router};
use pin_project::pin_project;
@@ -354,108 +354,3 @@ async fn build_issues_proxy<'a>(
.await?;
Ok(proxy)
}
-
-/// Builds a router to the `org.opensuse.Agama1.Validation` interface of a given
-/// D-Bus object.
-///
-/// ```no_run
-/// # use axum::{extract::State, routing::get, Json, Router};
-/// # use agama_lib::connection;
-/// # use agama_server::web::common::validation_router;
-/// # use tokio_test;
-///
-/// # tokio_test::block_on(async {
-/// async fn hello(state: State) {};
-///
-/// #[derive(Clone)]
-/// struct HelloWorldState {};
-///
-/// let dbus = connection().await.unwrap();
-/// let validation_routes = validation_router(
-/// &dbus, "org.opensuse.HelloWorld", "/org/opensuse/hello"
-/// ).await.unwrap();
-/// let router: Router = Router::new()
-/// .route("/hello", get(hello))
-/// .merge(validation_routes)
-/// .with_state(HelloWorldState {});
-/// });
-/// ```
-///
-/// * `dbus`: D-Bus connection.
-/// * `destination`: D-Bus service name.
-/// * `path`: D-Bus object path.
-pub async fn validation_router(
- dbus: &zbus::Connection,
- destination: &str,
- path: &str,
-) -> Result, ServiceError> {
- let proxy = build_validation_proxy(dbus, destination, path).await?;
- let state = ValidationState { proxy };
- Ok(Router::new()
- .route("/validation", get(validation))
- .with_state(state))
-}
-
-#[derive(Clone, Serialize, utoipa::ToSchema)]
-pub struct ValidationResult {
- valid: bool,
- errors: Vec,
-}
-
-async fn validation(
- State(state): State>,
-) -> Result, Error> {
- let validation = ValidationResult {
- valid: state.proxy.valid().await?,
- errors: state.proxy.errors().await?,
- };
- Ok(Json(validation))
-}
-
-#[derive(Clone)]
-struct ValidationState<'a> {
- proxy: ValidationProxy<'a>,
-}
-
-/// Builds a stream of the changes in the the `org.opensuse.Agama1.Validation`
-/// interface of the given D-Bus object.
-///
-/// * `dbus`: D-Bus connection.
-/// * `destination`: D-Bus service name.
-/// * `path`: D-Bus object path.
-pub async fn validation_stream(
- dbus: zbus::Connection,
- destination: &'static str,
- path: &'static str,
-) -> Result + Send>>, Error> {
- let proxy = build_validation_proxy(&dbus, destination, path).await?;
- let stream = proxy
- .receive_errors_changed()
- .await
- .then(move |change| async move {
- if let Ok(errors) = change.get().await {
- Some(Event::ValidationChanged {
- service: destination.to_string(),
- path: path.to_string(),
- errors,
- })
- } else {
- None
- }
- })
- .filter_map(|e| e);
- Ok(Box::pin(stream))
-}
-
-async fn build_validation_proxy<'a>(
- dbus: &zbus::Connection,
- destination: &str,
- path: &str,
-) -> Result, zbus::Error> {
- let proxy = ValidationProxy::builder(dbus)
- .destination(destination.to_string())?
- .path(path.to_string())?
- .build()
- .await?;
- Ok(proxy)
-}
diff --git a/rust/package/agama.changes b/rust/package/agama.changes
index dcd55ee9a2..2d838161e9 100644
--- a/rust/package/agama.changes
+++ b/rust/package/agama.changes
@@ -1,3 +1,16 @@
+-------------------------------------------------------------------
+Thu Jun 13 10:50:44 UTC 2024 - Knut Anderssen
+
+- Apply network changes when connecting or disconnecting
+ (gh#openSUSE/agama#1320).
+
+-------------------------------------------------------------------
+Thu Jun 13 10:39:57 UTC 2024 - Imobach Gonzalez Sosa
+
+- Expose Issues API in users-related interface
+ (gh#openSUSE/agama#1202).
+- Drop the old validations API.
+
-------------------------------------------------------------------
Wed Jun 12 10:15:33 UTC 2024 - Jorik Cronenberg
diff --git a/service/lib/agama/dbus/clients/with_validation.rb b/service/lib/agama/dbus/clients/with_validation.rb
deleted file mode 100644
index 495b40f3f5..0000000000
--- a/service/lib/agama/dbus/clients/with_validation.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright (c) [2022] 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 "agama/validation_error"
-
-module Agama
- module DBus
- # Mixin to include in the clients of services that implement the Validation1 interface
- module WithValidation
- VALIDATION_IFACE = "org.opensuse.Agama1.Validation"
- private_constant :VALIDATION_IFACE
-
- # Returns the validation errors
- #
- # @return [Array] Validation errors
- def errors
- dbus_object[VALIDATION_IFACE]["Errors"].map do |message|
- Agama::ValidationError.new(message)
- end
- end
-
- # Determines whether the service settings are valid or not
- #
- # @return [Boolean] true if the service has valid data; false otherwise
- def valid?
- dbus_object[VALIDATION_IFACE]["Valid"]
- end
- end
- end
-end
diff --git a/service/lib/agama/dbus/interfaces/validation.rb b/service/lib/agama/dbus/interfaces/validation.rb
deleted file mode 100644
index 6cb2974fbe..0000000000
--- a/service/lib/agama/dbus/interfaces/validation.rb
+++ /dev/null
@@ -1,92 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright (c) [2022] 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 "dbus"
-
-module Agama
- module DBus
- module Interfaces
- # Mixin to define the Validation D-Bus interface
- #
- # @example Update the validation on a D-Bus call
- # class Backend
- # def validate
- # ["Some error"]
- # end
- # end
- #
- # class Demo < Agama::DBus::BaseObject
- # include Agama::DBus::Interfaces::Validation
- #
- # dbus_interface "org.opensuse.Agama.Demo1" do
- # dbus_reader :errors, "as", dbus_name: "Errors"
- # dbus_method :Foo, "out result:u" do
- # # do some stuff
- # update_validation # run this method is the validation can change
- # 0
- # end
- # end
- # end
- #
- # @note This mixin is expected to be included in a class that inherits from {DBus::BaseObject}
- # and it requires a #backend method that returns an object that implements a #validate method.
- module Validation
- VALIDATION_INTERFACE = "org.opensuse.Agama1.Validation"
-
- # D-Bus properties of the Validation1 interface
- #
- # @return [Hash]
- def validation_properties
- interfaces_and_properties[VALIDATION_INTERFACE]
- end
-
- # Updates the validation and raise the `PropertiesChanged` signal
- def update_validation
- @errors = nil
- dbus_properties_changed(VALIDATION_INTERFACE, validation_properties, [])
- end
-
- # Returns the validation errors
- #
- # @return [Array] Validation error messages
- def errors
- @errors ||= backend.validate.map(&:message)
- end
-
- # Determines whether the service settings are valid or not
- #
- # @return [Boolean] true if the service has valid data; false otherwise
- def valid?
- errors.empty?
- end
-
- def self.included(base)
- base.class_eval do
- dbus_interface VALIDATION_INTERFACE do
- dbus_reader :errors, "as"
- dbus_reader :valid?, "b", dbus_name: "Valid"
- end
- end
- end
- end
- end
- end
-end
diff --git a/service/lib/agama/dbus/manager_service.rb b/service/lib/agama/dbus/manager_service.rb
index 6351ef4031..7a9c9bf432 100644
--- a/service/lib/agama/dbus/manager_service.rb
+++ b/service/lib/agama/dbus/manager_service.rb
@@ -125,7 +125,7 @@ def manager_dbus
end
def users_dbus
- @users_dbus ||= Agama::DBus::Users.new(users, logger)
+ @users_dbus ||= Agama::DBus::Users.new(manager.users, logger)
end
end
end
diff --git a/service/lib/agama/dbus/users.rb b/service/lib/agama/dbus/users.rb
index b80368444a..d9632a4e34 100644
--- a/service/lib/agama/dbus/users.rb
+++ b/service/lib/agama/dbus/users.rb
@@ -23,16 +23,16 @@
require "agama/users"
require "agama/dbus/base_object"
require "agama/dbus/with_service_status"
+require "agama/dbus/interfaces/issues"
require "agama/dbus/interfaces/service_status"
-require "agama/dbus/interfaces/validation"
module Agama
module DBus
# YaST D-Bus object (/org/opensuse/Agama/Users1)
class Users < BaseObject
include WithServiceStatus
+ include Interfaces::Issues
include Interfaces::ServiceStatus
- include Interfaces::Validation
PATH = "/org/opensuse/Agama/Users1"
private_constant :PATH
@@ -45,6 +45,14 @@ def initialize(backend, logger)
super(PATH, logger: logger)
@backend = backend
register_service_status_callbacks
+ register_users_callbacks
+ end
+
+ # List of issues, see {DBus::Interfaces::Issues}
+ #
+ # @return [Array]
+ def issues
+ backend.issues
end
USERS_INTERFACE = "org.opensuse.Agama.Users1"
@@ -66,7 +74,6 @@ def initialize(backend, logger)
backend.assign_root_password(value, encrypted)
dbus_properties_changed(USERS_INTERFACE, { "RootPasswordSet" => !value.empty? }, [])
- update_validation
0
end
@@ -76,7 +83,6 @@ def initialize(backend, logger)
dbus_properties_changed(USERS_INTERFACE, { "RootPasswordSet" => backend.root_password? },
[])
- update_validation
0
end
@@ -85,7 +91,6 @@ def initialize(backend, logger)
backend.root_ssh_key = (value)
dbus_properties_changed(USERS_INTERFACE, { "RootSSHKey" => value }, [])
- update_validation
0
end
@@ -94,16 +99,15 @@ def initialize(backend, logger)
# and the second parameter as an array of issues found in case of failure
FUSER_SIG + ", out result:(bas)" do |full_name, user_name, password, auto_login, data|
logger.info "Setting first user #{full_name}"
- issues = backend.assign_first_user(full_name, user_name, password, auto_login, data)
+ user_issues = backend.assign_first_user(full_name, user_name, password, auto_login, data)
- if issues.empty?
+ if user_issues.empty?
dbus_properties_changed(USERS_INTERFACE, { "FirstUser" => first_user }, [])
- update_validation
else
logger.info "First user fatal issues detected: #{issues}"
end
- [[issues.empty?, issues]]
+ [[user_issues.empty?, user_issues]]
end
dbus_method :RemoveFirstUser, "out result:u" do
@@ -111,7 +115,6 @@ def initialize(backend, logger)
backend.remove_first_user
dbus_properties_changed(USERS_INTERFACE, { "FirstUser" => first_user }, [])
- update_validation
0
end
@@ -149,6 +152,10 @@ def root_password_set
# @return [Agama::Users]
attr_reader :backend
+
+ def register_users_callbacks
+ backend.on_issues_change { issues_properties_changed }
+ end
end
end
end
diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb
index fe2ae6d25a..90326e0b83 100644
--- a/service/lib/agama/manager.rb
+++ b/service/lib/agama/manager.rb
@@ -203,7 +203,7 @@ def on_services_status_change(&block)
#
# @return [Boolean]
def valid?
- users.valid? && !software.errors? && !storage.errors?
+ users.issues.empty? && !software.errors? && !storage.errors?
end
# Collects the logs and stores them into an archive
diff --git a/service/lib/agama/users.rb b/service/lib/agama/users.rb
index 898a72239f..080ee30632 100644
--- a/service/lib/agama/users.rb
+++ b/service/lib/agama/users.rb
@@ -24,7 +24,8 @@
require "y2users/linux" # FIXME: linux is not in y2users file
require "yast2/execute"
require "agama/helpers"
-require "agama/validation_error"
+require "agama/issue"
+require "agama/with_issues"
module Agama
# Backend class using YaST code.
@@ -32,9 +33,12 @@ module Agama
# {Agama::DBus::Users} wraps it with a D-Bus interface and
class Users
include Helpers
+ include WithIssues
+ include Yast::I18n
def initialize(logger)
@logger = logger
+ update_issues
end
def root_ssh_key
@@ -43,8 +47,10 @@ def root_ssh_key
def root_ssh_key=(value)
root_user.authorized_keys = [value] # just one supported for now
+ update_issues
end
+ # NOTE: the root user is created if it does not exist
def root_password?
!!root_user.password_content
end
@@ -60,7 +66,9 @@ def assign_root_password(value, encrypted)
Y2Users::Password.create_plain(value)
end
+ logger.info "Updating the user password"
root_user.password = value.empty? ? nil : pwd
+ update_issues
end
# Whether the given user is configured for autologin
@@ -81,6 +89,7 @@ def first_user
# Clears the root password
def remove_root_password
root_user.password = nil
+ update_issues
end
# It adds the user with the given parameters to the login config only if there are no error
@@ -104,6 +113,7 @@ def assign_first_user(full_name, user_name, password, auto_login, _data)
config.attach(user)
config.login ||= Y2Users::LoginConfig.new
config.login.autologin_user = auto_login ? user : nil
+ update_issues
[]
end
@@ -111,6 +121,7 @@ def assign_first_user(full_name, user_name, password, auto_login, _data)
def remove_first_user
old_users = config.users.reject(&:root?)
config.detach(old_users) unless old_users.empty?
+ update_issues
end
def write
@@ -127,29 +138,24 @@ def write
end
end
- # Validates the users configuration
- #
- # @return [Array] List of validation errors
- def validate
- return [] if root_password? || root_ssh_key? || first_user?
+ private
- [
- ValidationError.new(
- "Defining a user, setting the root password or a SSH public key is required"
- )
- ]
- end
+ attr_reader :logger
- # Determines whether the users configuration is valid
- #
- # @return [Boolean]
- def valid?
- validate.empty?
- end
+ # Recalculates the list of issues
+ def update_issues
+ new_issues = []
- private
+ unless root_password? || root_ssh_key? || first_user?
+ new_issues << Issue.new(
+ _("Defining a user, setting the root password or a SSH public key is required"),
+ source: Issue::Source::CONFIG,
+ severity: Issue::Severity::ERROR
+ )
+ end
- attr_reader :logger
+ self.issues = new_issues
+ end
# Determines whether a first user is defined or not
#
diff --git a/service/lib/agama/validation_error.rb b/service/lib/agama/validation_error.rb
deleted file mode 100644
index 1f3566c851..0000000000
--- a/service/lib/agama/validation_error.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright (c) [2022] 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.
-
-module Agama
- # Represents a validation error
- #
- # These are errors related to the logic of the backends. For instance,
- # not defining neither a first user nor a root authentication method might
- # be a problem.
- class ValidationError
- # @return [String] Error message
- attr_reader :message
-
- # @param message [String] Error message
- def initialize(message)
- @message = message
- end
-
- # Determines whether two errors are equivalent
- #
- # @param other [ValidationError] Validation error to compare to
- # @return [Boolean]
- def ==(other)
- @message == other.message
- end
- end
-end
diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes
index 68bafe9416..dfd5b661c7 100644
--- a/service/package/rubygem-agama-yast.changes
+++ b/service/package/rubygem-agama-yast.changes
@@ -1,3 +1,9 @@
+-------------------------------------------------------------------
+Thu Jun 13 10:53:27 UTC 2024 - Imobach Gonzalez Sosa
+
+- Replace the Validations with the Issues API in the users-related
+ API (gh#openSUSE/agama#1202).
+
-------------------------------------------------------------------
Wed Jun 5 13:56:54 UTC 2024 - Ancor Gonzalez Sosa
diff --git a/service/test/agama/dbus/clients/with_validation_examples.rb b/service/test/agama/dbus/clients/with_validation_examples.rb
deleted file mode 100644
index 8262978c02..0000000000
--- a/service/test/agama/dbus/clients/with_validation_examples.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright (c) [2022] 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_relative "../../../test_helper"
-
-shared_examples "validation" do
- before do
- allow(dbus_object).to receive(:path).and_return("/org/opensuse/Agama/Test")
- allow(dbus_object).to receive(:[]).with("org.opensuse.Agama1.Validation")
- .and_return(validation_properties)
- end
-
- let(:validation_properties) do
- { "Errors" => ["An error"], "Valid" => false }
- end
-
- describe "#validation_errors" do
- it "returns the validation errors" do
- expect(subject.errors).to eq([Agama::ValidationError.new("An error")])
- end
- end
-
- describe "#valid?" do
- it "returns whether the service settings are valid or not" do
- expect(subject.valid?).to eq(false)
- end
- end
-end
diff --git a/service/test/agama/dbus/interfaces/validation_test.rb b/service/test/agama/dbus/interfaces/validation_test.rb
deleted file mode 100644
index bc15d682f7..0000000000
--- a/service/test/agama/dbus/interfaces/validation_test.rb
+++ /dev/null
@@ -1,96 +0,0 @@
-# frozen_string_literal: true
-
-# Copyright (c) [2022] 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_relative "../../../test_helper"
-require "dbus"
-require "agama/dbus/interfaces/validation"
-require "agama/dbus/base_object"
-require "agama/validation_error"
-
-class DBusObjectWithValidationInterface < Agama::DBus::BaseObject
- include Agama::DBus::Interfaces::Validation
- attr_reader :backend
-
- def initialize(backend)
- super("org.opensuse.Agama.UnitTests")
- @backend = backend
- end
-end
-
-describe Agama::DBus::Interfaces::Validation do
- subject { DBusObjectWithValidationInterface.new(backend) }
-
- let(:backend) { double("Backend", validate: errors) }
- let(:errors) { [] }
- let(:error1) { Agama::ValidationError.new("Some error") }
-
- describe "#errors" do
- context "when there are not validation errors" do
- it "returns an empty array" do
- expect(subject.errors).to eq([])
- end
- end
-
- context "when any error exists" do
- let(:errors) { [error1] }
-
- it "returns the error messages" do
- expect(subject.errors).to eq(["Some error"])
- end
- end
- end
-
- describe "#valid?" do
- context "when there are not validation errors" do
- it "returns true" do
- expect(subject.valid?).to eq(true)
- end
- end
-
- context "when any error exists" do
- let(:errors) { [error1] }
-
- it "returns the error messages" do
- expect(subject.errors).to eq(["Some error"])
- end
-
- it "returns true" do
- expect(subject.valid?).to eq(false)
- end
- end
- end
-
- describe "#update_validation" do
- let(:error2) { Agama::ValidationError.new("Another error") }
-
- before do
- allow(backend).to receive(:validate).and_return([error1], [error1, error2])
- end
-
- it "updates the validation" do
- expect(subject).to receive(:dbus_properties_changed)
- .once.with("org.opensuse.Agama1.Validation", Hash, [])
- expect { subject.update_validation }
- .to change { subject.errors.size }
- .from(1).to(2)
- end
- end
-end
diff --git a/service/test/agama/dbus/users_test.rb b/service/test/agama/dbus/users_test.rb
index 6d4c479ca1..744d6cb7db 100644
--- a/service/test/agama/dbus/users_test.rb
+++ b/service/test/agama/dbus/users_test.rb
@@ -20,6 +20,7 @@
# find current contact information at www.suse.com.
require_relative "../../test_helper"
+require "agama/dbus/interfaces/issues"
require "agama/dbus/interfaces/service_status"
require "agama/dbus/users"
require "agama/users"
@@ -29,7 +30,9 @@
let(:logger) { Logger.new($stdout, level: :warn) }
- let(:backend) { instance_double(Agama::Users) }
+ let(:backend) { instance_double(Agama::Users, on_issues_change: nil) }
+
+ let(:issues_interface) { Agama::DBus::Interfaces::Issues::ISSUES_INTERFACE }
before do
allow_any_instance_of(described_class).to receive(:register_service_status_callbacks)
@@ -41,10 +44,8 @@
)
end
- it "defines Validation D-Bus interface" do
- expect(subject.intfs.keys).to include(
- Agama::DBus::Interfaces::Validation::VALIDATION_INTERFACE
- )
+ it "defines Issues D-Bus interface" do
+ expect(subject.intfs.keys).to include(issues_interface)
end
describe ".new" do
@@ -52,6 +53,11 @@
expect_any_instance_of(described_class).to receive(:register_service_status_callbacks)
subject
end
+
+ it "configures callbacks from Issues interface" do
+ expect(backend).to receive(:on_issues_change)
+ subject
+ end
end
describe "first_user" do
diff --git a/service/test/agama/manager_test.rb b/service/test/agama/manager_test.rb
index b9e70a30fd..e37a9034e9 100644
--- a/service/test/agama/manager_test.rb
+++ b/service/test/agama/manager_test.rb
@@ -23,6 +23,7 @@
require_relative "./with_progress_examples"
require "agama/manager"
require "agama/config"
+require "agama/issue"
require "agama/question"
require "agama/dbus/service_status"
require "agama/users"
@@ -48,7 +49,7 @@
end
let(:users) do
instance_double(
- Agama::Users, write: nil, valid?: true
+ Agama::Users, write: nil, issues: []
)
end
let(:locale) { instance_double(Agama::DBus::Clients::Locale, finish: nil) }
@@ -183,7 +184,7 @@
context "when the users configuration is not valid" do
before do
- allow(users).to receive(:valid?).and_return(false)
+ allow(users).to receive(:issues).and_return([instance_double(Agama::Issue)])
end
it "returns false" do
diff --git a/service/test/agama/users_test.rb b/service/test/agama/users_test.rb
index dee60261f0..28808cefa4 100644
--- a/service/test/agama/users_test.rb
+++ b/service/test/agama/users_test.rb
@@ -111,8 +111,8 @@
context "when the given arguments presents some critical error" do
it "does not add the user to the config" do
- subject.assign_first_user("Root user", "root", "12345", false, {})
- user = users_config.users.by_name("root")
+ subject.assign_first_user("Jonh Doe", "john", "", false, {})
+ user = users_config.users.by_name("john")
expect(user).to be_nil
subject.assign_first_user("Ldap user", "ldap", "12345", false, {})
user = users_config.users.by_name("ldap")
@@ -183,14 +183,14 @@
subject.write
end
- describe "#validate" do
+ describe "#issues" do
context "when a root password is set" do
before do
subject.assign_root_password("123456", true)
end
it "returns an empty list" do
- expect(subject.validate).to be_empty
+ expect(subject.issues).to be_empty
end
end
@@ -200,7 +200,7 @@
end
it "returns an empty list" do
- expect(subject.validate).to be_empty
+ expect(subject.issues).to be_empty
end
end
@@ -210,14 +210,14 @@
end
it "returns an empty list" do
- expect(subject.validate).to be_empty
+ expect(subject.issues).to be_empty
end
end
context "when neither a first user is defined nor the root password/SSH key is set" do
it "returns the problem" do
- error = subject.validate.first
- expect(error.message).to match(/Defining a user/)
+ error = subject.issues.first
+ expect(error.description).to match(/Defining a user/)
end
end
end
diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes
index 82014d7fc6..4b232703f3 100644
--- a/web/package/agama-web-ui.changes
+++ b/web/package/agama-web-ui.changes
@@ -1,10 +1,16 @@
+-------------------------------------------------------------------
+Thu Jun 13 10:52:22 UTC 2024 - David Diaz
+
+- Remake the user interface to follow a streamlined approach
+ (gh#openSUSE/agama#1202).
+
-------------------------------------------------------------------
Thu Jun 6 07:43:50 UTC 2024 - Knut Anderssen
- Try to reconnect silently when the WebSocket is closed displaying
a page error if it is not possible (gh#openSUSE/agama#1254).
- Display a different login error message depending on the request
- response (gh@openSUSE/agama#1274).
+ response (gh#openSUSE/agama#1274).
-------------------------------------------------------------------
Thu May 23 07:28:44 UTC 2024 - Josef Reidinger
diff --git a/web/src/App.jsx b/web/src/App.jsx
index fbe3458e72..4eda8d27b0 100644
--- a/web/src/App.jsx
+++ b/web/src/App.jsx
@@ -20,17 +20,17 @@
*/
import React, { useEffect, useState } from "react";
+import { Loading } from "./components/layout";
import { Outlet } from "react-router-dom";
-
+import { ProductSelectionProgress } from "~/components/product";
+import { Questions } from "~/components/questions";
+import { ServerError, Installation } from "~/components/core";
+import { useInstallerL10n } from "./context/installerL10n";
import { useInstallerClient, useInstallerClientStatus } from "~/context/installer";
import { useProduct } from "./context/product";
-import { INSTALL, STARTUP } from "~/client/phase";
+import { CONFIG, INSTALL, STARTUP } from "~/client/phase";
import { BUSY } from "~/client/status";
-import { ServerError, If, Installation } from "~/components/core";
-import { Loading } from "./components/layout";
-import { useInstallerL10n } from "./context/installerL10n";
-
/**
* Main application component.
*
@@ -40,7 +40,7 @@ import { useInstallerL10n } from "./context/installerL10n";
*/
function App() {
const client = useInstallerClient();
- const { error } = useInstallerClientStatus();
+ const { connected, error } = useInstallerClientStatus();
const { products } = useProduct();
const { language } = useInstallerL10n();
const [status, setStatus] = useState(undefined);
@@ -73,21 +73,31 @@ function App() {
const Content = () => {
if (error) return ;
- if (!products) return ;
+
+ if (phase === INSTALL) {
+ return ;
+ }
+
+ if (!products || !connected) return ;
if ((phase === STARTUP && status === BUSY) || phase === undefined || status === undefined) {
return ;
}
- if (phase === INSTALL) {
- return ;
+ if (phase === CONFIG && status === BUSY) {
+ return ;
}
return ;
};
+ if (!language) return null;
+
return (
- } />
+ <>
+
+
+ >
);
}
diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx
index 6364ea8d63..828a89fa39 100644
--- a/web/src/App.test.jsx
+++ b/web/src/App.test.jsx
@@ -42,11 +42,22 @@ jest.mock("~/context/product", () => ({
}
}));
+jest.mock("~/context/installer", () => ({
+ ...jest.requireActual("~/context/installer"),
+ useInstallerClientStatus: () => {
+ return {
+ connected: true,
+ error: false
+ };
+ }
+}));
+
// Mock some components,
// See https://www.chakshunyu.com/blog/how-to-mock-a-react-component-in-jest/#default-export
jest.mock("~/components/questions/Questions", () => () =>
);
// this object holds the mocked callbacks
const callbacks = {};
@@ -54,8 +65,12 @@ const getStatusFn = jest.fn();
const getPhaseFn = jest.fn();
// capture the latest subscription to the manager#onPhaseChange for triggering it manually
-const onPhaseChangeFn = cb => { callbacks.onPhaseChange = cb };
-const onStatusChangeFn = cb => { callbacks.onStatusChange = cb };
+const onPhaseChangeFn = cb => {
+ callbacks.onPhaseChange = cb;
+};
+const onStatusChangeFn = cb => {
+ callbacks.onStatusChange = cb;
+};
const changePhaseTo = phase => act(() => callbacks.onPhaseChange(phase));
describe("App", () => {
@@ -68,7 +83,7 @@ describe("App", () => {
getStatus: getStatusFn,
getPhase: getPhaseFn,
onPhaseChange: onPhaseChangeFn,
- onStatusChange: onStatusChangeFn,
+ onStatusChange: onStatusChangeFn
},
l10n: {
locales: jest.fn().mockResolvedValue([["en_us", "English", "United States"]]),
@@ -121,7 +136,7 @@ describe("App", () => {
});
});
- describe("when the D-Bus service is busy during startup", () => {
+ describe("when the service is busy during startup", () => {
beforeEach(() => {
getPhaseFn.mockResolvedValue(STARTUP);
getStatusFn.mockResolvedValue(BUSY);
@@ -138,9 +153,26 @@ describe("App", () => {
getPhaseFn.mockResolvedValue(CONFIG);
});
- it("renders the application content", async () => {
- installerRender(, { withL10n: true });
- await screen.findByText(/Outlet Content/);
+ describe("if the service is busy", () => {
+ beforeEach(() => {
+ getStatusFn.mockResolvedValue(BUSY);
+ });
+
+ it("renders the product selection progress", async () => {
+ installerRender(, { withL10n: true });
+ await screen.findByText(/Product progress/);
+ });
+ });
+
+ describe("if the service is not busy", () => {
+ beforeEach(() => {
+ getStatusFn.mockResolvedValue(IDLE);
+ });
+
+ it("renders the application content", async () => {
+ installerRender(, { withL10n: true });
+ await screen.findByText(/Outlet Content/);
+ });
});
});
@@ -155,7 +187,7 @@ describe("App", () => {
});
});
- describe("when D-Bus service phase changes", () => {
+ describe("when service phase changes", () => {
beforeEach(() => {
getPhaseFn.mockResolvedValue(CONFIG);
});
@@ -167,16 +199,4 @@ describe("App", () => {
await screen.findByText("Installation Mock");
});
});
-
- describe("when the config phase is done", () => {
- beforeEach(() => {
- getPhaseFn.mockResolvedValue(CONFIG);
- getStatusFn.mockResolvedValue(IDLE);
- });
-
- it("renders the application's content", async () => {
- installerRender(, { withL10n: true });
- await screen.findByText(/Outlet Content/);
- });
- });
});
diff --git a/web/src/MainLayout.jsx b/web/src/MainLayout.jsx
new file mode 100644
index 0000000000..2775e2f3c4
--- /dev/null
+++ b/web/src/MainLayout.jsx
@@ -0,0 +1,134 @@
+/*
+ * 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 { Outlet, NavLink, useNavigate } from "react-router-dom";
+import {
+ Button,
+ Masthead, MastheadContent, MastheadToggle, MastheadMain, MastheadBrand,
+ Nav, NavItem, NavList,
+ Page, PageSidebar, PageSidebarBody, PageToggleButton,
+ Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem
+} from "@patternfly/react-core";
+import { Icon } from "~/components/layout";
+import { About, InstallerOptions } from "~/components/core";
+import { _ } from "~/i18n";
+import { rootRoutes } from "~/router";
+import { useProduct } from "~/context/product";
+
+const Header = () => {
+ const { selectedProduct } = useProduct();
+ // NOTE: Agama is a name, do not translate
+ const title = selectedProduct?.name || _("Agama");
+
+ // FIXME: do not use the style prop, find another way to play with the icon
+ // color.
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const ChangeProductButton = () => {
+ const navigate = useNavigate();
+ const { products } = useProduct();
+
+ if (!products.length) return null;
+
+ return (
+
+
+
+ );
+};
+
+const Sidebar = () => {
+ // TODO: Improve this and/or extract the NavItem to a wrapper component.
+ const links = rootRoutes.map(r => {
+ if (!r.handle || r.handle.hidden) return null;
+
+ return (
+
+ [className, isActive ? "pf-m-current" : ""].join(" ")}>
+ {r.handle?.name}
+
+ }
+ />
+ );
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+};
+
+/**
+ * Root application component for laying out the content.
+ */
+export default function Root() {
+ return (
+ }
+ sidebar={}
+ >
+
+
+ );
+}
diff --git a/web/src/SimpleLayout.jsx b/web/src/SimpleLayout.jsx
new file mode 100644
index 0000000000..342a4786dd
--- /dev/null
+++ b/web/src/SimpleLayout.jsx
@@ -0,0 +1,55 @@
+/*
+ * 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 { Outlet } from "react-router-dom";
+import {
+ Masthead, MastheadContent,
+ Page,
+ Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem
+} from "@patternfly/react-core";
+import { InstallerOptions } from "~/components/core";
+import { _ } from "~/i18n";
+
+/**
+ * Simple layout for displaying content that comes before product configuration
+ * TODO: improve documentation
+ */
+export default function SimpleLayout({ showOutlet = true, showInstallerOptions = true, children }) {
+ return (
+
+
+
+
+
+
+
+ {showInstallerOptions && }
+
+
+
+
+
+
+ {showOutlet ? : children}
+
+ );
+}
diff --git a/web/src/assets/styles/app.scss b/web/src/assets/styles/app.scss
index 00a0a1da6c..3ade7e3ad8 100644
--- a/web/src/assets/styles/app.scss
+++ b/web/src/assets/styles/app.scss
@@ -1,15 +1,3 @@
-#root {
- position: relative;
- /** block-size fallbacks **/
- height: 100vh;
- height: 100dvb;
- block-size: 100vh;
- /** END of block-size fallbacks **/
- block-size: 100dvb;
- max-inline-size: var(--ui-max-inline-size);
- margin-inline: auto;
-}
-
// Make proposal actions compact
.proposal-actions li + li {
margin-block-start: 0;
diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss
index 1505b8bf70..a148b07f1e 100644
--- a/web/src/assets/styles/blocks.scss
+++ b/web/src/assets/styles/blocks.scss
@@ -1,57 +1,6 @@
// CSS rules used for the standard Agama section (core/Section.jsx)
// In the future we might add different section layouts by using data-variant attribute
// or similar strategy
-[data-type="agama/section"] {
- display: grid;
- grid-template-rows:
- [header] auto
- [content] auto
- ;
- grid-template-columns: [bleed] var(--section-icon-size) [content] 1fr;
- gap: var(--spacer-small);
- margin-inline-start: calc(
- var(--header-icon-size) - var(--section-icon-size)
- );
- margin-inline-end: var(--section-icon-size);
-
- &:not(:last-child) {
- margin-block-end: var(--spacer-medium);
- }
-
- > header {
- display: grid;
- grid-area: header;
- grid-template-columns: subgrid;
- grid-column: bleed / content-end;
-
- h2 {
- display: grid;
- grid-template-columns: subgrid;
- grid-column: bleed / content-end;
-
- svg {
- block-size: var(--section-icon-size);
- inline-size: var(--section-icon-size);
- grid-column: bleed / content;
- }
-
- :not(svg) {
- grid-column: content
- }
- }
-
- p {
- grid-column: content;
- color: var(--color-gray-dimmest);
- margin-block-end: var(--spacer-smaller);
- }
- }
-
- > :not(header) {
- grid-area: content;
- grid-column: content;
- }
-}
// Custom selection list
.selection-list > * {
@@ -561,31 +510,6 @@ table.proposal-result {
);
}
/** End of temporary hack */
-
- @media (width > 768px) {
- th.details-column {
- padding-inline-start: calc(60px + var(--spacer-smaller) * 2);
- }
-
- td.details-column {
- display: grid;
- gap: var(--spacer-smaller);
- grid-template-columns: 60px 1fr;
-
- :first-child {
- text-align: end;
- }
- }
-
- th.sizes-column,
- td.sizes-column {
- text-align: end;
-
- div.split {
- justify-content: flex-end;
- }
- }
- }
}
// compact lists in popover
diff --git a/web/src/assets/styles/composition.scss b/web/src/assets/styles/composition.scss
index 7055502e3f..09be3715ef 100644
--- a/web/src/assets/styles/composition.scss
+++ b/web/src/assets/styles/composition.scss
@@ -1,29 +1,3 @@
-.stack > :where(
- :not(legend, :last-child)) {
- margin-block-end: var(--stack-gutter);
-}
-
-.flex-stack {
- display: flex;
- flex-direction: column;
- align-items: start;
- @extend .stack;
-}
-
-.split {
- display: flex;
- align-items: center;
- gap: var(--split-gutter);
-}
-
-[data-items-alignment="start"] {
- align-items: start;
-}
-
-.wrapped {
- flex-wrap: wrap;
-}
-
// TODO: make it less specific.
.location-layout > div {
display: flex;
diff --git a/web/src/assets/styles/global.scss b/web/src/assets/styles/global.scss
index 0478eab4d2..6bf961bd7d 100644
--- a/web/src/assets/styles/global.scss
+++ b/web/src/assets/styles/global.scss
@@ -1,27 +1,31 @@
// Global CSS starts
-body {
- line-height: var(--lh-normal);
- font-size: var(--fs-base);
- color: var(--color-text-primary);
- background: var(--color-gray);
- text-align: start;
- overflow-x: hidden;
-}
-
h1, h2, h3, h4, h5, h6 {
- margin: 0;
+ // margin: 0;
font-family: var(--ff-headlines);
font-weight: var(--fw-bold);
}
-h1 { font-size: var(--fs-h1); }
-h2 { font-size: var(--fs-h2); }
+h1 {
+ font-size: var(--pf-v5-global--FontSize--2xl)
+}
+
+h2 {
+ font-size: var(--pf-v5-global--FontSize--xl)
+}
+
+h3 {
+ font-size: var(--pf-v5-global--FontSize--lg)
+}
+
+h4 {
+ font-size: var(--pf-v5-global--FontSize--md)
+}
a {
color: currentcolor;
}
-a,
+a:not(.pf-v5-c-button,.pf-v5-c-nav__link,.pf-v5-c-menu__item),
// TODO: make it better, using PatternFly custom properties for overriding it
button.pf-m-plain,
button.pf-m-link {
@@ -35,6 +39,11 @@ button.pf-m-link {
}
}
+// Do not reserve space for empty nodes.
+div:empty {
+ display: none;
+}
+
fieldset {
padding: var(--fs-base);
border: 0;
diff --git a/web/src/assets/styles/patternfly-overrides.scss b/web/src/assets/styles/patternfly-overrides.scss
index 1eb8d871f7..aacae6db96 100644
--- a/web/src/assets/styles/patternfly-overrides.scss
+++ b/web/src/assets/styles/patternfly-overrides.scss
@@ -123,7 +123,6 @@ table td > .pf-v5-c-empty-state {
}
.pf-v5-c-toolbar {
- --stack-gutter: 0;
--pf-v5-c-toolbar--PaddingTop: 0;
--pf-v5-c-toolbar--PaddingBottom: 0;
}
@@ -238,15 +237,6 @@ table td > .pf-v5-c-empty-state {
padding-inline: 0;
}
-ul {
- list-style: initial;
- margin-inline: var(--spacer-normal);
-
- li:not(:last-child) {
- margin-block-end: var(--spacer-small);
- }
-}
-
// Styles for tree table used by storage page.
.pf-v5-c-table tbody {
@@ -266,11 +256,6 @@ ul {
border-block-end: 0;
}
-.pf-v5-c-table tr:where(.pf-v5-c-table__tr) > th {
- white-space: normal;
- vertical-align: middle;
-}
-
.pf-v5-c-radio {
align-items: center;
}
@@ -284,3 +269,68 @@ ul {
padding-inline: 0;
}
}
+
+
+// New-ui overrides
+// ================
+
+// For using icons, set fill as color.
+.pf-v5-c-nav__link {
+ fill: var(--pf-v5-c-nav__link--Color);
+}
+
+.pf-v5-c-page__sidebar-body{
+ fill: white;
+}
+
+// center alignment and a bit of gap makes links with icons looks better
+.pf-v5-c-nav__link {
+ align-items: center;
+ gap: calc(var(--pf-v5-c-nav__link--FontSize) / 2);
+}
+
+// Allows the pf-m-current directly in the a element instead of li.
+// Needed because setting the pf-m-current in ReactRouter/NavLink (the one
+// that knowst that link "isActive")
+
+.pf-v5-c-tabs__link.pf-m-current {
+ --pf-v5-c-tabs__link--after--BorderColor: var(--pf-v5-c-tabs__item--m-current__link--after--BorderColor);
+ --pf-v5-c-tabs__link--after--BorderWidth: var(--pf-v5-c-tabs__item--m-current__link--after--BorderWidth);
+}
+
+// Color for icons in Masthead
+.pf-v5-c-masthead {
+ fill: white;
+}
+
+:not(.pf-m-light-200).pf-v5-c-masthead {
+ .pf-v5-c-button.pf-m-link,
+ .pf-v5-c-button.pf-m-plain {
+ color: white;
+ }
+}
+
+// Force sidebar to only use needed width plus an extra padding at the end.
+.pf-v5-c-page__sidebar.pf-m-expanded {
+ --pf-v5-c-page__sidebar--Width: fit-content;
+
+ .pf-v5-c-nav__link {
+ padding-inline-end: calc(var(--pf-v5-global--spacer--xl) * 1.2);
+ }
+}
+
+// Makes the NotificationDrawerHeader "plain"
+.pf-v5-c-notification-drawer {
+ --pf-v5-c-notification-drawer--BackgroundColor: white;
+}
+
+.pf-v5-c-notification-drawer__list-item {
+ --pf-v5-c-notification-drawer__list-item--PaddingBottom: 0;
+ --pf-v5-c-notification-drawer__list-item--BoxShadow: none;
+}
+
+.pf-v5-c-notification-drawer__list-item-description {
+ padding-inline-start: calc(
+ 1em + var(--pf-v5-c-notification-drawer__list-item-header-icon--MarginRight)
+ );
+}
diff --git a/web/src/assets/styles/utilities.scss b/web/src/assets/styles/utilities.scss
index 16ff87dc16..9e8030b5e9 100644
--- a/web/src/assets/styles/utilities.scss
+++ b/web/src/assets/styles/utilities.scss
@@ -59,6 +59,16 @@
inline-size: var(--icon-size-s);
}
+.icon-xl {
+ block-size: var(--icon-size-xl);
+ inline-size: var(--icon-size-xl);
+}
+
+.icon-xxl {
+ block-size: var(--icon-size-xxl);
+ inline-size: var(--icon-size-xxl);
+}
+
.icon-xxxl {
block-size: var(--icon-size-xxxl);
inline-size: var(--icon-size-xxxl);
@@ -151,10 +161,6 @@
padding: 0;
}
-.no-stack-gutter {
- --stack-gutter: 0;
-}
-
.block-size-auto {
block-size: auto;
}
@@ -244,3 +250,7 @@
display: flex;
gap: var(--spacer-smaller);
}
+
+.no-padding {
+ padding: 0;
+}
diff --git a/web/src/assets/styles/variables.scss b/web/src/assets/styles/variables.scss
index 71a5d7e024..7c79ff96c2 100644
--- a/web/src/assets/styles/variables.scss
+++ b/web/src/assets/styles/variables.scss
@@ -37,12 +37,9 @@
--icon-size-m: 32px;
--icon-size-l: 36px;
--icon-size-xl: 40px;
- --icon-size-xxl: 44px;
+ --icon-size-xxl: 5rem;
--icon-size-xxxl: 10rem;
- --stack-gutter: var(--spacer-normal);
- --split-gutter: var(--spacer-small);
-
--wrapper-padding: var(--spacer-small);
--wrapper-background: white;
diff --git a/web/src/client/index.js b/web/src/client/index.js
index e8cc3f63a5..8488197714 100644
--- a/web/src/client/index.js
+++ b/web/src/client/index.js
@@ -61,17 +61,25 @@ import { HTTPClient } from "./http";
* @property {Issue[]} [product] - Issues from product.
* @property {Issue[]} [storage] - Issues from storage.
* @property {Issue[]} [software] - Issues from software.
+ * @property {Issue[]} [users] - Issues from users.
+ * @property {boolean} [isEmpty] - Whether the list is empty
*
* @typedef {(issues: Issues) => void} IssuesHandler
*/
+const createIssuesList = (product = [], software = [], storage = [], users = []) => {
+ const list = { product, storage, software, users };
+ list.isEmpty = !Object.values(list).some(v => v.length > 0);
+ return list;
+};
+
/**
* Creates the Agama client
*
* @param {URL} url - URL of the HTTP API.
* @return {InstallerClient}
*/
-const createClient = (url) => {
+const createClient = url => {
const client = new HTTPClient(url);
const l10n = new L10nClient(client);
// TODO: unify with the manager client
@@ -93,11 +101,11 @@ const createClient = (url) => {
* @returns {Promise}
*/
const issues = async () => {
- return {
- product: await product.getIssues(),
- storage: await storage.getIssues(),
- software: await software.getIssues(),
- };
+ const productIssues = await product.getIssues();
+ const storageIssues = await storage.getIssues();
+ const softwareIssues = await software.getIssues();
+ const usersIssues = await users.getIssues();
+ return createIssuesList(productIssues, softwareIssues, storageIssues, usersIssues);
};
/**
@@ -106,21 +114,16 @@ const createClient = (url) => {
* @param {IssuesHandler} handler - Callback function.
* @return {() => void} - Function to deregister the callback.
*/
- const onIssuesChange = (handler) => {
+ const onIssuesChange = handler => {
const unsubscribeCallbacks = [];
- unsubscribeCallbacks.push(
- product.onIssuesChange((i) => handler({ product: i })),
- );
- unsubscribeCallbacks.push(
- storage.onIssuesChange((i) => handler({ storage: i })),
- );
- unsubscribeCallbacks.push(
- software.onIssuesChange((i) => handler({ software: i })),
- );
+ unsubscribeCallbacks.push(product.onIssuesChange(i => handler({ product: i })));
+ unsubscribeCallbacks.push(storage.onIssuesChange(i => handler({ storage: i })));
+ unsubscribeCallbacks.push(software.onIssuesChange(i => handler({ software: i })));
+ unsubscribeCallbacks.push(users.onIssuesChange(i => handler({ users: i })));
return () => {
- unsubscribeCallbacks.forEach((cb) => cb());
+ unsubscribeCallbacks.forEach(cb => cb());
};
};
@@ -141,8 +144,8 @@ const createClient = (url) => {
onIssuesChange,
isConnected,
isRecoverable,
- onConnect: (handler) => client.ws.onOpen(handler),
- onDisconnect: (handler) => client.ws.onClose(handler),
+ onConnect: handler => client.ws.onOpen(handler),
+ onDisconnect: handler => client.ws.onClose(handler)
};
};
@@ -152,4 +155,4 @@ const createDefaultClient = async () => {
return createClient(httpUrl);
};
-export { createClient, createDefaultClient, phase };
+export { createClient, createDefaultClient, phase, createIssuesList };
diff --git a/web/src/client/mixins.js b/web/src/client/mixins.js
index 1799c28142..72c63b6320 100644
--- a/web/src/client/mixins.js
+++ b/web/src/client/mixins.js
@@ -254,50 +254,8 @@ const WithProgress = (superclass, progress_path, service_name) =>
/**
* @param {string} message - Error message
*/
-const createError = (message) => {
+const createError = message => {
return { message };
};
-/**
- * Extends the given class with methods to get validation errors over D-Bus
- * @template {!WithHTTPClient} T
- * @param {T} superclass - superclass to extend
- * @param {string} validation_path - status resource path (e.g., "/manager/status").
- * @param {string} service_name - service name (e.g., "org.opensuse.Agama.Manager1").
- */
-const WithValidation = (superclass, validation_path, service_name) =>
- class extends superclass {
- /**
- * Returns the validation errors
- *
- * @return {Promise}
- */
- async getValidationErrors() {
- const response = await this.client.get(validation_path);
- if (!response.ok) {
- console.log("get validation failed with:", response);
- return [{
- message: "Failed to validate",
- }];
- } else {
- const data = await response.json();
- return data.errors.map(createError);
- }
- }
-
- /**
- * Register a callback to run when the validation changes
- *
- * @param {ValidationErrorsHandler} handler - callback function
- * @return {import ("./dbus").RemoveFn} function to disable the callback
- */
- onValidationChange(handler) {
- return this.client.onEvent("ValidationChange", ({ service, errors }) => {
- if (service === service_name) {
- handler(errors);
- }
- });
- }
- };
-
-export { WithIssues, WithProgress, WithStatus, WithValidation };
+export { WithIssues, WithProgress, WithStatus };
diff --git a/web/src/client/network/index.js b/web/src/client/network/index.js
index 4ddb3ce1b1..a56bf51feb 100644
--- a/web/src/client/network/index.js
+++ b/web/src/client/network/index.js
@@ -123,7 +123,7 @@ class NetworkClient {
const { ipConfig = {}, ...dev } = device;
const routes4 = (ipConfig.routes4 || []).map((route) => {
const [ip, netmask] = route.destination.split("/");
- const destination = { address: ip, prefix: ipPrefixFor(netmask) };
+ const destination = (netmask !== undefined) ? { address: ip, prefix: ipPrefixFor(netmask) } : { address: ip };
return { ...route, destination };
});
@@ -225,7 +225,7 @@ class NetworkClient {
* @param {Connection} connection - connection to be activated
*/
async connectTo(connection) {
- return this.client.get(`/network/${connection.id}/connect`);
+ return this.client.get(`/network/connections/${connection.id}/connect`);
}
/**
@@ -234,7 +234,7 @@ class NetworkClient {
* @param {Connection} connection - connection to be activated
*/
async disconnect(connection) {
- return this.client.get(`/network/${connection.id}/disconnect`);
+ return this.client.get(`/network/connections/${connection.id}/disconnect`);
}
/**
diff --git a/web/src/client/network/model.js b/web/src/client/network/model.js
index 2ad100b080..c8a8a069e1 100644
--- a/web/src/client/network/model.js
+++ b/web/src/client/network/model.js
@@ -176,8 +176,8 @@ const ApSecurityFlags = Object.freeze({
/**
* @typedef {object} NetworkSettings
* @property {boolean} connectivity
-* @property {boolean} wirelessEnabled
-* @property {boolean} networkingEnabled
+* @property {boolean} wireless_enabled
+* @property {boolean} networking_enabled
* @property {string} hostname
/**
diff --git a/web/src/client/users.js b/web/src/client/users.js
index b93c154652..198c876606 100644
--- a/web/src/client/users.js
+++ b/web/src/client/users.js
@@ -21,7 +21,9 @@
// @ts-check
-import { WithValidation } from "./mixins";
+import { WithIssues } from "./mixins";
+
+const SERVICE_NAME = "org.opensuse.Agama.Manager1";
/**
* @typedef {object} UserResult
@@ -184,6 +186,6 @@ class UsersBaseClient {
/**
* Client to interact with the Agama users service
*/
-class UsersClient extends WithValidation(UsersBaseClient, "/users/validation", "/org/opensuse/Agama/Users1") { }
+class UsersClient extends WithIssues(UsersBaseClient, "/users/issues", SERVICE_NAME) {}
export { UsersClient };
diff --git a/web/src/components/core/About.jsx b/web/src/components/core/About.jsx
index 75b77808bd..736dbef7a0 100644
--- a/web/src/components/core/About.jsx
+++ b/web/src/components/core/About.jsx
@@ -39,15 +39,16 @@ import { Popup } from "~/components/core";
* @param {object} props
* @param {boolean} [props.showIcon=true] - Whether render a "help" icon into the button.
* @param {string} [props.iconSize="s"] - The size for the button icon.
- * @param {string} [props.buttonText="About Agama"] - The text for the button.
+ * @param {string} [props.buttonText="About"] - The text for the button.
* @param {ButtonProps["variant"]} [props.buttonVariant="link"] - The button variant.
* See {@link https://www.patternfly.org/components/button#variant-examples PF/Button}.
*/
export default function About({
showIcon = true,
iconSize = "s",
- buttonText = _("About Agama"),
- buttonVariant = "link"
+ buttonText = _("About"),
+ buttonVariant = "link",
+ ...props
}) {
const [isOpen, setIsOpen] = useState(false);
@@ -60,8 +61,9 @@ export default function About({
variant={buttonVariant}
icon={showIcon && }
onClick={open}
+ {...props}
>
- { buttonText }
+ {buttonText}
{
it("allows setting its button variant", () => {
plainRender();
- const button = screen.getByRole("button", { name: "About Agama" });
+ const button = screen.getByRole("button", { name: "About" });
expect(button.classList.contains("pf-m-tertiary")).toBe(true);
});
diff --git a/web/src/components/core/ButtonLink.jsx b/web/src/components/core/ButtonLink.jsx
new file mode 100644
index 0000000000..5afe4448fd
--- /dev/null
+++ b/web/src/components/core/ButtonLink.jsx
@@ -0,0 +1,46 @@
+/*
+ * 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.
+ */
+
+// @ts-check
+
+import React from "react";
+import { Link } from "react-router-dom";
+import buttonStyles from '@patternfly/react-styles/css/components/Button/button';
+
+// TODO: Evaluate which is better, this approach or just using a
+// PF/Button with onClick callback and "component" prop sets as "a"
+
+export default function ButtonLink({ to, isPrimary = false, children, ...props }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web/src/components/core/CardField.jsx b/web/src/components/core/CardField.jsx
new file mode 100644
index 0000000000..28b7552c61
--- /dev/null
+++ b/web/src/components/core/CardField.jsx
@@ -0,0 +1,72 @@
+/*
+ * 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.
+ */
+
+// @ts-check
+
+import React from "react";
+import {
+ Card, CardHeader, CardTitle, CardBody, CardFooter,
+ Flex, FlexItem,
+} from "@patternfly/react-core";
+import textStyles from '@patternfly/react-styles/css/utilities/Text/text';
+
+// FIXME: improve name and documentation
+// TODO: allows having a drawer, see storage/ProposalResultActions
+
+/**
+ * Field wrapper built on top of PF/Card
+ * @component
+ *
+ * @todo write documentation
+ */
+const CardField = ({
+ label,
+ value,
+ description,
+ actions,
+ children = [],
+ cardProps = {},
+ cardHeaderProps = {},
+
+}) => {
+ return (
+
+
+
+
+
+
{label}
+
+
+ {value}
+
+
+
+
+ {description &&
{description}
}
+ {children}
+ {actions && {actions}}
+
+ );
+};
+
+CardField.Content = CardBody;
+export default CardField;
diff --git a/web/src/components/core/ControlledPanels.jsx b/web/src/components/core/ControlledPanels.jsx
deleted file mode 100644
index 6c164607f9..0000000000
--- a/web/src/components/core/ControlledPanels.jsx
+++ /dev/null
@@ -1,108 +0,0 @@
-/*
- * 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.
- */
-
-// @ts-check
-
-import React from "react";
-
-/**
- * Wrapper component for holding ControlledPanel options
- *
- * Useful for rendering the ControlledPanel options horizontally.
- *
- * @see ControlledPanel examples.
- *
- * @param {React.PropsWithChildren} props
- */
-const Options = ({ children, ...props }) => {
- return (
-
- { children }
-
- );
-};
-
-/**
- * Renders an option intended to control the visibility of panels referenced by
- * the controls prop.
- *
- * @typedef {object} OptionProps
- * @property {string} id - The option id.
- * @property {React.AriaAttributes["aria-controls"]} controls - A space-separated of one or more ID values
- * referencing the elements whose contents or presence are controlled by the option.
- * @property {boolean} isSelected - Whether the option is selected or not.
- * @typedef {Omit, "aria-controls">} InputProps
- *
- * @param {React.PropsWithChildren} props
- */
-const Option = ({ id, controls, isSelected, children, ...props }) => {
- return (
-
-
-
- );
-};
-
-/**
- * Renders content whose visibility will be controlled by an option
- *
- * @typedef {object} PanelBaseProps
- * @property {string} id - The option id.
- * @property {boolean} isExpanded - The value for the aria-expanded attribute
- * which will determine if the panel is visible or not.
- *
- * @typedef {PanelBaseProps & Omit, "id" | "aria-expanded">} PanelProps
- *
- * @param {PanelProps} props
- */
-const Panel = ({ id, isExpanded = false, children, ...props }) => {
- return (
-
- { children }
-
- );
-};
-
-/**
- * TODO: Write the documentation and examples.
- * TODO: Find a better name.
- * TODO: Improve it.
- * NOTE: Please, be aware that despite the name, this has no relation with so
- * called React controlled components https://react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components
- * This is just a convenient, dummy component for simplifying the use of this
- * options/tabs pattern across Agama UI.
- */
-const ControlledPanels = ({ children, ...props }) => {
- return (
-
- { children }
-
- );
-};
-
-ControlledPanels.Options = Options;
-ControlledPanels.Option = Option;
-ControlledPanels.Panel = Panel;
-
-export default ControlledPanels;
diff --git a/web/src/components/core/Disclosure.jsx b/web/src/components/core/Disclosure.jsx
deleted file mode 100644
index c75f8c76d1..0000000000
--- a/web/src/components/core/Disclosure.jsx
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- * Copyright (c) [2023] 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.
- */
-
-// @ts-check
-
-import React, { useState } from "react";
-import { Icon } from '~/components/layout';
-
-/**
- * Build and render an accessible disclosure
- * @component
- *
- * TODO: use inert and/or hidden/hidden="until-found" attribute for the panel?
- * https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/inert
- * https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/hidden
- *
- * FIXME: do not send all otherProps to the icon but only the
- * data-keep-sidebar-open attribute for being able keep the sidebar open
- *
- * @example
Simple usage
- *
- *
- *
- *
- * @example
Sending attributes to the button
- *
- *
- *
- *
- * @param {object} props
- * @param {string} props.label - the label to be used as button text
- * @param {React.ReactElement} props.children - the section content
- * @param {object} props.otherProps - rest of props, sent to the button element
- */
-export default function Disclosure({ label, children, ...otherProps }) {
- const [isExpanded, setIsExpanded] = useState(false);
- const toggle = () => setIsExpanded(!isExpanded);
-
- return (
-
-
-
- {children}
-
-
- );
-}
diff --git a/web/src/components/core/Disclosure.test.jsx b/web/src/components/core/Disclosure.test.jsx
deleted file mode 100644
index 594fd1c2e6..0000000000
--- a/web/src/components/core/Disclosure.test.jsx
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * Copyright (c) [2023] 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 { Disclosure } from "~/components/core";
-
-describe("Disclosure", () => {
- it("renders a button with given label", () => {
- plainRender(The disclosed content);
-
- screen.getByRole("button", { name: "Developer tools" });
- });
-
- it("renders a panel with given children", () => {
- plainRender(
-
- A disclosed link
-
A disclosed paragraph
-
- );
-
- screen.getByRole("link", { name: "A disclosed link" });
- screen.getByText("A disclosed paragraph");
- });
-
- it("renders it initially collapsed", () => {
- plainRender(The disclosed content);
- const button = screen.getByRole("button", { name: "Developer tools" });
- expect(button).toHaveAttribute("aria-expanded", "false");
- });
-
- it("expands it when user clicks on the button ", async () => {
- const { user } = plainRender(The disclosed content);
- const button = screen.getByRole("button", { name: "Developer tools" });
-
- await user.click(button);
- expect(button).toHaveAttribute("aria-expanded", "true");
- });
-});
diff --git a/web/src/components/core/EmptyState.jsx b/web/src/components/core/EmptyState.jsx
new file mode 100644
index 0000000000..46f7cd9770
--- /dev/null
+++ b/web/src/components/core/EmptyState.jsx
@@ -0,0 +1,75 @@
+/*
+ * 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.
+ */
+
+// @ts-check
+
+import React from "react";
+import { EmptyState, EmptyStateHeader, EmptyStateBody } from "@patternfly/react-core";
+import { Icon } from "~/components/layout";
+
+/**
+ * @typedef {import("~/components/layout/Icon").IconName} IconName
+ * @typedef {import("@patternfly/react-core").EmptyStateProps} EmptyStateProps
+ * @typedef {import("@patternfly/react-core").EmptyStateHeaderProps} EmptyStateHeaderProps
+ */
+
+/**
+ * Convenient wrapper for easing the use of PF/EmptyState component
+ *
+ * For consistence, try to use it as much as possible. Use PF/EmptyState
+ * directly when dealing with a very specific UI use case and more freedom is
+ * needed.
+ *
+ * @param {object} props
+ * @param {string} props.title
+ * @param {IconName} props.icon
+ * @param {string} props.color
+ * @param {Pick} [props.headingLevel="h4"]
+ * @param {boolean} [props.noPadding=false]
+ * @param {React.ReactNode} props.children
+ * @param {EmptyStateProps} [props.props]
+ * @todo write documentation
+ */
+export default function EmptyStateWrapper({
+ title,
+ icon,
+ color,
+ headingLevel = "h4",
+ noPadding = false,
+ children,
+ ...props
+}) {
+ if (noPadding) props.className = [props.className, 'no-padding'].join(" ").trim();
+
+ return (
+
+ }
+ />
+
+ {children}
+
+
+ );
+}
diff --git a/web/src/components/core/ExpandableSelector.jsx b/web/src/components/core/ExpandableSelector.jsx
index af98528c71..aaad695abd 100644
--- a/web/src/components/core/ExpandableSelector.jsx
+++ b/web/src/components/core/ExpandableSelector.jsx
@@ -57,7 +57,7 @@ import { Table, Thead, Tr, Th, Tbody, Td, ExpandableRowContent, RowSelectVariant
* @param {ExpandableSelectorColumn[]} props.columns
*/
const TableHeader = ({ columns }) => (
-
+
diff --git a/web/src/components/core/Field.jsx b/web/src/components/core/Field.jsx
deleted file mode 100644
index 7e63dc49ba..0000000000
--- a/web/src/components/core/Field.jsx
+++ /dev/null
@@ -1,115 +0,0 @@
-/*
- * 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.
- */
-
-// @ts-check
-
-import React from "react";
-import { Icon } from "~/components/layout";
-
-/**
- * @typedef {import("react").ButtonHTMLAttributes} ButtonHTMLAttributes
- * @typedef {import("~/components/layout/Icon").IconName} IconName
- * @typedef {import("~/components/layout/Icon").IconSize} IconSize
- */
-
-/**
- * @typedef {object} FieldProps
- * @property {React.ReactNode} label - The field label.
- * @property {React.ReactNode} [value] - The field value.
- * @property {React.ReactNode} [description] - A field description, useful for providing context to the user.
- * @property {IconName} [icon] - The name of the icon for the field.
- * @property {IconSize} [iconSize="s"] - The size for the field icon.
- * @property {("b"|"span")} [textWrapper="b"] - The element used for wrapping the label.
- * @property {ButtonHTMLAttributes} [buttonAttrs={}] - The element used for wrapping the label.
- * @property {string} [className] - ClassName
- * @property {() => void} [onClick] - Callback
- * @property {React.ReactNode} [children] - A content to be rendered as field children
- *
- * @typedef {Omit} FieldPropsWithoutIcon
- */
-
-/**
- * Component for laying out a page field
- *
- * @param {FieldProps} props
- */
-const Field = ({
- label,
- value,
- description,
- icon,
- iconSize = "s",
- onClick,
- children,
- textWrapper = "b",
- buttonAttrs = {},
- ...props
-}) => {
- const FieldIcon = () => icon?.length > 0 && ;
- const TextWrapper = textWrapper;
- return (
-
-
- {value}
-
-
- {description}
-
-
- {children}
-
-
- );
-};
-
-/**
- * @param {Omit & {isChecked: boolean, highlightContent?: boolean}} props
- */
-const SwitchField = ({ isChecked = false, highlightContent = false, ...props }) => {
- const iconName = isChecked ? "toggle_on" : "toggle_off";
- const baseClassnames = highlightContent ? "highlighted" : "";
- const stateClassnames = isChecked ? "on" : "off";
-
- return (
-
- );
-};
-
-/**
- * @param {FieldProps & {isExpanded: boolean}} props
- */
-const ExpandableField = ({ label, isExpanded, ...props }) => {
- const iconName = isExpanded ? "collapse_all" : "expand_all";
- const className = isExpanded ? "expanded" : "collapsed";
- const labelWithIcon = <>{label} >;
-
- return ;
-};
-
-export default Field;
-export { ExpandableField, SwitchField };
diff --git a/web/src/components/core/Field.test.jsx b/web/src/components/core/Field.test.jsx
deleted file mode 100644
index 6485c230c9..0000000000
--- a/web/src/components/core/Field.test.jsx
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
- * 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 { Field, ExpandableField, SwitchField } from "~/components/core";
-
-const onClick = jest.fn();
-
-describe("Field", () => {
- it("renders a button with given icon and label", () => {
- const { container } = plainRender(
-
- );
- screen.getByRole("button", { name: "Theme" });
- const icon = container.querySelector("button > svg");
- expect(icon).toHaveAttribute("data-icon-name", "edit");
- });
-
- it("renders value, description, and given children", () => {
- plainRender(
-
-