Remove last in-use builder

This commit is contained in:
Adrian Woźniak 2021-04-16 16:46:50 +02:00
parent 2b40f9fd91
commit 041078b7c9
13 changed files with 161 additions and 377 deletions

View File

@ -27,7 +27,7 @@ impl StyledCheckboxState {
#[derive(Debug)]
pub struct ChildBuilder<'l> {
pub field_id: Option<FieldId>,
pub field_id: FieldId,
pub name: &'l str,
pub label: &'l str,
pub value: u32,
@ -38,7 +38,7 @@ pub struct ChildBuilder<'l> {
impl<'l> Default for ChildBuilder<'l> {
fn default() -> Self {
Self {
field_id: None,
field_id: FieldId::TextFilterBoard,
name: "",
label: "",
value: 0,
@ -48,38 +48,6 @@ impl<'l> Default for ChildBuilder<'l> {
}
}
impl<'l> ChildBuilder<'l> {
pub fn value(mut self, value: u32) -> Self {
self.value = value;
self
}
pub fn name(mut self, name: &'l str) -> Self {
self.name = name;
self
}
pub fn label(mut self, label: &'l str) -> Self {
self.label = label;
self
}
pub fn with_id(mut self, id: FieldId) -> Self {
self.field_id = Some(id);
self
}
pub fn try_select(mut self, value: u32) -> Self {
self.selected = self.value == value;
self
}
pub fn class_list(mut self, name: &'l str) -> Self {
self.class_list = name;
self
}
}
impl<'l> ToNode for ChildBuilder<'l> {
fn into_node(self) -> Node<Msg> {
let ChildBuilder {
@ -91,18 +59,12 @@ impl<'l> ToNode for ChildBuilder<'l> {
class_list,
} = self;
let id = field_id.as_ref().map(|f| f.to_string()).unwrap_or_default();
let field_id_clone = field_id.as_ref().cloned();
let id = field_id.to_string();
let field_id_clone = field_id.clone();
let handler: EventHandler<Msg> = mouse_ev(Ev::Click, move |_| {
field_id_clone.map(|field_id| Msg::U32InputChanged(field_id, value))
Msg::U32InputChanged(field_id_clone, value)
});
let input_attrs = if selected {
attrs![At::Type => "radio", At::Name => name, At::Checked => selected, At::Id => format!("{}-{}", id, name)]
} else {
attrs![At::Type => "radio", At::Name => name, At::Id => format!("{}-{}", id, name)]
};
div![
C![
"styledCheckboxChild",
@ -111,7 +73,10 @@ impl<'l> ToNode for ChildBuilder<'l> {
],
handler,
label![attrs![At::For => format!("{}-{}", id, name)], label],
input![input_attrs],
input![
attrs![At::Type => "radio", At::Name => name, At::Id => format!("{}-{}", id, name)],
IF![selected => attrs!(At::Checked => selected)]
],
]
}
}
@ -121,10 +86,20 @@ pub struct StyledCheckbox<'l, Options>
where
Options: Iterator<Item = ChildBuilder<'l>>,
{
id: FieldId,
options: Option<Options>,
selected: u32,
class_list: Vec<&'l str>,
pub options: Option<Options>,
pub class_list: &'l str,
}
impl<'l, Options> Default for StyledCheckbox<'l, Options>
where
Options: Iterator<Item = ChildBuilder<'l>>,
{
fn default() -> Self {
Self {
options: None,
class_list: "",
}
}
}
impl<'l, Options> ToNode for StyledCheckbox<'l, Options>
@ -136,78 +111,19 @@ where
}
}
impl<'l, Options> StyledCheckbox<'l, Options>
where
Options: Iterator<Item = ChildBuilder<'l>>,
{
pub fn build() -> StyledCheckboxBuilder<'l, Options> {
StyledCheckboxBuilder {
options: None,
selected: 0,
class_list: vec![],
}
}
}
pub struct StyledCheckboxBuilder<'l, Options>
where
Options: Iterator<Item = ChildBuilder<'l>>,
{
options: Option<Options>,
selected: u32,
class_list: Vec<&'l str>,
}
impl<'l, Options> StyledCheckboxBuilder<'l, Options>
where
Options: Iterator<Item = ChildBuilder<'l>>,
{
pub fn state(mut self, state: &StyledCheckboxState) -> Self {
self.selected = state.value;
self
}
pub fn add_class(mut self, name: &'l str) -> Self {
self.class_list.push(name);
self
}
pub fn options(mut self, options: Options) -> Self {
self.options = Some(options);
self
}
pub fn build(self, field_id: FieldId) -> StyledCheckbox<'l, Options> {
StyledCheckbox {
id: field_id,
options: self.options,
selected: self.selected,
class_list: self.class_list,
}
}
}
fn render<'l, Options>(values: StyledCheckbox<'l, Options>) -> Node<Msg>
where
Options: Iterator<Item = ChildBuilder<'l>>,
{
let StyledCheckbox {
id,
options,
selected,
class_list,
} = values;
let opt: Vec<Node<Msg>> = match options {
Some(options) => options
.map(|child| child.with_id(id.clone()).try_select(selected).into_node())
.collect(),
Some(options) => options.map(|child| child.into_node()).collect(),
_ => vec![Node::Empty],
};
div![
C!["styledCheckbox"],
attrs![At::Class => class_list.join(" ")],
opt,
]
div![C!["styledCheckbox", class_list], opt,]
}

