frontend: policydata: component to manage the policy data elements
* policydata_view: GUI the user will interact with (orbtk Widget) will act on policy element lists, that itself will combine policy data (e.g. a vector of policy_code elements, metadata elements) * policydata_state: rust code with helper methods Signed-off-by: Ralf Zerres <ralf.zerres@networkx.de>
This commit is contained in:
252
frontend/src/policydata_state.rs
Normal file
252
frontend/src/policydata_state.rs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
use orbtk::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
base_state::BaseState,
|
||||||
|
data::{PolicyData, PolicyList},
|
||||||
|
keys::*,
|
||||||
|
};
|
||||||
|
use chrono::{Local, NaiveDateTime};
|
||||||
|
//use chrono::{DateTime, Local, TimeZone, NaiveDateTime, Utc};
|
||||||
|
|
||||||
|
/// Actions that can execute on the task view.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Action {
|
||||||
|
InputTextChanged(Entity),
|
||||||
|
NavigateBack(),
|
||||||
|
NewEntry(Entity),
|
||||||
|
RemoveFocus(Entity),
|
||||||
|
RemoveEntry(usize),
|
||||||
|
SetEntry(Entity),
|
||||||
|
StatusChanged(Entity, usize),
|
||||||
|
TextChanged(Entity, usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the requests of the `OverviewView`.
|
||||||
|
#[derive(Default, AsAny)]
|
||||||
|
pub struct PolicyDataState {
|
||||||
|
action:Option<Action>,
|
||||||
|
add_button: Entity,
|
||||||
|
back_entity: Entity,
|
||||||
|
last_focused: Option<Entity>,
|
||||||
|
pub text_box: Entity,
|
||||||
|
open: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// implement of the BaseState trait for our PolicyDataState type
|
||||||
|
impl BaseState for PolicyDataState {}
|
||||||
|
|
||||||
|
/// implement the PolicyDateState type itself
|
||||||
|
impl PolicyDataState {
|
||||||
|
/// Sets a new action.
|
||||||
|
pub fn action(&mut self, action: Action) {
|
||||||
|
self.action = action.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// create a new policy data member
|
||||||
|
fn new_entry(&self, text: String, registry: &mut Registry, ctx: &mut Context) {
|
||||||
|
let index = ctx.widget().clone::<Option<usize>>("list_index");
|
||||||
|
|
||||||
|
if let Some(index) = index {
|
||||||
|
if let Some(policy_list) = ctx
|
||||||
|
.widget()
|
||||||
|
.get_mut::<PolicyData>("policy_data")
|
||||||
|
.get_mut(index)
|
||||||
|
{
|
||||||
|
policy_list.push(PolicyData {
|
||||||
|
policy_code,
|
||||||
|
date_valid_until,
|
||||||
|
selected: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.adjust_count(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save(registry, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// update number of available policy data entries.
|
||||||
|
fn adjust_count(&self, ctx: &mut Context) {
|
||||||
|
if let Some(index) = ctx.widget().clone::<Option<usize>>("list_index") {
|
||||||
|
if let Some(policy_list) = ctx
|
||||||
|
.widget()
|
||||||
|
.clone::<PolicyList>("policy_list")
|
||||||
|
.get(index)
|
||||||
|
{
|
||||||
|
ctx.widget().set("policy_count", policy_list.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// navigate to previous policy data entry.
|
||||||
|
fn navigate_back(&mut self, ctx: &mut Context) {
|
||||||
|
ctx.get_widget(self.text_box)
|
||||||
|
.set("name", String16::from(""));
|
||||||
|
self.open = false;
|
||||||
|
ctx.widget().set::<Option<usize>>("list_index", None);
|
||||||
|
ctx.widget().set("policy_count", 0 as usize);
|
||||||
|
self.navigate(self.back_entity, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If policy data element 'name' is empty, disable the add button
|
||||||
|
/// otherwise otherwise enabled it.
|
||||||
|
fn adjust_add_button_enabled(&self, text_box: Entity, ctx: &mut Context) {
|
||||||
|
if ctx.get_widget(text_box).get::<String16>("name").is_empty() {
|
||||||
|
ctx.get_widget(self.add_button).set("enabled", false);
|
||||||
|
} else {
|
||||||
|
ctx.get_widget(self.add_button).set("enabled", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.get_widget(self.add_button).update_theme_by_state(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle the invalid element of the given policy data entry
|
||||||
|
fn toggle_invalid(
|
||||||
|
&self,
|
||||||
|
entry: Entity,
|
||||||
|
index: usize,
|
||||||
|
registry: &mut Registry,
|
||||||
|
ctx: &mut Context,
|
||||||
|
) {
|
||||||
|
let invalid: bool = *ctx.get_widget(entry).get("invalid");
|
||||||
|
|
||||||
|
if let Some(idx) = ctx.widget().clone::<Option<usize>>("list_index") {
|
||||||
|
if let Some(policy_list) = ctx
|
||||||
|
.widget()
|
||||||
|
.get_mut::<PolicyData>("policy_list")
|
||||||
|
.get_mut(idx)
|
||||||
|
{
|
||||||
|
if let Some(task) = policy_list.get_mut(index) {
|
||||||
|
policy.selected = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.save(registry, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open(&mut self, ctx: &mut Context) {
|
||||||
|
if let Some(index) = ctx.widget().clone::<Option<usize>>("list_index") {
|
||||||
|
let mut name: String16 = "".into();
|
||||||
|
let mut policy_count = 0;
|
||||||
|
if let Some(policy_list) = ctx.widget().get::<PolicyList>("policy_list").get(index) {
|
||||||
|
name = String16::from(policy_list.name.as_str());
|
||||||
|
policy_count = policy_list.len();
|
||||||
|
}
|
||||||
|
ctx.widget().set("name", title);
|
||||||
|
ctx.widget().set("policy_count", policy_count);
|
||||||
|
self.open = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change status of given text box to edit mode.
|
||||||
|
fn edit_entry(&self, text_box: Entity, ctx: &mut Context) {
|
||||||
|
if *ctx.get_widget(text_box).get::<bool>("focused") {
|
||||||
|
ctx.get_widget(text_box).set("enabled", false);
|
||||||
|
ctx.push_event_by_window(FocusEvent::RemoveFocus(text_box));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(old_focused_element) = ctx.window().get::<Global>("global").focused_widget {
|
||||||
|
ctx.push_event_by_window(FocusEvent::RemoveFocus(old_focused_element));
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.get_widget(text_box).set("enabled", true);
|
||||||
|
|
||||||
|
// select all
|
||||||
|
ctx.get_widget(text_box)
|
||||||
|
.get_mut::<TextSelection>("text_selection")
|
||||||
|
.start_index = 0;
|
||||||
|
ctx.get_widget(text_box)
|
||||||
|
.get_mut::<TextSelection>("text_selection")
|
||||||
|
.length = ctx.get_widget(text_box).get::<String16>("name").len();
|
||||||
|
ctx.push_event_by_window(FocusEvent::RequestFocus(text_box));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_entry(&self, index: usize, registry: &mut Registry, ctx: &mut Context) {
|
||||||
|
if let Some(idx) = ctx.widget().clone::<Option<usize>>("list_index") {
|
||||||
|
if let Some(policy_list) = ctx
|
||||||
|
.widget()
|
||||||
|
.get_mut::<PolicyList>("policy_list")
|
||||||
|
.get_mut(idx)
|
||||||
|
{
|
||||||
|
policy_list.remove(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.adjust_count(ctx);
|
||||||
|
self.save(registry, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_entry(
|
||||||
|
&self,
|
||||||
|
text_box: Entity,
|
||||||
|
index: usize,
|
||||||
|
registry: &mut Registry,
|
||||||
|
ctx: &mut Context,
|
||||||
|
) {
|
||||||
|
let text: String16 = ctx.get_widget(text_box).clone("text");
|
||||||
|
|
||||||
|
if let Some(idx) = ctx.widget().clone::<Option<usize>>("list_index") {
|
||||||
|
if let Some(policy_list) = ctx
|
||||||
|
.widget()
|
||||||
|
.get_mut::<PolicyList>("policy_list")
|
||||||
|
.get_mut(idx)
|
||||||
|
{
|
||||||
|
if let Some(task) = policy_list.get_mut(index) {
|
||||||
|
task.text = text.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.save(registry, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State for PolicyState {
|
||||||
|
fn init(&mut self, _: &mut Registry, ctx: &mut Context) {
|
||||||
|
self.back_entity = (*ctx.widget().get::<u32>("back_entity")).into();
|
||||||
|
self.add_button = ctx
|
||||||
|
.entity_of_child(ID_POLICY_ADD_BUTTON)
|
||||||
|
.expect("PolicyState.init: Can't find child 'Add button'.");
|
||||||
|
self.text_box = ctx
|
||||||
|
.entity_of_child(ID_POLICY_TEXT_BOX)
|
||||||
|
.expect("PolicyState.init: Can't find child 'Text Box'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, registry: &mut Registry, ctx: &mut Context) {
|
||||||
|
if !self.open {
|
||||||
|
self.open(ctx);
|
||||||
|
}
|
||||||
|
if let Some(action) = self.action {
|
||||||
|
match action {
|
||||||
|
Action::InputTextChanged(text_box) => {
|
||||||
|
self.adjust_add_button_enabled(text_box, ctx);
|
||||||
|
}
|
||||||
|
Action::CreateEntry(entity) => {
|
||||||
|
if let Some(text) = self.fetch_text(ctx, entity) {
|
||||||
|
self.create_entry(name, registry, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::RemoveEntry(index) => {
|
||||||
|
self.remove_entry(index, registry, ctx);
|
||||||
|
}
|
||||||
|
Action::SelectionChanged(entity, index) => {
|
||||||
|
self.toggle_selection(entity, index, registry, ctx);
|
||||||
|
}
|
||||||
|
Action::TextChanged(entity, index) => {
|
||||||
|
self.update_entry(entity, index, registry, ctx);
|
||||||
|
}
|
||||||
|
Action::EditEntry(text_box) => {
|
||||||
|
self.last_focused = Some(text_box);
|
||||||
|
self.edit_entry(text_box, ctx);
|
||||||
|
}
|
||||||
|
Action::RemoveFocus(text_box) => {
|
||||||
|
ctx.get_widget(text_box).set("enabled", false);
|
||||||
|
ctx.push_event_by_window(FocusEvent::RemoveFocus(text_box));
|
||||||
|
}
|
||||||
|
Action::NavigateBack() => {
|
||||||
|
self.navigate_back(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.action = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
280
frontend/src/policydata_view.rs
Normal file
280
frontend/src/policydata_view.rs
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
use orbtk::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
data::PolicyData,
|
||||||
|
keys::*,
|
||||||
|
policy_state::{Action, PolicyState},
|
||||||
|
};
|
||||||
|
|
||||||
|
type ListIndex = Option<usize>;
|
||||||
|
|
||||||
|
widget!(PolicyList<PolicyState> {
|
||||||
|
back_entity: u32,
|
||||||
|
list_index: ListIndex,
|
||||||
|
policy_list: PolicyList,
|
||||||
|
policy_count: usize,
|
||||||
|
name: String16
|
||||||
|
});
|
||||||
|
|
||||||
|
impl Template for PolicyList {
|
||||||
|
fn template(self, id: Entity, ctx: &mut BuildContext) -> Self {
|
||||||
|
// listing the policy elements
|
||||||
|
let items_widget = ItemsWidget::new()
|
||||||
|
.id(ID_POLICY_ITEMS_WIDGET)
|
||||||
|
.v_align("start")
|
||||||
|
.items_builder(move |ctx, index| {
|
||||||
|
let mut policy_code = "".to_string();
|
||||||
|
let mut date_inserted = None;
|
||||||
|
let mut invalid = false;
|
||||||
|
|
||||||
|
if let Some(list_index) = ctx.get_widget(id).clone::<ListIndex>("list_index") {
|
||||||
|
if let Some(task_overview) = ctx
|
||||||
|
.get_widget(id)
|
||||||
|
.get::<PolicyList>(PROP_POLICY_LIST)
|
||||||
|
.get(list_index)
|
||||||
|
{
|
||||||
|
if let Some(policy) = policy_list.get(index) {
|
||||||
|
policy_code = policy.policy_code.clone();
|
||||||
|
date_inserted = policy.date_inserted.clone();
|
||||||
|
invalid = policy.invalid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let text_box = TextBox::new()
|
||||||
|
.text(text)
|
||||||
|
.enabled(false)
|
||||||
|
.v_align("center")
|
||||||
|
.water_mark("Insert text...")
|
||||||
|
.class("inplace")
|
||||||
|
.attach(Grid::column(3))
|
||||||
|
.on_changed(move |ctx, entity| {
|
||||||
|
ctx.get_mut::<PolicyState>(id)
|
||||||
|
.action(Action::TextChanged(entity, index));
|
||||||
|
})
|
||||||
|
.on_activate(move |ctx, entity| {
|
||||||
|
ctx.get_mut::<PolicyState>(id)
|
||||||
|
.action(Action::RemoveFocus(entity));
|
||||||
|
})
|
||||||
|
.build(ctx);
|
||||||
|
|
||||||
|
Grid::new()
|
||||||
|
.height(48.0)
|
||||||
|
.columns(
|
||||||
|
Columns::new()
|
||||||
|
.add(10.0)
|
||||||
|
.add(24.0)
|
||||||
|
.add(8.0)
|
||||||
|
.add("*")
|
||||||
|
.add(8.0)
|
||||||
|
.add(32.0)
|
||||||
|
.add(4.0)
|
||||||
|
.add(32.0)
|
||||||
|
.add(8.0)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
CheckBox::new()
|
||||||
|
.attach(Grid::column(1))
|
||||||
|
.v_align("center")
|
||||||
|
.invalid(invalid)
|
||||||
|
.on_changed(move |ctx, entity| {
|
||||||
|
ctx.get_mut::<PolicyState>(id)
|
||||||
|
.action(Action::StatusChanged(entity, index));
|
||||||
|
})
|
||||||
|
.build(ctx),
|
||||||
|
)
|
||||||
|
.child(text_box)
|
||||||
|
.child(
|
||||||
|
ToggleButton::new()
|
||||||
|
.selected(("focused", text_box))
|
||||||
|
.class(CLASS_ICON_ONLY)
|
||||||
|
.attach(Grid::column(5))
|
||||||
|
.min_size(32.0, 32.0)
|
||||||
|
.v_align("center")
|
||||||
|
.build(ctx),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.class(CLASS_ICON_ONLY)
|
||||||
|
.attach(Grid::column(5))
|
||||||
|
.min_size(32.0, 32.0)
|
||||||
|
.v_align("center")
|
||||||
|
// todo use remove from icons
|
||||||
|
// .icon(material_font_icons::DELETE_FONT_ICON)
|
||||||
|
.icon("")
|
||||||
|
.on_mouse_down(|_, _| true)
|
||||||
|
.on_click(move |ctx, _| {
|
||||||
|
ctx.get_mut::<PolicyState>(id)
|
||||||
|
.action(Action::EditEntry(text_box));
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.build(ctx),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.class("icon_only")
|
||||||
|
.attach(Grid::column(7))
|
||||||
|
.min_size(32.0, 32.0)
|
||||||
|
.v_align("center")
|
||||||
|
// todo use remove from icons
|
||||||
|
// .icon(material_font_icons::DELETE_FONT_ICON)
|
||||||
|
.icon("")
|
||||||
|
.on_mouse_down(|_, _| true)
|
||||||
|
.on_click(move |ctx, _| {
|
||||||
|
ctx.get_mut::<PolicyState>(id)
|
||||||
|
.action(Action::RemoveEntry(index));
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.build(ctx),
|
||||||
|
)
|
||||||
|
.build(ctx)
|
||||||
|
})
|
||||||
|
.count((PROP_POLICY_COUNT, id))
|
||||||
|
.build(ctx);
|
||||||
|
|
||||||
|
let scroll_viewer = ScrollViewer::new()
|
||||||
|
.scroll_viewer_mode(("disabled", "auto"))
|
||||||
|
.child(items_widget)
|
||||||
|
.build(ctx);
|
||||||
|
|
||||||
|
let task_text_box = TextBox::new()
|
||||||
|
.id(ID_POLICY_TEXT_BOX)
|
||||||
|
.attach(Grid::row(4))
|
||||||
|
.v_align("center")
|
||||||
|
.margin((4.0, 0.0, 0.0, 0.0))
|
||||||
|
.lost_focus_on_activation(false)
|
||||||
|
.on_activate(move |ctx, entity| {
|
||||||
|
ctx.get_mut::<PolicyState>(id)
|
||||||
|
.action(Action::CreateEntry(entity));
|
||||||
|
})
|
||||||
|
.on_changed(move |ctx, entity| {
|
||||||
|
ctx.get_mut::<PolicyState>(id)
|
||||||
|
.action(Action::InputTextChanged(entity));
|
||||||
|
})
|
||||||
|
.build(ctx);
|
||||||
|
|
||||||
|
self.name("PolicyList").child(
|
||||||
|
Grid::new()
|
||||||
|
.rows(
|
||||||
|
Rows::new()
|
||||||
|
.add(52.0)
|
||||||
|
.add(1.0)
|
||||||
|
.add("*")
|
||||||
|
.add(1.0)
|
||||||
|
.add("auto")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.columns(
|
||||||
|
Columns::new()
|
||||||
|
.add("*")
|
||||||
|
.add(4.0)
|
||||||
|
.add(36.0)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
// Content
|
||||||
|
.child(
|
||||||
|
Container::new()
|
||||||
|
.attach(Grid::row(2))
|
||||||
|
.attach(Grid::column(0))
|
||||||
|
.attach(Grid::column_span(3))
|
||||||
|
.child(scroll_viewer)
|
||||||
|
.child(
|
||||||
|
ScrollIndicator::new()
|
||||||
|
.padding((0.0, 4.0, 0.0, 0.0))
|
||||||
|
.content_id(items_widget.0)
|
||||||
|
.scroll_offset(scroll_viewer)
|
||||||
|
.build(ctx),
|
||||||
|
)
|
||||||
|
.build(ctx),
|
||||||
|
)
|
||||||
|
// Top Bar
|
||||||
|
.child(
|
||||||
|
Container::new()
|
||||||
|
.class(CLASS_TOP_BAR)
|
||||||
|
.attach(Grid::row(0))
|
||||||
|
.attach(Grid::column(0))
|
||||||
|
.attach(Grid::column_span(3))
|
||||||
|
.child(
|
||||||
|
Grid::new()
|
||||||
|
.columns(
|
||||||
|
Columns::new()
|
||||||
|
.add(32.0)
|
||||||
|
.add(4.0)
|
||||||
|
.add("*")
|
||||||
|
.add(4.0)
|
||||||
|
.add(32.0)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.height(32.0)
|
||||||
|
.icon("")
|
||||||
|
.class(CLASS_ICON_ONLY)
|
||||||
|
.v_align("center")
|
||||||
|
.on_click(move |ctx, _| {
|
||||||
|
ctx.get_mut::<PolicyState>(id)
|
||||||
|
.action(Action::NavigateBack());
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.build(ctx),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
TextBlock::new()
|
||||||
|
.class(CLASS_HEADER)
|
||||||
|
.attach(Grid::column(2))
|
||||||
|
.v_align("center")
|
||||||
|
.h_align("center")
|
||||||
|
.text(("name", id))
|
||||||
|
.build(ctx),
|
||||||
|
)
|
||||||
|
.build(ctx),
|
||||||
|
)
|
||||||
|
.build(ctx),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Container::new()
|
||||||
|
.class("separator")
|
||||||
|
.attach(Grid::row(1))
|
||||||
|
.attach(Grid::column_span(3))
|
||||||
|
.build(ctx),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Container::new()
|
||||||
|
.class("separator")
|
||||||
|
.attach(Grid::row(3))
|
||||||
|
.attach(Grid::column_span(3))
|
||||||
|
.build(ctx),
|
||||||
|
)
|
||||||
|
// Bottom bar
|
||||||
|
.child(
|
||||||
|
Container::new()
|
||||||
|
.class(CLASS_BOTTOM_BAR)
|
||||||
|
.attach(Grid::row(4))
|
||||||
|
.attach(Grid::column(0))
|
||||||
|
.attach(Grid::column_span(3))
|
||||||
|
.build(ctx),
|
||||||
|
)
|
||||||
|
.child(task_text_box)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.id(ID_POLICY_ADD_BUTTON)
|
||||||
|
.class(CLASS_ICON_ONLY)
|
||||||
|
.attach(Grid::row(4))
|
||||||
|
.attach(Grid::column(2))
|
||||||
|
.margin((0.0, 0.0, 4.0, 0.0))
|
||||||
|
.enabled(false)
|
||||||
|
.min_size(32.0, 32.0)
|
||||||
|
.v_align("center")
|
||||||
|
.icon(material_font_icons::ADD_FONT_ICON)
|
||||||
|
.on_click(move |ctx, _| {
|
||||||
|
ctx.get_mut::<PolicyState>(id)
|
||||||
|
.action(Action::CreateEntry(task_text_box));
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.build(ctx),
|
||||||
|
)
|
||||||
|
.build(ctx),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user