ui: add user attributes page

todo
This commit is contained in:
Austin Alvarado
2024-01-18 05:41:06 +00:00
parent 6f905b1ca9
commit 55225bc15b
10 changed files with 621 additions and 1 deletions

View File

@@ -1,4 +1,4 @@
FROM rust:1.72
FROM rust:1.74
ARG USERNAME=lldapdev
# We need to keep the user as 1001 to match the GitHub runner's UID.

View File

@@ -0,0 +1,5 @@
mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {
addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {
ok
}
}

View File

@@ -0,0 +1,5 @@
mutation DeleteUserAttributeQuery($name: String!) {
deleteUserAttribute(name: $name) {
ok
}
}

View File

@@ -0,0 +1,14 @@
query GetUserAttributesSchema {
schema {
userSchema {
attributes {
name
attributeType
isList
isVisible
isEditable
isHardcoded
}
}
}
}

View File

@@ -3,6 +3,7 @@ use crate::{
change_password::ChangePasswordForm,
create_group::CreateGroupForm,
create_user::CreateUserForm,
create_user_attribute::CreateUserAttributeForm,
group_details::GroupDetails,
group_table::GroupTable,
login::LoginForm,
@@ -10,6 +11,7 @@ use crate::{
reset_password_step1::ResetPasswordStep1Form,
reset_password_step2::ResetPasswordStep2Form,
router::{AppRoute, Link, Redirect},
user_attributes_table::UserAttributesTable,
user_details::UserDetails,
user_table::UserTable,
},
@@ -227,6 +229,9 @@ impl App {
AppRoute::CreateGroup => html! {
<CreateGroupForm/>
},
AppRoute::CreateUserAttribute => html! {
<CreateUserAttributeForm/>
},
AppRoute::ListGroups => html! {
<div>
<GroupTable />
@@ -236,6 +241,15 @@ impl App {
</Link>
</div>
},
AppRoute::ListUserAttributes => html! {
<div>
<UserAttributesTable />
<Link classes="btn btn-primary" to={AppRoute::CreateUserAttribute}>
<i class="bi-plus-circle me-2"></i>
{"Create an attribute"}
</Link>
</div>
},
AppRoute::GroupDetails { group_id } => html! {
<GroupDetails group_id={*group_id} />
},
@@ -291,6 +305,14 @@ impl App {
{"Groups"}
</Link>
</li>
<li>
<Link
classes="nav-link px-2 h6"
to={AppRoute::ListUserAttributes}>
<i class="bi-list-ul me-2"></i>
{"User attributes"}
</Link>
</li>
</>
} } else { html!{} } }
</ul>

View File

@@ -0,0 +1,235 @@
use crate::{
components::router::AppRoute,
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{bail, Result};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use validator_derive::Validate;
use yew::prelude::*;
use yew_form_derive::Model;
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/create_user_attribute.graphql",
response_derives = "Debug",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct CreateUserAttribute;
type AttributeType = create_user_attribute::AttributeType;
pub struct CreateUserAttributeForm {
common: CommonComponentParts<Self>,
form: yew_form::Form<CreateUserAttributeModel>,
}
#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
pub struct CreateUserAttributeModel {
#[validate(length(min = 1, message = "attribute_name is required"))]
attribute_name: String,
#[validate(length(min = 1, message = "attribute_type is required"))]
attribute_type: String,
is_editable: bool,
is_list: bool,
is_visible: bool,
}
pub enum Msg {
Update,
SubmitForm,
CreateUserAttributeResponse(Result<create_user_attribute::ResponseData>),
}
impl CommonComponent<CreateUserAttributeForm> for CreateUserAttributeForm {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::SubmitForm => {
if !self.form.validate() {
bail!("Check the form for errors");
}
let model = self.form.model();
if model.is_editable && !model.is_visible {
bail!("Editable attributes must also be visible");
}
let attribute_type = match model.attribute_type.as_str() {
"Jpeg" => AttributeType::JPEG_PHOTO,
"DateTime" => AttributeType::DATE_TIME,
"Integer" => AttributeType::INTEGER,
"String" => AttributeType::STRING,
_ => bail!("Check the form for errors"),
};
let req = create_user_attribute::Variables {
name: model.attribute_name,
attribute_type: attribute_type,
is_editable: model.is_editable,
is_list: model.is_list,
is_visible: model.is_visible,
};
self.common.call_graphql::<CreateUserAttribute, _>(
ctx,
req,
Msg::CreateUserAttributeResponse,
"Error trying to create user attribute",
);
Ok(true)
}
Msg::CreateUserAttributeResponse(response) => {
response?;
let model = self.form.model();
log!(&format!(
"Created user attribute '{}'",
model.attribute_name
));
ctx.link()
.history()
.unwrap()
.push(AppRoute::ListUserAttributes);
Ok(true)
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for CreateUserAttributeForm {
type Message = Msg;
type Properties = ();
fn create(_: &Context<Self>) -> Self {
let mut model = CreateUserAttributeModel::default();
model.attribute_type = "String".to_string();
Self {
common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::<CreateUserAttributeModel>::new(model),
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
type Field = yew_form::Field<CreateUserAttributeModel>;
type Select = yew_form::Select<CreateUserAttributeModel>;
type Checkbox = yew_form::CheckBox<CreateUserAttributeModel>;
html! {
<div class="row justify-content-center">
<form class="form py-3" style="max-width: 636px">
<div class="row mb-3">
<h5 class="fw-bold">{"Create a user attribute"}</h5>
</div>
<div class="form-group row mb-3">
<label for="attribute_name"
class="form-label col-4 col-form-label">
{"Attribute name"}
<span class="text-danger">{"*"}</span>
{":"}
</label>
<div class="col-8">
<Field
form={&self.form}
field_name="attribute_name"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="attribute_name"
oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback">
{&self.form.field_message("attribute_name")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="attribute_type"
class="form-label col-4 col-form-label">
{"Type:"}
</label>
<div class="col-8">
<Select
form={&self.form}
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
field_name="attribute_type"
oninput={link.callback(|_| Msg::Update)} >
<option selected=true value="String">{"String"}</option>
<option value="Integer">{"Integer"}</option>
<option value="Jpeg">{"Jpeg"}</option>
<option value="DateTime">{"DateTime"}</option>
</Select>
<div class="invalid-feedback">
{&self.form.field_message("attribute_type")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="is_list"
class="form-label col-4 col-form-label">
{"Multiple values:"}
</label>
<div class="col-8">
<Checkbox
form={&self.form}
field_name="is_list"
ontoggle={link.callback(|_| Msg::Update)} />
</div>
</div>
<div class="form-group row mb-3">
<label for="is_visible"
class="form-label col-4 col-form-label">
{"Visible to users:"}
</label>
<div class="col-8">
<Checkbox
form={&self.form}
field_name="is_visible"
ontoggle={link.callback(|_| Msg::Update)} />
</div>
</div>
<div class="form-group row mb-3">
<label for="is_editable"
class="form-label col-4 col-form-label">
{"Editable by users:"}
</label>
<div class="col-8">
<Checkbox
form={&self.form}
field_name="is_editable"
ontoggle={link.callback(|_| Msg::Update)} />
</div>
</div>
<div class="form-group row justify-content-center">
<button
class="btn btn-primary col-auto col-form-label"
type="submit"
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
<i class="bi-save me-2"></i>
{"Submit"}
</button>
</div>
</form>
{ if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
</div>
}
} else { html! {} }
}
</div>
}
}
}

View File

@@ -0,0 +1,172 @@
use crate::infra::{
common_component::{CommonComponent, CommonComponentParts},
modal::Modal,
};
use anyhow::{Error, Result};
use graphql_client::GraphQLQuery;
use yew::prelude::*;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/delete_user_attribute.graphql",
response_derives = "Debug",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct DeleteUserAttributeQuery;
pub struct DeleteUserAttribute {
common: CommonComponentParts<Self>,
node_ref: NodeRef,
modal: Option<Modal>,
}
#[derive(yew::Properties, Clone, PartialEq, Debug)]
pub struct DeleteUserAttributeProps {
pub attribute_name: String,
pub on_attribute_deleted: Callback<String>,
pub on_error: Callback<Error>,
}
pub enum Msg {
ClickedDeleteUserAttribute,
ConfirmDeleteUserAttribute,
DismissModal,
DeleteUserAttributeResponse(Result<delete_user_attribute_query::ResponseData>),
}
impl CommonComponent<DeleteUserAttribute> for DeleteUserAttribute {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::ClickedDeleteUserAttribute => {
self.modal.as_ref().expect("modal not initialized").show();
}
Msg::ConfirmDeleteUserAttribute => {
self.update(ctx, Msg::DismissModal);
self.common.call_graphql::<DeleteUserAttributeQuery, _>(
ctx,
delete_user_attribute_query::Variables {
name: ctx.props().attribute_name.clone(),
},
Msg::DeleteUserAttributeResponse,
"Error trying to delete user attribute",
);
}
Msg::DismissModal => {
self.modal.as_ref().expect("modal not initialized").hide();
}
Msg::DeleteUserAttributeResponse(response) => {
response?;
ctx.props()
.on_attribute_deleted
.emit(ctx.props().attribute_name.clone());
}
}
Ok(true)
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for DeleteUserAttribute {
type Message = Msg;
type Properties = DeleteUserAttributeProps;
fn create(_: &Context<Self>) -> Self {
Self {
common: CommonComponentParts::<Self>::create(),
node_ref: NodeRef::default(),
modal: None,
}
}
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
if first_render {
self.modal = Some(Modal::new(
self.node_ref
.cast::<web_sys::Element>()
.expect("Modal node is not an element"),
));
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update_and_report_error(
self,
ctx,
msg,
ctx.props().on_error.clone(),
)
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
html! {
<>
<button
class="btn btn-danger"
disabled={self.common.is_task_running()}
onclick={link.callback(|_| Msg::ClickedDeleteUserAttribute)}>
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
</button>
{self.show_modal(ctx)}
</>
}
}
}
impl DeleteUserAttribute {
fn show_modal(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
html! {
<div
class="modal fade"
id={"deleteUserAttributeModal".to_string() + &ctx.props().attribute_name}
tabindex="-1"
aria-labelledby="deleteUserAttributeModalLabel"
aria-hidden="true"
ref={self.node_ref.clone()}>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteUserAttributeModalLabel">{"Delete user attribute?"}</h5>
<button
type="button"
class="btn-close"
aria-label="Close"
onclick={link.callback(|_| Msg::DismissModal)} />
</div>
<div class="modal-body">
<span>
{"Are you sure you want to delete user attribute "}
<b>{&ctx.props().attribute_name}</b>{"?"}
</span>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
onclick={link.callback(|_| Msg::DismissModal)}>
<i class="bi-x-circle me-2"></i>
{"Cancel"}
</button>
<button
type="button"
onclick={link.callback(|_| Msg::ConfirmDeleteUserAttribute)}
class="btn btn-danger">
<i class="bi-check-circle me-2"></i>
{"Yes, I'm sure"}
</button>
</div>
</div>
</div>
</div>
}
}
}

View File

@@ -4,8 +4,10 @@ pub mod app;
pub mod change_password;
pub mod create_group;
pub mod create_user;
pub mod create_user_attribute;
pub mod delete_group;
pub mod delete_user;
pub mod delete_user_attribute;
pub mod group_details;
pub mod group_table;
pub mod login;
@@ -15,6 +17,7 @@ pub mod reset_password_step1;
pub mod reset_password_step2;
pub mod router;
pub mod select;
pub mod user_attributes_table;
pub mod user_details;
pub mod user_details_form;
pub mod user_table;

View File

@@ -24,6 +24,10 @@ pub enum AppRoute {
GroupDetails { group_id: i64 },
#[at("/")]
Index,
#[at("/user-attributes")]
ListUserAttributes,
#[at("/user-attributes/create")]
CreateUserAttribute,
}
pub type Link = yew_router::components::Link<AppRoute>;

View File

@@ -0,0 +1,160 @@
use crate::{
components::delete_user_attribute::DeleteUserAttribute,
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{Error, Result};
use graphql_client::GraphQLQuery;
use yew::prelude::*;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/get_user_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct GetUserAttributesSchema;
use get_user_attributes_schema::ResponseData;
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
pub type AttributeType = get_user_attributes_schema::AttributeType;
pub struct UserAttributesTable {
common: CommonComponentParts<Self>,
attributes: Option<Vec<Attribute>>,
}
pub enum Msg {
ListAttributesResponse(Result<ResponseData>),
OnAttributeDeleted(String),
OnError(Error),
}
impl CommonComponent<UserAttributesTable> for UserAttributesTable {
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ListAttributesResponse(schema) => {
self.attributes = Some(schema?.schema.user_schema.attributes.into_iter().collect());
Ok(true)
}
Msg::OnError(e) => Err(e),
Msg::OnAttributeDeleted(attribute_name) => {
debug_assert!(self.attributes.is_some());
self.attributes
.as_mut()
.unwrap()
.retain(|a| a.name != attribute_name);
Ok(true)
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for UserAttributesTable {
type Message = Msg;
type Properties = ();
fn create(ctx: &Context<Self>) -> Self {
let mut table = UserAttributesTable {
common: CommonComponentParts::<Self>::create(),
attributes: None,
};
table.common.call_graphql::<GetUserAttributesSchema, _>(
ctx,
get_user_attributes_schema::Variables {},
Msg::ListAttributesResponse,
"Error trying to fetch user schema",
);
table
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<div>
{self.view_attributes(ctx)}
{self.view_errors()}
</div>
}
}
}
impl UserAttributesTable {
fn view_attributes(&self, ctx: &Context<Self>) -> Html {
let make_table = |attributes: &Vec<Attribute>| {
html! {
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{"Attribute name"}</th>
<th>{"Type"}</th>
<th>{"Editable"}</th>
<th>{"Visible"}</th>
<th>{"Delete"}</th>
</tr>
</thead>
<tbody>
{attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::<Vec<_>>()}
</tbody>
</table>
</div>
}
};
match &self.attributes {
None => html! {{"Loading..."}},
Some(attributes) => make_table(attributes),
}
}
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
let link = ctx.link();
let attribute_type = match attribute.attribute_type {
AttributeType::STRING => "String",
AttributeType::INTEGER => "Integer",
AttributeType::JPEG_PHOTO => "Jpeg",
AttributeType::DATE_TIME => "DateTime",
_ => "Unknown",
};
let checkmark = html! {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
</svg>
};
html! {
<tr key={attribute.name.clone()}>
<td>{&attribute.name}</td>
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
<td>{if attribute.is_editable {checkmark.clone()} else {html!{}}}</td>
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
<td>{if attribute.is_hardcoded {html!{
<button
class="btn btn-danger"
disabled=true>
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
</button>
}} else {html!{
<DeleteUserAttribute
attribute_name={attribute.name.clone()}
on_attribute_deleted={link.callback(Msg::OnAttributeDeleted)}
on_error={link.callback(Msg::OnError)}/>
}}}</td>
</tr>
}
}
fn view_errors(&self) -> Html {
match &self.common.error {
None => html! {},
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
}
}
}