View File

@ -32,64 +32,12 @@ impl<'l> Default for StyledConfirmModal<'l> {
}
}
impl<'l> StyledConfirmModal<'l> {
pub fn build() -> StyledConfirmModalBuilder<'l> {
StyledConfirmModalBuilder::default()
}
}
impl<'l> ToNode for StyledConfirmModal<'l> {
fn into_node(self) -> Node<Msg> {
render(self)
}
}
#[derive(Default)]
pub struct StyledConfirmModalBuilder<'l> {
title: Option<&'l str>,
message: Option<&'l str>,
confirm_text: Option<&'l str>,
cancel_text: Option<&'l str>,
on_confirm: Option<EventHandler<Msg>>,
}
impl<'l> StyledConfirmModalBuilder<'l> {
pub fn title(mut self, title: &'l str) -> Self {
self.title = Some(title);
self
}
pub fn message(mut self, message: &'l str) -> Self {
self.message = Some(message);
self
}
pub fn confirm_text(mut self, confirm_text: &'l str) -> Self {
self.confirm_text = Some(confirm_text);
self
}
pub fn cancel_text(mut self, cancel_text: &'l str) -> Self {
self.cancel_text = Some(cancel_text);
self
}
pub fn on_confirm(mut self, on_confirm: EventHandler<Msg>) -> Self {
self.on_confirm = Some(on_confirm);
self
}
pub fn build(self) -> StyledConfirmModal<'l> {
StyledConfirmModal {
title: self.title.unwrap_or(TITLE),
message: self.message.unwrap_or(MESSAGE),
confirm_text: self.confirm_text.unwrap_or(CONFIRM_TEXT),
cancel_text: self.cancel_text.unwrap_or(CANCEL_TEXT),
on_confirm: self.on_confirm,
}
}
}
pub fn render(values: StyledConfirmModal) -> Node<Msg> {
let StyledConfirmModal {
title,
@ -114,7 +62,7 @@ pub fn render(values: StyledConfirmModal) -> Node<Msg> {
let message_node = match message {
_ if message.is_empty() => empty![],
_ => p![attrs![At::Class => "message"], message],
_ => p![C!["message"], message],
};
let confirm_button = StyledButton {

View File

@ -21,8 +21,8 @@ pub enum StyledDateTimeChanged {
#[derive(Clone, Debug, PartialOrd, PartialEq)]
pub struct StyledDateTimeInputState {
field_id: FieldId,
timestamp: Option<chrono::NaiveDateTime>,
popup_visible: bool,
pub timestamp: Option<chrono::NaiveDateTime>,
pub popup_visible: bool,
}
impl StyledDateTimeInputState {
@ -70,18 +70,9 @@ impl StyledDateTimeInputState {
}
pub struct StyledDateTimeInput {
field_id: FieldId,
timestamp: Option<chrono::NaiveDateTime>,
popup_visible: bool,
}
impl StyledDateTimeInput {
pub fn build() -> StyledDateTimeInputBuilder {
StyledDateTimeInputBuilder {
timestamp: None,
popup_visible: false,
}
}
pub field_id: FieldId,
pub timestamp: Option<chrono::NaiveDateTime>,
pub popup_visible: bool,
}
impl ToNode for StyledDateTimeInput {
@ -90,27 +81,6 @@ impl ToNode for StyledDateTimeInput {
}
}
pub struct StyledDateTimeInputBuilder {
timestamp: Option<chrono::NaiveDateTime>,
popup_visible: bool,
}
impl StyledDateTimeInputBuilder {
pub fn state(mut self, state: &StyledDateTimeInputState) -> Self {
self.timestamp = state.timestamp;
self.popup_visible = state.popup_visible;
self
}
pub fn build(self, field_id: FieldId) -> StyledDateTimeInput {
StyledDateTimeInput {
field_id,
timestamp: self.timestamp,
popup_visible: self.popup_visible,
}
}
}
fn render(values: StyledDateTimeInput) -> Node<Msg> {
let timestamp = values
.timestamp

View File

@ -4,20 +4,13 @@ use seed::*;
use crate::shared::ToNode;
use crate::Msg;
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct StyledForm<'l> {
pub heading: &'l str,
pub fields: Vec<Node<Msg>>,
pub on_submit: Option<EventHandler<Msg>>,
}
impl<'l> StyledForm<'l> {
#[inline]
pub fn build() -> StyledFormBuilder<'l> {
StyledFormBuilder::default()
}
}
impl<'l> ToNode for StyledForm<'l> {
#[inline]
fn into_node(self) -> Node<Msg> {
@ -25,50 +18,6 @@ impl<'l> ToNode for StyledForm<'l> {
}
}
#[derive(Debug, Default)]
pub struct StyledFormBuilder<'l> {
fields: Vec<Node<Msg>>,
heading: &'l str,
on_submit: Option<EventHandler<Msg>>,
}
impl<'l> StyledFormBuilder<'l> {
#[inline]
pub fn add_field(mut self, node: Node<Msg>) -> Self {
self.fields.push(node);
self
}
#[inline]
pub fn try_field(mut self, node: Option<Node<Msg>>) -> Self {
if let Some(n) = node {
self.fields.push(n);
}
self
}
#[inline]
pub fn heading(mut self, heading: &'l str) -> Self {
self.heading = heading;
self
}
#[inline]
pub fn on_submit(mut self, on_submit: EventHandler<Msg>) -> Self {
self.on_submit = Some(on_submit);
self
}
#[inline]
pub fn build(self) -> StyledForm<'l> {
StyledForm {
heading: self.heading,
fields: self.fields,
on_submit: self.on_submit,
}
}
}
#[inline]
pub fn render(values: StyledForm) -> Node<Msg> {
let StyledForm {

View File

@ -69,7 +69,7 @@ impl StyledSelectState {
}
}
pub fn update(&mut self, msg: &Msg, orders: &mut impl Orders<Msg>) {
pub fn update(&mut self, msg: &Msg, _orders: &mut impl Orders<Msg>) {
let field_id = match msg {
Msg::StyledSelectChanged(field_id, ..) => field_id,
_ => return,
@ -85,7 +85,6 @@ impl StyledSelectState {
}
}
Msg::StyledSelectChanged(_, StyledSelectChanged::Text(text)) => {
orders.skip();
self.text_filter = text.clone();
}
Msg::StyledSelectChanged(_, StyledSelectChanged::Changed(Some(v))) => {

View File

@ -8,23 +8,9 @@ use crate::components::styled_icon::Icon;
use crate::components::styled_input::*;
use crate::components::styled_modal::*;
use crate::modals::epics_edit::Model;
use crate::shared::{IntoChild, ToNode};
use crate::shared::ToNode;
use crate::{model, FieldId, Msg};
pub struct IssueTypeWrapper(IssueType);
impl<'l> IntoChild<'l> for IssueTypeWrapper {
type Builder = ChildBuilder<'l>;
fn into_child(self) -> Self::Builder {
Self::Builder::default()
.label(self.0.to_label())
.name(self.0.to_str())
.value(self.0.into())
.class_list(self.0.to_str())
}
}
pub fn view(_model: &model::Model, modal: &Model) -> Node<Msg> {
let transform = if modal.related_issues.is_empty() {
transform_into_available(modal)
@ -62,15 +48,15 @@ pub fn view(_model: &model::Model, modal: &Model) -> Node<Msg> {
}
fn transform_into_available(modal: &super::Model) -> Node<Msg> {
let types = StyledCheckbox::build()
.options(
let types = StyledCheckbox {
options: Some(
IssueType::default()
.into_iter()
.map(issue_type_select_option),
)
.state(&modal.transform_into)
.build(FieldId::EditEpic(EpicFieldId::TransformInto))
.into_node();
.map(|it| issue_type_select_option(it, &modal.transform_into)),
),
..Default::default()
}
.into_node();
let execute = StyledButton {
on_click: Some(mouse_ev("click", |ev| {
ev.stop_propagation();
@ -85,13 +71,15 @@ fn transform_into_available(modal: &super::Model) -> Node<Msg> {
}
#[inline(always)]
fn issue_type_select_option<'l>(ty: IssueType) -> ChildBuilder<'l> {
fn issue_type_select_option<'l>(ty: IssueType, state: &StyledCheckboxState) -> ChildBuilder<'l> {
let value: u32 = ty.into();
ChildBuilder {
field_id: state.field_id.clone(),
name: ty.to_str(),
label: ty.to_label(),
value: ty.into(),
class_list: ty.to_str(),
..Default::default()
selected: value == state.value,
}
}
@ -100,6 +88,7 @@ fn transform_into_unavailable(modal: &super::Model) -> Node<Msg> {
1 => (1.to_string(), "issue"),
n => (n.to_string(), "issues"),
};
div![
C!["transform unavailable"],
span![

View File

@ -29,36 +29,43 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node<Msg> {
.unwrap_or_else(|| Type::Task);
let issue_type_field = issue_type_field(modal);
let form = StyledForm::build()
.heading(issue_type.form_label())
.add_field(issue_type_field)
.add_field(crate::shared::divider());
let mut form = StyledForm {
heading: issue_type.form_label(),
fields: vec![issue_type_field, crate::shared::divider()],
..Default::default()
};
let form = match issue_type {
match issue_type {
Type::Epic => {
let name_field = name_field(modal);
let starts = StyledField {
input: StyledDateTimeInput::build()
.state(&modal.epic_starts_at_state)
.build(FieldId::AddIssueModal(IssueFieldId::EpicStartsAt))
.into_node(),
input: StyledDateTimeInput {
field_id: FieldId::AddIssueModal(IssueFieldId::EpicStartsAt),
popup_visible: modal.epic_starts_at_state.popup_visible,
timestamp: modal.epic_starts_at_state.timestamp.clone(),
}
.into_node(),
label: "Starts at",
..Default::default()
}
.into_node();
let end = StyledField {
input: StyledDateTimeInput::build()
.state(&modal.epic_ends_at_state)
.build(FieldId::AddIssueModal(IssueFieldId::EpicEndsAt))
.into_node(),
input: StyledDateTimeInput {
field_id: FieldId::AddIssueModal(IssueFieldId::EpicEndsAt),
popup_visible: modal.epic_ends_at_state.popup_visible,
timestamp: modal.epic_ends_at_state.timestamp.clone(),
}
.into_node(),
label: "Ends at",
..Default::default()
}
.into_node();
form.add_field(name_field).add_field(starts).add_field(end)
form.fields.push(name_field);
form.fields.push(starts);
form.fields.push(end)
}
Type::Task | Type::Story | Type::Bug => {
let short_summary_field = short_summary_field(modal);
@ -69,12 +76,14 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node<Msg> {
let epic_field =
epic_field(model, modal, FieldId::AddIssueModal(IssueFieldId::EpicName));
form.add_field(short_summary_field)
.add_field(description_field)
.add_field(reporter_field)
.add_field(assignees_field)
.add_field(issue_priority_field)
.try_field(epic_field)
form.fields.push(short_summary_field);
form.fields.push(description_field);
form.fields.push(reporter_field);
form.fields.push(assignees_field);
form.fields.push(issue_priority_field);
if let Some(field) = epic_field {
form.fields.push(field);
}
}
};
@ -106,12 +115,12 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node<Msg> {
.into_node();
let actions = div![attrs![At::Class => "actions"], submit, cancel];
let form = form.add_field(actions).build().into_node();
form.fields.push(actions);
StyledModal {
class_list: "addIssue",
width: Some(0),
children: vec![form],
children: vec![form.into_node()],
..Default::default()
}
.into_node()

View File

@ -22,17 +22,15 @@ pub fn view(model: &Model) -> Node<Msg> {
_ => empty![],
};
let form = StyledForm::build()
.heading("Welcome in JIRS")
.on_submit(ev(Ev::Submit, move |ev| {
let form = StyledForm {
heading: "Welcome in JIRS",
on_submit: Some(ev(Ev::Submit, move |ev| {
ev.prevent_default();
Msg::PageChanged(PageChanged::Invitation(InvitationPageChange::SubmitForm))
}))
.add_field(token_field)
.add_field(submit_field)
.add_field(error)
.build()
.into_node();
})),
fields: vec![token_field, submit_field, error],
}
.into_node();
outer_layout(model, "invite", vec![form])
}

View File

@ -77,19 +77,21 @@ pub fn view(model: &Model) -> Node<Msg> {
}
.into_node();
let content = StyledForm::build()
.heading("Profile")
.on_submit(ev(Ev::Submit, |ev| {
let content = StyledForm {
heading: "Profile",
on_submit: Some(ev(Ev::Submit, |ev| {
ev.prevent_default();
Msg::PageChanged(PageChanged::Profile(ProfilePageChange::SubmitForm))
}))
.add_field(avatar)
.add_field(username_field)
.add_field(email_field)
.add_field(current_project)
.add_field(submit_field)
.build()
.into_node();
})),
fields: vec![
avatar,
username_field,
email_field,
current_project,
submit_field,
],
}
.into_node();
inner_layout(model, "profile", &[content])
}

View File

@ -5,7 +5,7 @@ use seed::prelude::*;
use seed::*;
use crate::components::styled_button::{ButtonVariant, StyledButton};
use crate::components::styled_checkbox::{ChildBuilder, StyledCheckbox};
use crate::components::styled_checkbox::{ChildBuilder, StyledCheckbox, StyledCheckboxState};
use crate::components::styled_editor::StyledEditor;
use crate::components::styled_field::StyledField;
use crate::components::styled_form::StyledForm;
@ -64,24 +64,26 @@ pub fn view(model: &model::Model) -> Node<Msg> {
}
.into_node();
let form = StyledForm::build()
.heading("Project Details")
.on_submit(ev(Ev::Submit, |ev| {
let form = StyledForm {
heading: "Project Details",
fields: vec![
name_field,
url_field,
description_field,
category_field,
time_tracking_field,
save_button,
columns_field,
],
on_submit: Some(ev(Ev::Submit, |ev| {
ev.prevent_default();
ev.stop_propagation();
Msg::PageChanged(PageChanged::ProjectSettings(
ProjectPageChange::SubmitProjectSettingsForm,
))
}))
.add_field(name_field)
.add_field(url_field)
// .add_field(desc_rte)
.add_field(description_field)
.add_field(category_field)
.add_field(time_tracking_field)
.add_field(save_button)
.add_field(columns_field)
.build()
.into_node();
})),
}
.into_node();
let project_section = [div![C!["formContainer"], form]];
@ -90,16 +92,15 @@ pub fn view(model: &model::Model) -> Node<Msg> {
#[inline(always)]
fn time_tracking_select(page: &ProjectSettingsPage) -> Node<Msg> {
let time_tracking = StyledCheckbox::build()
.options(
let time_tracking = StyledCheckbox {
options: Some(
TimeTracking::default()
.into_iter()
.map(time_tracking_select_option),
)
.state(&page.time_tracking)
.add_class("timeTracking")
.build(FieldId::ProjectSettings(ProjectFieldId::TimeTracking))
.into_node();
.map(|tt| time_tracking_checkbox_option(tt, &page.time_tracking)),
),
class_list: "timeTracking",
}
.into_node();
let time_tracking_type: TimeTracking = page.time_tracking.value.into();
StyledField {
input: time_tracking,
@ -113,8 +114,14 @@ fn time_tracking_select(page: &ProjectSettingsPage) -> Node<Msg> {
.into_node()
}
fn time_tracking_select_option<'l>(t: TimeTracking) -> ChildBuilder<'l> {
fn time_tracking_checkbox_option<'l>(
t: TimeTracking,
state: &StyledCheckboxState,
) -> ChildBuilder<'l> {
let value: u32 = t.into();
ChildBuilder {
field_id: state.field_id.clone(),
selected: state.value == value,
label: match t {
TimeTracking::Untracked => "No tracking",
TimeTracking::Fibonacci => "Fibonacci (Bad mode)",
@ -130,8 +137,7 @@ fn time_tracking_select_option<'l>(t: TimeTracking) -> ChildBuilder<'l> {
TimeTracking::Fibonacci => "fibonacci",
TimeTracking::Hourly => "hourly",
},
value: t.into(),
..Default::default()
value,
}
}
@ -236,12 +242,10 @@ fn category_select_option<'l>(pc: ProjectCategory) -> StyledSelectChild<'l> {
fn columns_section(model: &Model, page: &ProjectSettingsPage) -> Node<Msg> {
let width = 100f64 / (model.issue_statuses.len() + 1) as f64;
let column_style = format!("width: calc({width}% - 10px)", width = width);
let mut per_column_issue_count = HashMap::new();
for issue in model.issues().iter() {
*per_column_issue_count
.entry(issue.issue_status_id)
.or_insert(0) += 1;
}
let per_column_issue_count = model.issues().iter().fold(HashMap::new(), |mut h, issue| {
*h.entry(issue.issue_status_id).or_insert(0) += 1;
h
});
let columns: Vec<Node<Msg>> = model
.issue_statuses
.iter()
@ -281,6 +285,7 @@ fn add_column(page: &ProjectSettingsPage, column_style: &str) -> Node<Msg> {
});
let on_submit = ev(Ev::Submit, move |ev| {
ev.prevent_default();
ev.stop_propagation();
Some(Msg::PageChanged(PageChanged::ProjectSettings(
ProjectPageChange::SubmitIssueStatusForm,
)))

View File

@ -124,16 +124,16 @@ pub fn view(model: &model::Model) -> Node<Msg> {
}
.into_node();
let bind_token_form = StyledForm::build()
.on_submit(ev(Ev::Submit, |ev| {
let bind_token_form = StyledForm {
on_submit: Some(ev(Ev::Submit, |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::BindClientRequest
}))
.add_field(token_field)
.add_field(submit_token_field)
.build()
.into_node();
})),
fields: vec![token_field, submit_token_field],
..Default::default()
}
.into_node();
let children = vec![sign_in_form, bind_token_form];
outer_layout(model, "login", children)

View File

@ -94,20 +94,22 @@ pub fn view(model: &model::Model) -> Node<Msg> {
div![C!["error"], p![page.error.as_str()]]
};
let sign_up_form = StyledForm::build()
.heading("Sign In to your account")
.on_submit(ev(Ev::Submit, |ev| {
let sign_up_form = StyledForm {
heading: "Sign In to your account",
on_submit: Some(ev(Ev::Submit, |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::SignUpRequest
}))
.add_field(username_field)
.add_field(email_field)
.add_field(submit_field)
.add_field(no_pass_section)
.add_field(error_row)
.build()
.into_node();
})),
fields: vec![
username_field,
email_field,
submit_field,
no_pass_section,
error_row,
],
}
.into_node();
let children = vec![sign_up_form];
outer_layout(model, "register", children)
}

View File

@ -82,18 +82,15 @@ pub fn view(model: &Model) -> Node<Msg> {
}
.into_node();
let form = StyledForm::build()
.heading("Invite new user")
.add_field(name_field)
.add_field(email_field)
.add_field(user_role_field)
.add_field(submit_field)
.on_submit(ev(Ev::Submit, |ev| {
let form = StyledForm {
heading: "Invite new user",
on_submit: Some(ev(Ev::Submit, |ev| {
ev.prevent_default();
Msg::InviteRequest
}))
.build()
.into_node();
})),
fields: vec![name_field, email_field, user_role_field, submit_field],
}
.into_node();
let users: Vec<Node<Msg>> = page
.invited_users