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
|
||||
# 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,
|
||||
create_group::CreateGroupForm,
|
||||
create_user::CreateUserForm,
|
||||
create_user_attribute::CreateUserAttributeForm,
|
||||
group_details::GroupDetails,
|
||||
group_table::GroupTable,
|
||||
login::LoginForm,
|
||||
|
@ -11,6 +12,7 @@ use crate::{
|
|||
reset_password_step2::ResetPasswordStep2Form,
|
||||
router::{AppRoute, Link, Redirect},
|
||||
user_details::UserDetails,
|
||||
user_schema_table::ListUserSchema,
|
||||
user_table::UserTable,
|
||||
},
|
||||
infra::{api::HostService, cookies::get_cookie},
|
||||
|
@ -227,6 +229,9 @@ impl App {
|
|||
AppRoute::CreateGroup => html! {
|
||||
<CreateGroupForm/>
|
||||
},
|
||||
AppRoute::CreateUserAttribute => html! {
|
||||
<CreateUserAttributeForm/>
|
||||
},
|
||||
AppRoute::ListGroups => html! {
|
||||
<div>
|
||||
<GroupTable />
|
||||
|
@ -236,6 +241,9 @@ impl App {
|
|||
</Link>
|
||||
</div>
|
||||
},
|
||||
AppRoute::ListUserSchema => html! {
|
||||
<ListUserSchema />
|
||||
},
|
||||
AppRoute::GroupDetails { group_id } => html! {
|
||||
<GroupDetails group_id={*group_id} />
|
||||
},
|
||||
|
@ -291,6 +299,14 @@ impl App {
|
|||
{"Groups"}
|
||||
</Link>
|
||||
</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!{} } }
|
||||
</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 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 form;
|
||||
pub mod group_details;
|
||||
pub mod group_table;
|
||||
pub mod login;
|
||||
|
@ -17,4 +20,5 @@ pub mod router;
|
|||
pub mod select;
|
||||
pub mod user_details;
|
||||
pub mod user_details_form;
|
||||
pub mod user_schema_table;
|
||||
pub mod user_table;
|
||||
|
|
|
@ -22,6 +22,10 @@ pub enum AppRoute {
|
|||
ListGroups,
|
||||
#[at("/group/:group_id")]
|
||||
GroupDetails { group_id: i64 },
|
||||
#[at("/user-attributes")]
|
||||
ListUserSchema,
|
||||
#[at("/user-attributes/create")]
|
||||
CreateUserAttribute,
|
||||
#[at("/")]
|
||||
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 graphql;
|
||||
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