[pdm-devel] [PATCH datacenter-manager 3/3] ui: restructure wizard to have a better flow
Dominik Csapak
d.csapak at proxmox.com
Thu Dec 19 13:09:20 CET 2024
instead of entering all connection info on the first page, just require
the hostname/port (+fingerprint). Then on the second page, require an id
and either user/password/realm/new tokenname or existing token/secret.
the third page is then the endpoint list, and the summary stayed the
same.
This requires a bit of restructuring of the place and way we collect the
information.
Signed-off-by: Dominik Csapak <d.csapak at proxmox.com>
---
without patch 1 for yew-widget-toolkit, the right radio button is not
correctly placed.
ui/src/remotes/add_wizard.rs | 37 ++-
ui/src/remotes/mod.rs | 5 +-
ui/src/remotes/wizard_page_connect.rs | 115 ++++-----
ui/src/remotes/wizard_page_info.rs | 336 ++++++++++++++++++++++----
4 files changed, 363 insertions(+), 130 deletions(-)
diff --git a/ui/src/remotes/add_wizard.rs b/ui/src/remotes/add_wizard.rs
index f8c9bba..f4bf9a3 100644
--- a/ui/src/remotes/add_wizard.rs
+++ b/ui/src/remotes/add_wizard.rs
@@ -13,7 +13,10 @@ use proxmox_yew_comp::{
};
use yew::virtual_dom::VNode;
-use super::{WizardPageConnect, WizardPageInfo, WizardPageNodes, WizardPageSummary};
+use super::{
+ wizard_page_connect::ConnectParams, WizardPageConnect, WizardPageInfo, WizardPageNodes,
+ WizardPageSummary,
+};
use pwt_macros::builder;
@@ -51,9 +54,11 @@ impl AddWizard {
pub enum Msg {
ServerChange(Option<Remote>),
+ ConnectChange(Option<ConnectParams>),
}
pub struct AddWizardState {
server_info: Option<Remote>,
+ connect_info: Option<ConnectParams>,
}
impl Component for AddWizardState {
@@ -61,7 +66,10 @@ impl Component for AddWizardState {
type Properties = AddWizard;
fn create(_ctx: &Context<Self>) -> Self {
- Self { server_info: None }
+ Self {
+ server_info: None,
+ connect_info: None,
+ }
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
@@ -69,6 +77,9 @@ impl Component for AddWizardState {
Msg::ServerChange(server_info) => {
self.server_info = server_info;
}
+ Msg::ConnectChange(realms) => {
+ self.connect_info = realms;
+ }
}
true
}
@@ -93,11 +104,21 @@ impl Component for AddWizardState {
let link = ctx.link().clone();
move |p: &WizardPageRenderInfo| {
WizardPageConnect::new(p.clone(), remote_type)
- .on_server_change(link.callback(Msg::ServerChange))
+ .on_connect_change(link.callback(Msg::ConnectChange))
.into()
}
},
- );
+ )
+ .with_page(TabBarItem::new().key("info").label(tr!("Settings")), {
+ let realms = self.connect_info.clone();
+ let link = ctx.link().clone();
+ move |p: &WizardPageRenderInfo| {
+ WizardPageInfo::new(p.clone())
+ .connect_info(realms.clone())
+ .on_server_change(link.callback(Msg::ServerChange))
+ .into()
+ }
+ });
if remote_type == RemoteType::Pve {
wizard = wizard.with_page(TabBarItem::new().key("nodes").label(tr!("Endpoints")), {
@@ -111,14 +132,6 @@ impl Component for AddWizardState {
}
wizard
- .with_page(TabBarItem::new().key("info").label(tr!("Settings")), {
- let server_info = self.server_info.clone();
- move |p: &WizardPageRenderInfo| {
- WizardPageInfo::new(p.clone())
- .server_info(server_info.clone())
- .into()
- }
- })
.with_page(TabBarItem::new().label(tr!("Summary")), {
let server_info = self.server_info.clone();
move |p: &WizardPageRenderInfo| {
diff --git a/ui/src/remotes/mod.rs b/ui/src/remotes/mod.rs
index c9a4e43..cc91b3f 100644
--- a/ui/src/remotes/mod.rs
+++ b/ui/src/remotes/mod.rs
@@ -261,7 +261,10 @@ impl LoadableComponent for PbsRemoteConfigPanel {
ConfirmDialog::new()
.title(tr!("Confirm: Remove Remote"))
.confirm_text(tr!("Remove"))
- .confirm_message(tr!("Are you sure you want to remove the remote '{0}' ?", key))
+ .confirm_message(tr!(
+ "Are you sure you want to remove the remote '{0}' ?",
+ key
+ ))
.on_confirm(ctx.link().callback(|_| Msg::RemoveItem))
.on_done(ctx.link().change_view_callback(|_| None))
.into()
diff --git a/ui/src/remotes/wizard_page_connect.rs b/ui/src/remotes/wizard_page_connect.rs
index ebcae8c..28f14bf 100644
--- a/ui/src/remotes/wizard_page_connect.rs
+++ b/ui/src/remotes/wizard_page_connect.rs
@@ -1,22 +1,21 @@
use std::rc::Rc;
-use anyhow::Error;
+use anyhow::{bail, Error};
use serde::{Deserialize, Serialize};
+use serde_json::json;
use yew::html::IntoEventCallback;
use yew::virtual_dom::{Key, VComp, VNode};
use pwt::css::{AlignItems, FlexFit};
-use pwt::widget::form::{Field, FormContext, FormContextObserver, InputType};
+use pwt::widget::form::{Field, FormContext, FormContextObserver};
use pwt::widget::{error_message, Button, Column, Container, InputPanel, Mask, Row};
use pwt::{prelude::*, AsyncPool};
use proxmox_yew_comp::{SchemaValidation, WizardPageRenderInfo};
-use proxmox_schema::property_string::PropertyString;
-use proxmox_schema::ApiType;
-
-use pdm_api_types::remotes::{NodeUrl, Remote, RemoteType};
+use pdm_api_types::remotes::RemoteType;
use pdm_api_types::CERT_FINGERPRINT_SHA256_SCHEMA;
+use pdm_client::types::ListRealm;
use pwt_macros::builder;
@@ -25,9 +24,9 @@ use pwt_macros::builder;
pub struct WizardPageConnect {
info: WizardPageRenderInfo,
- #[builder_cb(IntoEventCallback, into_event_callback, Option<Remote>)]
+ #[builder_cb(IntoEventCallback, into_event_callback, Option<ConnectParams>)]
#[prop_or_default]
- pub on_server_change: Option<Callback<Option<Remote>>>,
+ pub on_connect_change: Option<Callback<Option<ConnectParams>>>,
remote_type: RemoteType,
}
@@ -38,35 +37,32 @@ impl WizardPageConnect {
}
}
-async fn scan(connect: ConnectParams) -> Result<Remote, Error> {
- let params = serde_json::to_value(&connect)?;
- let mut result: Remote = proxmox_yew_comp::http_post("/pve/scan", Some(params)).await?;
-
- // insert the initial connection too, since we know that works
- result.nodes.insert(
- 0,
- PropertyString::new(NodeUrl {
- hostname: connect.hostname,
- fingerprint: connect.fingerprint,
- }),
- );
-
- result.nodes.sort_by(|a, b| a.hostname.cmp(&b.hostname));
+async fn list_realms(
+ hostname: String,
+ fingerprint: Option<String>,
+) -> Result<Vec<ListRealm>, Error> {
+ let mut params = json!({
+ "hostname": hostname,
+ });
+ if let Some(fp) = fingerprint {
+ params["fingerprint"] = fp.into();
+ }
+ let result: Vec<ListRealm> = proxmox_yew_comp::http_post("/pve/realms", Some(params)).await?;
Ok(result)
}
-#[derive(Deserialize, Serialize)]
+#[derive(PartialEq, Clone, Deserialize, Serialize)]
/// Parameters for connect call.
pub struct ConnectParams {
- hostname: String,
- authid: String,
- token: String,
+ pub hostname: String,
#[serde(skip_serializing_if = "Option::is_none")]
- fingerprint: Option<String>,
+ pub fingerprint: Option<String>,
+ #[serde(default)]
+ pub realms: Vec<ListRealm>,
}
-async fn connect(form_ctx: FormContext, remote_type: RemoteType) -> Result<Remote, Error> {
+async fn connect(form_ctx: FormContext, remote_type: RemoteType) -> Result<ConnectParams, Error> {
let data = form_ctx.get_submit_data();
let mut data: ConnectParams = serde_json::from_value(data.clone())?;
if let Some(hostname) = data.hostname.strip_prefix("http://") {
@@ -79,28 +75,22 @@ async fn connect(form_ctx: FormContext, remote_type: RemoteType) -> Result<Remot
data.hostname = hostname.to_string();
}
- Ok(match remote_type {
- RemoteType::Pve => scan(data).await?,
- RemoteType::Pbs => Remote {
- ty: remote_type,
- id: data.hostname.clone(),
- authid: data.authid.parse()?,
- token: data.token,
- nodes: vec![PropertyString::new(NodeUrl {
- hostname: data.hostname,
- fingerprint: data.fingerprint,
- })],
- },
- })
+ let realms = match remote_type {
+ RemoteType::Pve => list_realms(data.hostname.clone(), data.fingerprint.clone()).await?,
+ RemoteType::Pbs => bail!("not implemented"),
+ };
+
+ data.realms = realms;
+ Ok(data)
}
pub enum Msg {
FormChange,
Connect,
- ConnectResult(Result<Remote, Error>),
+ ConnectResult(Result<ConnectParams, Error>),
}
pub struct PdmWizardPageConnect {
- server_info: Option<Remote>,
+ connect_info: Option<ConnectParams>,
_form_observer: FormContextObserver,
form_valid: bool,
loading: bool,
@@ -109,12 +99,12 @@ pub struct PdmWizardPageConnect {
}
impl PdmWizardPageConnect {
- fn update_server_info(&mut self, ctx: &Context<Self>, server_info: Option<Remote>) {
+ fn update_connect_info(&mut self, ctx: &Context<Self>, info: Option<ConnectParams>) {
let props = ctx.props();
- self.server_info = server_info;
- props.info.page_lock(self.server_info.is_none());
- if let Some(on_server_change) = &props.on_server_change {
- on_server_change.emit(self.server_info.clone());
+ self.connect_info = info.clone();
+ props.info.page_lock(info.is_none());
+ if let Some(on_connect_change) = &props.on_connect_change {
+ on_connect_change.emit(info);
}
}
}
@@ -133,7 +123,7 @@ impl Component for PdmWizardPageConnect {
props.info.page_lock(true);
Self {
- server_info: None,
+ connect_info: None,
_form_observer,
form_valid: false,
loading: false,
@@ -149,7 +139,7 @@ impl Component for PdmWizardPageConnect {
self.form_valid = props.info.form_ctx.read().is_valid();
match props.remote_type {
RemoteType::Pve => {
- self.update_server_info(ctx, None);
+ self.update_connect_info(ctx, None);
}
RemoteType::Pbs => {
return <Self as yew::Component>::update(self, ctx, Msg::Connect)
@@ -158,7 +148,7 @@ impl Component for PdmWizardPageConnect {
}
Msg::Connect => {
let link = ctx.link().clone();
- self.update_server_info(ctx, None);
+ self.update_connect_info(ctx, None);
let form_ctx = props.info.form_ctx.clone();
self.loading = true;
self.last_error = None;
@@ -172,8 +162,8 @@ impl Component for PdmWizardPageConnect {
Msg::ConnectResult(server_info) => {
self.loading = false;
match server_info {
- Ok(server_info) => {
- self.update_server_info(ctx, Some(server_info));
+ Ok(connect_info) => {
+ self.update_connect_info(ctx, Some(connect_info));
}
Err(err) => {
self.last_error = Some(err);
@@ -196,28 +186,13 @@ impl Component for PdmWizardPageConnect {
// FIXME: input panel css style is not optimal here...
.width("auto")
.padding(4)
- .with_field(
+ .with_large_field(
tr!("Server Address"),
Field::new()
.name("hostname")
.placeholder(tr!("<IP/Hostname>:Port"))
.required(true),
)
- .with_right_field(
- tr!("User/Token"),
- Field::new()
- .name("authid")
- .placeholder(tr!("Example: user at pve!tokenid"))
- .schema(&pdm_api_types::Authid::API_SCHEMA)
- .required(true),
- )
- .with_right_field(
- tr!("Password/Secret"),
- Field::new()
- .name("token")
- .input_type(InputType::Password)
- .required(true),
- )
.with_large_field(
tr!("Fingerprint"),
Field::new()
@@ -242,7 +217,7 @@ impl Component for PdmWizardPageConnect {
)
.with_flex_spacer()
.with_optional_child(
- (self.last_error.is_none() && self.server_info.is_some())
+ (self.last_error.is_none() && self.connect_info.is_some())
.then_some(Container::new().with_child(tr!("Connection OK"))),
)
.with_child(
diff --git a/ui/src/remotes/wizard_page_info.rs b/ui/src/remotes/wizard_page_info.rs
index 7874eae..c67be78 100644
--- a/ui/src/remotes/wizard_page_info.rs
+++ b/ui/src/remotes/wizard_page_info.rs
@@ -1,30 +1,41 @@
use std::rc::Rc;
-use yew::virtual_dom::{VComp, VNode};
+use anyhow::Error;
+use html::IntoEventCallback;
+use proxmox_schema::property_string::PropertyString;
+use serde::{Deserialize, Serialize};
+use yew::virtual_dom::{Key, VComp, VNode};
+use proxmox_yew_comp::WizardPageRenderInfo;
use pwt::{
- css::FlexFit,
+ css::{self, FlexFit},
prelude::*,
widget::{
- form::{Checkbox, Field},
- InputPanel,
+ error_message,
+ form::{Combobox, Field, FormContext, FormContextObserver, InputType, RadioButton},
+ Button, Column, Container, InputPanel, Mask, Row,
},
+ AsyncPool,
};
-use proxmox_yew_comp::WizardPageRenderInfo;
-
-use pdm_api_types::{remotes::Remote, Authid};
+use pdm_api_types::remotes::{NodeUrl, Remote};
use pwt_macros::builder;
+use super::wizard_page_connect::ConnectParams;
+
#[derive(Clone, PartialEq, Properties)]
#[builder]
pub struct WizardPageInfo {
info: WizardPageRenderInfo,
+ #[builder_cb(IntoEventCallback, into_event_callback, Option<Remote>)]
+ #[prop_or_default]
+ pub on_server_change: Option<Callback<Option<Remote>>>,
+
#[builder]
#[prop_or_default]
- server_info: Option<Remote>,
+ connect_info: Option<ConnectParams>,
}
impl WizardPageInfo {
@@ -34,11 +45,102 @@ impl WizardPageInfo {
}
pub struct PdmWizardPageInfo {
- create_token: bool,
+ user_mode: bool,
+ realms: Rc<Vec<AttrValue>>,
+ server_info: Option<Remote>,
+ last_error: Option<Error>,
+ credentials: Option<(String, String)>,
+ loading: bool,
+ _form_observer: FormContextObserver,
+ async_pool: AsyncPool,
}
pub enum Msg {
ToggleCreateToken(bool),
+ FormChange,
+ Connect,
+ ConnectResult(Result<Remote, Error>),
+}
+
+#[derive(Deserialize, Serialize)]
+/// Parameters for connect call.
+pub struct ScanParams {
+ hostname: String,
+ authid: String,
+ token: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ fingerprint: Option<String>,
+}
+
+fn create_realm_list(props: &WizardPageInfo) -> Rc<Vec<AttrValue>> {
+ if let Some(info) = &props.connect_info {
+ let realms = Rc::new(
+ info.realms
+ .iter()
+ .map(|realm| AttrValue::from(realm.realm.clone()))
+ .collect(),
+ );
+ realms
+ } else {
+ Rc::new(Vec::new())
+ }
+}
+
+async fn scan(connection_params: ConnectParams, form_ctx: FormContext) -> Result<Remote, Error> {
+ let mut data = form_ctx.get_submit_data();
+
+ data["hostname"] = connection_params.hostname.into();
+ if let Some(fp) = connection_params.fingerprint {
+ data["fingerprint"] = fp.into();
+ }
+
+ let data: ScanParams = serde_json::from_value(data.clone())?;
+
+ let params = serde_json::to_value(&data)?;
+ let mut result: Remote = proxmox_yew_comp::http_post("/pve/scan", Some(params)).await?;
+ result.nodes.insert(
+ 0,
+ PropertyString::new(NodeUrl {
+ hostname: data.hostname,
+ fingerprint: data.fingerprint,
+ }),
+ );
+ result.nodes.sort_by(|a, b| a.hostname.cmp(&b.hostname));
+ Ok(result)
+}
+
+impl PdmWizardPageInfo {
+ fn update_credentials(form_ctx: &FormContext) {
+ let user = form_ctx.read().get_field_text("user");
+ let realm = form_ctx.read().get_field_text("realm");
+ let password = form_ctx.read().get_field_text("password");
+
+ let user_mode = form_ctx.read().get_field_text("login-mode") == "login";
+
+ let tokenid = form_ctx.read().get_field_text("tokenid");
+ let secret = form_ctx.read().get_field_text("secret");
+
+ let (authid, token) =
+ if user_mode && !user.is_empty() && !realm.is_empty() && !password.is_empty() {
+ (format!("{user}@{realm}").into(), password.into())
+ } else if !user_mode && !tokenid.is_empty() && !secret.is_empty() {
+ (tokenid.into(), secret.into())
+ } else {
+ (serde_json::Value::Null, serde_json::Value::Null)
+ };
+
+ form_ctx.write().set_field_value("authid", authid);
+ form_ctx.write().set_field_value("token", token);
+ }
+
+ fn update_server_info(&mut self, ctx: &Context<Self>, server_info: Option<Remote>) {
+ let props = ctx.props();
+ self.server_info = server_info;
+ props.info.page_lock(self.server_info.is_none());
+ if let Some(on_server_change) = &props.on_server_change {
+ on_server_change.emit(self.server_info.clone());
+ }
+ }
}
impl Component for PdmWizardPageInfo {
@@ -47,67 +149,207 @@ impl Component for PdmWizardPageInfo {
fn create(ctx: &Context<Self>) -> Self {
let props = ctx.props();
- if props.server_info.is_none() {
- props.info.page_lock(true);
- }
- Self { create_token: true }
+ props.info.page_lock(true);
+
+ let _form_observer = props
+ .info
+ .form_ctx
+ .add_listener(ctx.link().callback(|_| Msg::FormChange));
+
+ Self {
+ server_info: None,
+ user_mode: true,
+ realms: create_realm_list(props),
+ _form_observer,
+ last_error: None,
+ loading: false,
+ credentials: None,
+ async_pool: AsyncPool::new(),
+ }
}
- fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
+ fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+ let props = ctx.props();
match msg {
Msg::ToggleCreateToken(create_token) => {
- self.create_token = create_token;
+ self.user_mode = create_token;
+ }
+ Msg::FormChange => {
+ let form_ctx = &props.info.form_ctx;
+ Self::update_credentials(form_ctx);
+ let authid = form_ctx.read().get_field_text("authid");
+ let token = form_ctx.read().get_field_text("token");
+ if !authid.is_empty() && !token.is_empty() {
+ match &self.credentials {
+ Some((old_auth, old_token))
+ if *old_auth == authid && *old_token == token => {}
+ Some(_) | None => {
+ self.credentials = Some((authid, token));
+ self.update_server_info(ctx, None);
+ }
+ }
+ } else {
+ self.credentials = None;
+ }
+ }
+ Msg::Connect => {
+ let link = ctx.link().clone();
+ self.update_server_info(ctx, None);
+ let form_ctx = props.info.form_ctx.clone();
+ self.loading = true;
+ self.last_error = None;
+
+ if let Some(connection_info) = props.connect_info.clone() {
+ self.async_pool.spawn(async move {
+ let result = scan(connection_info, form_ctx).await;
+ link.send_message(Msg::ConnectResult(result));
+ });
+ } else {
+ unreachable!("Settings page must have connection info");
+ }
+ }
+ Msg::ConnectResult(server_info) => {
+ self.loading = false;
+ match server_info {
+ Ok(server_info) => {
+ self.update_server_info(ctx, Some(server_info));
+ }
+ Err(err) => {
+ self.last_error = Some(err);
+ }
+ }
+
+ if let Some(form_ctx) = props.info.lookup_form_context(&Key::from("nodes")) {
+ form_ctx.write().reset_form();
+ }
+ props.info.reset_remaining_valid_pages();
}
}
true
}
+ fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
+ self.realms = create_realm_list(ctx.props());
+ true
+ }
+
fn view(&self, ctx: &Context<Self>) -> Html {
- let mut is_user = true;
- if let Some(Some(authid)) = ctx
- .props()
- .info
- .valid_data
- .get("authid")
- .map(|a| a.as_str())
- {
- match authid.parse::<Authid>() {
- Ok(authid) => is_user = !authid.is_token(),
- Err(_) => {}
- }
- }
- let name = ctx
- .props()
- .server_info
- .as_ref()
- .map(|s| s.id.to_string())
- .unwrap_or_default();
- InputPanel::new()
+ let input_panel = InputPanel::new()
.class(FlexFit)
.padding(4)
+ .with_field(tr!("Remote ID"), Field::new().name("id").required(true))
+ .with_custom_child(
+ RadioButton::new("login")
+ .key("login-mode-login")
+ .name("login-mode")
+ .default(true)
+ .box_label(tr!("Login and create Token"))
+ .on_change(
+ ctx.link()
+ .callback(|value| Msg::ToggleCreateToken(value == "login")),
+ ),
+ )
+ .with_field(
+ tr!("User"),
+ Field::new()
+ .name("user")
+ .disabled(!self.user_mode)
+ .required(self.user_mode)
+ .submit(false),
+ )
.with_field(
- tr!("Remote ID"),
- Field::new().default(name).name("id").required(true),
+ tr!("Password"),
+ Field::new()
+ .input_type(InputType::Password)
+ .name("password")
+ .disabled(!self.user_mode)
+ .required(self.user_mode)
+ .submit(false),
)
.with_field(
- tr!("Create API Token"),
- Checkbox::new()
- .key("create-token-cb")
- .submit(false)
- .disabled(is_user)
- .default(self.create_token || is_user)
- .on_change(ctx.link().callback(Msg::ToggleCreateToken)),
+ tr!("Realm"),
+ Combobox::new()
+ .name("realm")
+ .disabled(!self.user_mode)
+ .required(self.user_mode)
+ .items(self.realms.clone())
+ .submit(false),
)
.with_field(
tr!("API Token Name"),
Field::new()
.name("create-token")
- .disabled(!self.create_token && !is_user)
- .required(self.create_token || is_user)
- .submit(self.create_token || is_user)
+ .disabled(!self.user_mode)
+ .required(self.user_mode)
+ .submit(self.user_mode)
.default("pdm-admin"),
)
+ .with_right_custom_child(Container::new().key("spacer")) //spacer
+ .with_right_custom_child(
+ RadioButton::new("token")
+ .key("login-mode-token")
+ .name("login-mode")
+ .box_label(tr!("Use existing Token")),
+ )
+ .with_right_field(
+ tr!("Token"),
+ Field::new()
+ .name("tokenid")
+ .disabled(self.user_mode)
+ .required(!self.user_mode)
+ .submit(false),
+ )
+ .with_right_field(
+ tr!("Secret"),
+ Field::new()
+ .name("secret")
+ .input_type(InputType::Password)
+ .disabled(self.user_mode)
+ .required(!self.user_mode)
+ .submit(false),
+ )
+ .with_field_and_options(
+ pwt::widget::FieldPosition::Left,
+ false,
+ true,
+ tr!(""),
+ Field::new().name("token").required(true),
+ )
+ .with_field_and_options(
+ pwt::widget::FieldPosition::Left,
+ false,
+ true,
+ tr!(""),
+ Field::new().name("authid").required(true),
+ );
+ let content = Column::new()
+ .class(FlexFit)
+ .with_child(input_panel)
+ .with_child(
+ Row::new()
+ .padding(2)
+ .gap(2)
+ .class(css::AlignItems::Center)
+ .with_optional_child(
+ self.last_error
+ .as_deref()
+ .map(|err| error_message(&err.to_string())),
+ )
+ .with_flex_spacer()
+ .with_optional_child(
+ (self.last_error.is_none() && self.server_info.is_some())
+ .then_some(Container::new().with_child(tr!("Scan OK"))),
+ )
+ .with_child(
+ Button::new("Scan")
+ .disabled(self.credentials.is_none())
+ .onclick(ctx.link().callback(|_| Msg::Connect)),
+ ),
+ );
+ Mask::new(content)
+ .class(FlexFit)
+ .visible(self.loading)
.into()
}
}
--
2.39.5
More information about the pdm-devel
mailing list