mirror of
https://github.com/lldap/lldap.git
synced 2024-11-25 09:06:03 +00:00
app: add user attributes schema page (#802)
This commit is contained in:
parent
c2eed8909a
commit
b78e093205
17 changed files with 816 additions and 1 deletions
|
@ -1,4 +1,4 @@
|
||||||
FROM rust:1.72
|
FROM rust:1.74
|
||||||
|
|
||||||
ARG USERNAME=lldapdev
|
ARG USERNAME=lldapdev
|
||||||
# We need to keep the user as 1001 to match the GitHub runner's UID.
|
# We need to keep the user as 1001 to match the GitHub runner's UID.
|
||||||
|
|
5
app/queries/create_user_attribute.graphql
Normal file
5
app/queries/create_user_attribute.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
5
app/queries/delete_user_attribute.graphql
Normal file
5
app/queries/delete_user_attribute.graphql
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
mutation DeleteUserAttributeQuery($name: String!) {
|
||||||
|
deleteUserAttribute(name: $name) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
14
app/queries/get_user_attributes_schema.graphql
Normal file
14
app/queries/get_user_attributes_schema.graphql
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
query GetUserAttributesSchema {
|
||||||
|
schema {
|
||||||
|
userSchema {
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
attributeType
|
||||||
|
isList
|
||||||
|
isVisible
|
||||||
|
isEditable
|
||||||
|
isHardcoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ use crate::{
|
||||||
change_password::ChangePasswordForm,
|
change_password::ChangePasswordForm,
|
||||||
create_group::CreateGroupForm,
|
create_group::CreateGroupForm,
|
||||||
create_user::CreateUserForm,
|
create_user::CreateUserForm,
|
||||||
|
create_user_attribute::CreateUserAttributeForm,
|
||||||
group_details::GroupDetails,
|
group_details::GroupDetails,
|
||||||
group_table::GroupTable,
|
group_table::GroupTable,
|
||||||
login::LoginForm,
|
login::LoginForm,
|
||||||
|
@ -11,6 +12,7 @@ use crate::{
|
||||||
reset_password_step2::ResetPasswordStep2Form,
|
reset_password_step2::ResetPasswordStep2Form,
|
||||||
router::{AppRoute, Link, Redirect},
|
router::{AppRoute, Link, Redirect},
|
||||||
user_details::UserDetails,
|
user_details::UserDetails,
|
||||||
|
user_schema_table::ListUserSchema,
|
||||||
user_table::UserTable,
|
user_table::UserTable,
|
||||||
},
|
},
|
||||||
infra::{api::HostService, cookies::get_cookie},
|
infra::{api::HostService, cookies::get_cookie},
|
||||||
|
@ -227,6 +229,9 @@ impl App {
|
||||||
AppRoute::CreateGroup => html! {
|
AppRoute::CreateGroup => html! {
|
||||||
<CreateGroupForm/>
|
<CreateGroupForm/>
|
||||||
},
|
},
|
||||||
|
AppRoute::CreateUserAttribute => html! {
|
||||||
|
<CreateUserAttributeForm/>
|
||||||
|
},
|
||||||
AppRoute::ListGroups => html! {
|
AppRoute::ListGroups => html! {
|
||||||
<div>
|
<div>
|
||||||
<GroupTable />
|
<GroupTable />
|
||||||
|
@ -236,6 +241,9 @@ impl App {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
},
|
},
|
||||||
|
AppRoute::ListUserSchema => html! {
|
||||||
|
<ListUserSchema />
|
||||||
|
},
|
||||||
AppRoute::GroupDetails { group_id } => html! {
|
AppRoute::GroupDetails { group_id } => html! {
|
||||||
<GroupDetails group_id={*group_id} />
|
<GroupDetails group_id={*group_id} />
|
||||||
},
|
},
|
||||||
|
@ -291,6 +299,14 @@ impl App {
|
||||||
{"Groups"}
|
{"Groups"}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="nav-link px-2 h6"
|
||||||
|
to={AppRoute::ListUserSchema}>
|
||||||
|
<i class="bi-list-ul me-2"></i>
|
||||||
|
{"User schema"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
</>
|
</>
|
||||||
} } else { html!{} } }
|
} } else { html!{} } }
|
||||||
</ul>
|
</ul>
|
||||||
|
|
186
app/src/components/create_user_attribute.rs
Normal file
186
app/src/components/create_user_attribute.rs
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||||
|
router::AppRoute,
|
||||||
|
},
|
||||||
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
schema::AttributeType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use gloo_console::log;
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use validator::ValidationError;
|
||||||
|
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;
|
||||||
|
|
||||||
|
convert_attribute_type!(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(custom = "validate_attribute_type")]
|
||||||
|
attribute_type: String,
|
||||||
|
is_editable: bool,
|
||||||
|
is_list: bool,
|
||||||
|
is_visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
|
||||||
|
let result = AttributeType::from_str(attribute_type);
|
||||||
|
match result {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
_ => Err(ValidationError::new("Invalid attribute type")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = model.attribute_type.parse::<AttributeType>().unwrap();
|
||||||
|
let req = create_user_attribute::Variables {
|
||||||
|
name: model.attribute_name,
|
||||||
|
attribute_type: create_user_attribute::AttributeType::from(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::ListUserSchema);
|
||||||
|
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 model = CreateUserAttributeModel {
|
||||||
|
attribute_type: AttributeType::String.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
html! {
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<form class="form py-3" style="max-width: 636px">
|
||||||
|
<h5 class="fw-bold">{"Create a user attribute"}</h5>
|
||||||
|
<Field<CreateUserAttributeModel>
|
||||||
|
label="Name"
|
||||||
|
required={true}
|
||||||
|
form={&self.form}
|
||||||
|
field_name="attribute_name"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
<Select<CreateUserAttributeModel>
|
||||||
|
label="Type"
|
||||||
|
required={true}
|
||||||
|
form={&self.form}
|
||||||
|
field_name="attribute_name"
|
||||||
|
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<CreateUserAttributeModel>>
|
||||||
|
<CheckBox<CreateUserAttributeModel>
|
||||||
|
label="Multiple values"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_list"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<CheckBox<CreateUserAttributeModel>
|
||||||
|
label="Visible to users"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_visible"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<CheckBox<CreateUserAttributeModel>
|
||||||
|
label="Editable by users"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_editable"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<Submit
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}/>
|
||||||
|
</form>
|
||||||
|
{ if let Some(e) = &self.common.error {
|
||||||
|
html! {
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{e.to_string() }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
172
app/src/components/delete_user_attribute.rs
Normal file
172
app/src/components/delete_user_attribute.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
35
app/src/components/form/checkbox.rs
Normal file
35
app/src/components/form/checkbox.rs
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
use yew::{function_component, html, virtual_dom::AttrValue, Callback, Properties};
|
||||||
|
use yew_form::{Form, Model};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props<T: Model> {
|
||||||
|
pub label: AttrValue,
|
||||||
|
pub field_name: String,
|
||||||
|
pub form: Form<T>,
|
||||||
|
#[prop_or(false)]
|
||||||
|
pub required: bool,
|
||||||
|
#[prop_or_else(Callback::noop)]
|
||||||
|
pub ontoggle: Callback<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(CheckBox)]
|
||||||
|
pub fn checkbox<T: Model>(props: &Props<T>) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="form-group row mb-3">
|
||||||
|
<label for={props.field_name.clone()}
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{&props.label}
|
||||||
|
{if props.required {
|
||||||
|
html!{<span class="text-danger">{"*"}</span>}
|
||||||
|
} else {html!{}}}
|
||||||
|
{":"}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<yew_form::CheckBox<T>
|
||||||
|
form={&props.form}
|
||||||
|
field_name={props.field_name.clone()}
|
||||||
|
ontoggle={props.ontoggle.clone()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
42
app/src/components/form/field.rs
Normal file
42
app/src/components/form/field.rs
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
use yew::{function_component, html, virtual_dom::AttrValue, Callback, InputEvent, Properties};
|
||||||
|
use yew_form::{Form, Model};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props<T: Model> {
|
||||||
|
pub label: AttrValue,
|
||||||
|
pub field_name: String,
|
||||||
|
pub form: Form<T>,
|
||||||
|
#[prop_or(false)]
|
||||||
|
pub required: bool,
|
||||||
|
#[prop_or_else(Callback::noop)]
|
||||||
|
pub oninput: Callback<InputEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Field)]
|
||||||
|
pub fn field<T: Model>(props: &Props<T>) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label for={props.field_name.clone()}
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{&props.label}
|
||||||
|
{if props.required {
|
||||||
|
html!{<span class="text-danger">{"*"}</span>}
|
||||||
|
} else {html!{}}}
|
||||||
|
{":"}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<yew_form::Field<T>
|
||||||
|
form={&props.form}
|
||||||
|
field_name={props.field_name.clone()}
|
||||||
|
class="form-control"
|
||||||
|
class_invalid="is-invalid has-error"
|
||||||
|
class_valid="has-success"
|
||||||
|
autocomplete={props.field_name.clone()}
|
||||||
|
oninput={&props.oninput} />
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
{&props.form.field_message(&props.field_name)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
4
app/src/components/form/mod.rs
Normal file
4
app/src/components/form/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod checkbox;
|
||||||
|
pub mod field;
|
||||||
|
pub mod select;
|
||||||
|
pub mod submit;
|
46
app/src/components/form/select.rs
Normal file
46
app/src/components/form/select.rs
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
use yew::{
|
||||||
|
function_component, html, virtual_dom::AttrValue, Callback, Children, InputEvent, Properties,
|
||||||
|
};
|
||||||
|
use yew_form::{Form, Model};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props<T: Model> {
|
||||||
|
pub label: AttrValue,
|
||||||
|
pub field_name: String,
|
||||||
|
pub form: Form<T>,
|
||||||
|
#[prop_or(false)]
|
||||||
|
pub required: bool,
|
||||||
|
#[prop_or_else(Callback::noop)]
|
||||||
|
pub oninput: Callback<InputEvent>,
|
||||||
|
pub children: Children,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Select)]
|
||||||
|
pub fn select<T: Model>(props: &Props<T>) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label for={props.field_name.clone()}
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{&props.label}
|
||||||
|
{if props.required {
|
||||||
|
html!{<span class="text-danger">{"*"}</span>}
|
||||||
|
} else {html!{}}}
|
||||||
|
{":"}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<yew_form::Select<T>
|
||||||
|
form={&props.form}
|
||||||
|
class="form-control"
|
||||||
|
class_invalid="is-invalid has-error"
|
||||||
|
class_valid="has-success"
|
||||||
|
field_name={props.field_name.clone()}
|
||||||
|
oninput={&props.oninput} >
|
||||||
|
{for props.children.iter()}
|
||||||
|
</yew_form::Select<T>>
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
{&props.form.field_message(&props.field_name)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
24
app/src/components/form/submit.rs
Normal file
24
app/src/components/form/submit.rs
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::{function_component, html, Callback, Properties};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub disabled: bool,
|
||||||
|
pub onclick: Callback<MouseEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Submit)]
|
||||||
|
pub fn submit(props: &Props) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="form-group row justify-content-center">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary col-auto col-form-label"
|
||||||
|
type="submit"
|
||||||
|
disabled={props.disabled}
|
||||||
|
onclick={&props.onclick}>
|
||||||
|
<i class="bi-save me-2"></i>
|
||||||
|
{"Submit"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,8 +4,11 @@ pub mod app;
|
||||||
pub mod change_password;
|
pub mod change_password;
|
||||||
pub mod create_group;
|
pub mod create_group;
|
||||||
pub mod create_user;
|
pub mod create_user;
|
||||||
|
pub mod create_user_attribute;
|
||||||
pub mod delete_group;
|
pub mod delete_group;
|
||||||
pub mod delete_user;
|
pub mod delete_user;
|
||||||
|
pub mod delete_user_attribute;
|
||||||
|
pub mod form;
|
||||||
pub mod group_details;
|
pub mod group_details;
|
||||||
pub mod group_table;
|
pub mod group_table;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
|
@ -17,4 +20,5 @@ pub mod router;
|
||||||
pub mod select;
|
pub mod select;
|
||||||
pub mod user_details;
|
pub mod user_details;
|
||||||
pub mod user_details_form;
|
pub mod user_details_form;
|
||||||
|
pub mod user_schema_table;
|
||||||
pub mod user_table;
|
pub mod user_table;
|
||||||
|
|
|
@ -22,6 +22,10 @@ pub enum AppRoute {
|
||||||
ListGroups,
|
ListGroups,
|
||||||
#[at("/group/:group_id")]
|
#[at("/group/:group_id")]
|
||||||
GroupDetails { group_id: i64 },
|
GroupDetails { group_id: i64 },
|
||||||
|
#[at("/user-attributes")]
|
||||||
|
ListUserSchema,
|
||||||
|
#[at("/user-attributes/create")]
|
||||||
|
CreateUserAttribute,
|
||||||
#[at("/")]
|
#[at("/")]
|
||||||
Index,
|
Index,
|
||||||
}
|
}
|
||||||
|
|
198
app/src/components/user_schema_table.rs
Normal file
198
app/src/components/user_schema_table.rs
Normal file
|
@ -0,0 +1,198 @@
|
||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
delete_user_attribute::DeleteUserAttribute,
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
},
|
||||||
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
schema::AttributeType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::{anyhow, Error, Result};
|
||||||
|
use gloo_console::log;
|
||||||
|
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;
|
||||||
|
|
||||||
|
convert_attribute_type!(get_user_attributes_schema::AttributeType);
|
||||||
|
|
||||||
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub hardcoded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UserSchemaTable {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
attributes: Option<Vec<Attribute>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
ListAttributesResponse(Result<ResponseData>),
|
||||||
|
OnAttributeDeleted(String),
|
||||||
|
OnError(Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<UserSchemaTable> for UserSchemaTable {
|
||||||
|
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) => {
|
||||||
|
match self.attributes {
|
||||||
|
None => {
|
||||||
|
log!(format!("Attribute {attribute_name} was deleted but component has no attributes"));
|
||||||
|
Err(anyhow!("invalid state"))
|
||||||
|
}
|
||||||
|
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 UserSchemaTable {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = Props;
|
||||||
|
|
||||||
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
|
let mut table = UserSchemaTable {
|
||||||
|
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 UserSchemaTable {
|
||||||
|
fn view_attributes(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let hardcoded = ctx.props().hardcoded;
|
||||||
|
let make_table = |attributes: &Vec<Attribute>| {
|
||||||
|
html! {
|
||||||
|
<div class="table-responsive">
|
||||||
|
<h3>{if hardcoded {"Hardcoded"} else {"User-defined"}}{" attributes"}</h3>
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{"Attribute name"}</th>
|
||||||
|
<th>{"Type"}</th>
|
||||||
|
<th>{"Editable"}</th>
|
||||||
|
<th>{"Visible"}</th>
|
||||||
|
{if hardcoded {html!{}} else {html!{<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) => {
|
||||||
|
let mut attributes = attributes.clone();
|
||||||
|
attributes.retain(|attribute| attribute.is_hardcoded == ctx.props().hardcoded);
|
||||||
|
make_table(&attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
|
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
|
||||||
|
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>
|
||||||
|
};
|
||||||
|
let hardcoded = ctx.props().hardcoded;
|
||||||
|
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>
|
||||||
|
{
|
||||||
|
if hardcoded {
|
||||||
|
html!{}
|
||||||
|
} else {
|
||||||
|
html!{
|
||||||
|
<td>
|
||||||
|
<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>},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(ListUserSchema)]
|
||||||
|
pub fn list_user_schema() -> Html {
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<UserSchemaTable hardcoded={true} />
|
||||||
|
<UserSchemaTable hardcoded={false} />
|
||||||
|
<Link classes="btn btn-primary" to={AppRoute::CreateUserAttribute}>
|
||||||
|
<i class="bi-plus-circle me-2"></i>
|
||||||
|
{"Create an attribute"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,3 +3,4 @@ pub mod common_component;
|
||||||
pub mod cookies;
|
pub mod cookies;
|
||||||
pub mod graphql;
|
pub mod graphql;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
|
pub mod schema;
|
||||||
|
|
59
app/src/infra/schema.rs
Normal file
59
app/src/infra/schema.rs
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::{fmt::Display, str::FromStr};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AttributeType {
|
||||||
|
String,
|
||||||
|
Integer,
|
||||||
|
DateTime,
|
||||||
|
Jpeg,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for AttributeType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{:?}", self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for AttributeType {
|
||||||
|
type Err = ();
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"String" => Ok(AttributeType::String),
|
||||||
|
"Integer" => Ok(AttributeType::Integer),
|
||||||
|
"DateTime" => Ok(AttributeType::DateTime),
|
||||||
|
"Jpeg" => Ok(AttributeType::Jpeg),
|
||||||
|
_ => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Macro to generate traits for converting between AttributeType and the
|
||||||
|
// graphql generated equivalents.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! convert_attribute_type {
|
||||||
|
($source_type:ty) => {
|
||||||
|
impl From<$source_type> for AttributeType {
|
||||||
|
fn from(value: $source_type) -> Self {
|
||||||
|
match value {
|
||||||
|
<$source_type>::STRING => AttributeType::String,
|
||||||
|
<$source_type>::INTEGER => AttributeType::Integer,
|
||||||
|
<$source_type>::DATE_TIME => AttributeType::DateTime,
|
||||||
|
<$source_type>::JPEG_PHOTO => AttributeType::Jpeg,
|
||||||
|
_ => panic!("Unknown attribute type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AttributeType> for $source_type {
|
||||||
|
fn from(value: AttributeType) -> Self {
|
||||||
|
match value {
|
||||||
|
AttributeType::String => <$source_type>::STRING,
|
||||||
|
AttributeType::Integer => <$source_type>::INTEGER,
|
||||||
|
AttributeType::DateTime => <$source_type>::DATE_TIME,
|
||||||
|
AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
Loading…
Reference in a new issue