This commit is contained in:
Adrian Woźniak 2021-10-13 16:01:44 +02:00
parent a4c7f916be
commit e807dae81e
No known key found for this signature in database
GPG Key ID: DE43476F72AD3F6C
40 changed files with 713 additions and 492 deletions

View File

@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)]
pub enum ProjectFieldId {
Name,
Url,
@ -11,20 +11,20 @@ pub enum ProjectFieldId {
IssueStatusName,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)]
pub enum SignInFieldId {
Username,
Email,
Token,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)]
pub enum SignUpFieldId {
Username,
Email,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)]
pub enum UsersFieldId {
Username,
Email,
@ -34,17 +34,17 @@ pub enum UsersFieldId {
TextEditorMode,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)]
pub enum InviteFieldId {
Token,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)]
pub enum CommentFieldId {
Body,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)]
pub enum IssueFieldId {
Type,
Title,
@ -62,7 +62,7 @@ pub enum IssueFieldId {
EpicEndsAt,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)]
pub enum EpicFieldId {
Name,
StartsAt,

View File

@ -35,6 +35,18 @@
text-align: center;
}
> .styledField {
.emailSend {
text-align: center;
> div > svg {
width: 160px;
margin: 0 auto;
fill: var(--primary);
}
}
}
> .noPasswordSection {
line-height: 32px;
margin-top: 15px;
@ -58,6 +70,18 @@
justify-content: space-between;
}
}
.error {
> p {
line-height: 1.4285;
color: var(--danger);
font-family: var(--font-medium);
text-align: center;
font-size: 14.5px;
border-top: 1px solid var(--danger);
margin-top: 15px;
}
}
}
@media (min-width: 1240px) {

View File

@ -0,0 +1,193 @@
use std::str::FromStr;
use seed::prelude::{ev, Ev, EventHandler};
use crate::components::styled_date_time_input::StyledDateTimeChanged;
use crate::components::styled_md_editor::MdEditorMode;
use crate::components::styled_select::StyledSelectChanged;
use crate::{resolve_page, FieldChange};
type EvHandler = EventHandler<crate::Msg>;
pub fn on_click_change_number_input(field_id: crate::FieldId, value: u32) -> EvHandler {
ev(Ev::Click, move |ev| {
ev.stop_propagation();
ev.prevent_default();
crate::Msg::U32InputChanged(field_id, value)
})
}
pub fn on_change_set_str_input(field_id: crate::FieldId) -> EvHandler {
ev(Ev::Change, move |ev| {
ev.stop_propagation();
let target = ev.target().unwrap();
crate::Msg::StrInputChanged(field_id, seed::to_input(&target).value())
})
}
pub fn on_click_drop_modal() -> EvHandler {
ev(Ev::Click, move |ev| {
ev.stop_propagation();
crate::Msg::ModalDropped
})
}
pub fn on_click_change_day(field_id: crate::FieldId, date: chrono::NaiveDateTime) -> EvHandler {
ev(Ev::Click, move |ev| {
ev.stop_propagation();
ev.prevent_default();
// log::info!("{:?}", date);
crate::Msg::StyledDateTimeInputChanged(
field_id,
StyledDateTimeChanged::DayChanged(Some(date)),
)
})
}
pub fn on_click_change_date_time_visibility(field_id: crate::FieldId, visible: bool) -> EvHandler {
ev(Ev::Click, move |ev| {
ev.prevent_default();
ev.stop_propagation();
crate::Msg::StyledDateTimeInputChanged(
field_id,
StyledDateTimeChanged::PopupVisibilityChanged(!visible),
)
})
}
pub fn on_click_change_month(field_id: crate::FieldId, date: chrono::NaiveDateTime) -> EvHandler {
ev(Ev::Click, move |ev| {
ev.stop_propagation();
ev.prevent_default();
crate::Msg::StyledDateTimeInputChanged(
field_id,
StyledDateTimeChanged::MonthChanged(Some(date)),
)
})
}
pub fn on_change_image_input(field_id: crate::FieldId) -> EvHandler {
ev(Ev::Change, move |ev| {
let target = ev.target().unwrap();
let input = seed::to_input(&target);
let v = input
.files()
.map(|list| {
(0..list.length())
.filter_map(|i| list.get(i))
.collect::<Vec<web_sys::File>>()
})
.unwrap_or_default();
crate::Msg::FileInputChanged(field_id, v)
})
}
pub fn on_keyup_change_select_text(field_id: crate::FieldId) -> EvHandler {
ev(Ev::KeyUp, move |ev| {
ev.stop_propagation();
let target = ev.target().unwrap();
let value = seed::to_input(&target).value();
crate::Msg::StyledSelectChanged(field_id, StyledSelectChanged::Text(value))
})
}
pub fn on_click_change_select_dropdown_visibility(
field_id: crate::FieldId,
opened: bool,
) -> EvHandler {
ev(Ev::Click, move |ev| {
ev.stop_propagation();
crate::Msg::StyledSelectChanged(field_id, StyledSelectChanged::DropDownVisibility(!opened))
})
}
pub fn on_click_change_select_selected(field_id: crate::FieldId, value: Option<u32>) -> EvHandler {
ev(Ev::Click, move |ev| {
ev.stop_propagation();
ev.prevent_default();
crate::Msg::StyledSelectChanged(field_id, StyledSelectChanged::Changed(value))
})
}
pub fn on_click_change_select_remove_multi(field_id: crate::FieldId, value: u32) -> EvHandler {
ev(Ev::Click, move |ev| {
ev.stop_propagation();
crate::Msg::StyledSelectChanged(field_id, StyledSelectChanged::RemoveMulti(value))
})
}
pub fn on_click_change_page(href: String) -> EvHandler {
ev(Ev::Click, move |ev| {
if href.starts_with('/') {
ev.prevent_default();
ev.stop_propagation();
if let Ok(url) = seed::Url::from_str(href.as_str()) {
url.go_and_push();
return resolve_page(url).map(crate::Msg::ChangePage);
}
}
None as Option<crate::Msg>
})
}
pub fn on_click_change_tab(field_id: crate::FieldId, new_mode: MdEditorMode) -> EvHandler {
ev(Ev::Click, move |ev| {
ev.stop_propagation();
crate::Msg::ModalChanged(FieldChange::TabChanged(field_id, new_mode))
})
}
pub fn on_click_noop() -> EvHandler {
noop(Ev::Click)
}
pub fn on_keyup_noop() -> EvHandler {
noop(Ev::KeyUp)
}
fn noop(event: Ev) -> EvHandler {
ev(event, |ev| {
ev.stop_propagation();
ev.prevent_default();
None as Option<crate::Msg>
})
}
pub fn on_event_change_text_value(
field_id: crate::FieldId,
update_event: Ev,
handler_disable_auto_resize: bool,
) -> EvHandler {
ev(update_event, move |event| {
event.stop_propagation();
let value = event
.target()
.map(|target| seed::to_textarea(&target).value())
.unwrap_or_default();
if handler_disable_auto_resize && value.contains('\n') {
event.prevent_default();
}
crate::Msg::StrInputChanged(
field_id,
if handler_disable_auto_resize {
value.trim().to_string()
} else {
value
},
)
})
}

View File

@ -18,3 +18,4 @@ pub mod styled_select_child;
pub mod styled_textarea;
pub mod styled_tip;
pub mod styled_tooltip;
mod events;

View File

@ -32,7 +32,7 @@ impl<'l> StyledAvatar<'l> {
.chars()
.rev()
.last()
.map(|c| c.to_string())
.map(String::from)
.unwrap_or_default();
match avatar_url {
Some(url) => {

View File

@ -1,6 +1,7 @@
use seed::prelude::*;
use seed::*;
use super::events;
use crate::{FieldId, Msg};
#[derive(Debug)]
@ -56,15 +57,7 @@ impl<'l> ChildBuilder<'l> {
#[inline(always)]
pub fn render(self) -> Node<Msg> {
let id = self.field_id.to_string();
let handler: EventHandler<Msg> = {
let id = self.field_id;
let value = self.value;
mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::U32InputChanged(id, value)
})
};
let handler = events::on_click_change_number_input(self.field_id, self.value);
div![
C![

View File

@ -48,7 +48,7 @@ impl<'l> StyledConfirmModal<'l> {
_ => cancel_text,
}),
variant: ButtonVariant::Secondary,
on_click: Some(mouse_ev(Ev::Click, |_| Msg::ModalDropped)),
on_click: Some(super::events::on_click_drop_modal()),
..Default::default()
}
.render();

View File

@ -105,13 +105,16 @@ impl StyledDateTimeInput {
let calendar_start = StyledDateTimeInput::calendar_start(start);
let calendar_end = StyledDateTimeInput::calendar_end(end);
let current_month_range = start..=end;
// let current = calendar_start;
let mut weeks = vec![];
let mut current_week = vec![];
let mut weeks = Vec::with_capacity(7);
let mut current_week = Vec::with_capacity(6);
for current in DateRange(calendar_start, calendar_end) {
if current.weekday() == Weekday::Mon && !current_week.is_empty() {
weeks.push(div![C!["week"], std::mem::take(&mut current_week)]);
weeks.push(div![
C!["week"],
std::mem::replace(&mut current_week, Vec::with_capacity(7))
]);
}
current_week.push(
@ -130,21 +133,14 @@ impl StyledDateTimeInput {
}
let left_action = {
let field_id = self.field_id.clone();
let current = timestamp;
let on_click_left = mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
ev.prevent_default();
let last_day_of_prev_month = current.with_day0(0).unwrap() - Duration::days(1);
let on_click_left = {
let last_day_of_prev_month = timestamp.with_day0(0).unwrap() - Duration::days(1);
let date = last_day_of_prev_month
.with_day0(timestamp.day0())
.unwrap_or(last_day_of_prev_month);
Msg::StyledDateTimeInputChanged(
field_id,
StyledDateTimeChanged::MonthChanged(Some(date)),
)
});
super::events::on_click_change_month(self.field_id.clone(), date)
};
StyledButton {
on_click: Some(on_click_left),
icon: Some(StyledIcon::from(Icon::DoubleLeft).render()),
@ -153,13 +149,11 @@ impl StyledDateTimeInput {
}
.render()
};
let right_action = {
let field_id = self.field_id.clone();
let current = timestamp;
let on_click_right = mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
ev.prevent_default();
let first_day_of_next_month = (current + Duration::days(32)).with_day0(0).unwrap();
let on_click_right = {
let first_day_of_next_month =
(timestamp + Duration::days(32)).with_day0(0).unwrap();
let last_day_of_next_month = (first_day_of_next_month + Duration::days(32))
.with_day0(0)
.unwrap()
@ -167,11 +161,8 @@ impl StyledDateTimeInput {
let date = first_day_of_next_month
.with_day0(timestamp.day0())
.unwrap_or(last_day_of_next_month);
Msg::StyledDateTimeInputChanged(
field_id,
StyledDateTimeChanged::MonthChanged(Some(date)),
)
});
super::events::on_click_change_month(self.field_id.clone(), date)
};
StyledButton {
on_click: Some(on_click_right),
icon: Some(StyledIcon::from(Icon::DoubleRight).render()),
@ -210,16 +201,10 @@ impl StyledDateTimeInput {
.render();
let input = {
let field_id = self.field_id.clone();
let visible = self.popup_visible;
let on_focus = ev(Ev::Click, move |ev| {
ev.prevent_default();
ev.stop_propagation();
Msg::StyledDateTimeInputChanged(
field_id,
StyledDateTimeChanged::PopupVisibilityChanged(!visible),
)
});
let on_focus = super::events::on_click_change_date_time_visibility(
self.field_id.clone(),
self.popup_visible,
);
let text = self
.timestamp
.unwrap_or_else(|| Utc::now().naive_utc())
@ -279,25 +264,13 @@ pub struct DayCell<'l> {
impl<'l> DayCell<'l> {
#[inline(always)]
pub fn render(self) -> Node<Msg> {
let on_click = {
let field_id = self.field_id.clone();
let date = *self.current;
ev(Ev::Click, move |ev| {
ev.stop_propagation();
ev.prevent_default();
log::info!("{:?}", date);
Msg::StyledDateTimeInputChanged(
field_id,
StyledDateTimeChanged::DayChanged(Some(date)),
)
})
};
let on_click = super::events::on_click_change_day(self.field_id.clone(), *self.current);
div![
C![
"day",
format!("{}", self.current.weekday()),
IF![self.is_selected() => "selected"],
if self.current_month_range.contains(&self.current) {
if self.current_month_range.contains(self.current) {
"inCurrentMonth"
} else {
"outCurrentMonth"

View File

@ -68,6 +68,7 @@ impl StyledEditorState {
}
impl EditorMode {
#[inline(always)]
pub fn update(&mut self, msg: &Msg, orders: &mut impl Orders<Msg>) {
match self {
EditorMode::Md(state) => state.update(msg),
@ -167,10 +168,12 @@ fn build_state(
}
}
#[inline(always)]
fn build_state_rte(field_id: FieldId) -> EditorMode {
EditorMode::Rte(StyledRteState::new(field_id))
}
#[inline(always)]
fn build_state_md(field_id: FieldId, text: &str, html: &str) -> EditorMode {
EditorMode::Md(StyledMdEditorState::new(
field_id,

View File

@ -1,5 +1,5 @@
use seed::prelude::*;
use seed::*;
use seed::prelude::*;
use web_sys::File;
use crate::{FieldId, Msg};
@ -45,20 +45,7 @@ impl<'l> StyledImageInput<'l> {
url,
} = self;
let field_id = id.clone();
let on_change = ev(Ev::Change, move |ev| {
let target = ev.target().unwrap();
let input = seed::to_input(&target);
let v = input
.files()
.map(|list| {
(0..list.length())
.filter_map(|i| list.get(i))
.collect::<Vec<File>>()
})
.unwrap_or_default();
Msg::FileInputChanged(field_id, v)
});
let on_change = super::events::on_change_image_input(id.clone());
let input_id = id.to_string();
div![

View File

@ -160,14 +160,7 @@ impl<'l, 'm: 'l> StyledInput<'l, 'm> {
.map(|icon| StyledIcon::from(icon).render())
.unwrap_or(Node::Empty);
let on_change = {
let field_id = id.clone();
ev(Ev::Change, move |event| {
event.stop_propagation();
let target = event.target().unwrap();
Msg::StrInputChanged(field_id, seed::to_input(&target).value())
})
};
let on_change = super::events::on_change_set_str_input(id.clone());
div![
C![
@ -183,7 +176,7 @@ impl<'l, 'm: 'l> StyledInput<'l, 'm> {
"inputElement",
variant.to_str(),
input_class_list,
icon.as_ref().map(|_| "withIcon").unwrap_or_default()
icon.map(|_| "withIcon")
],
attrs![
"id" => format!("{}", id),

View File

@ -1,9 +1,7 @@
use std::str::FromStr;
use seed::prelude::*;
use seed::*;
use seed::prelude::*;
use crate::{resolve_page, Msg};
use crate::Msg;
#[derive(Debug, Default)]
pub struct StyledLink<'l> {
@ -16,23 +14,7 @@ pub struct StyledLink<'l> {
impl<'l> StyledLink<'l> {
#[inline(always)]
pub fn render(self) -> Node<Msg> {
let on_click = {
let href = self.href.to_string();
mouse_ev("click", move |ev| {
if href.starts_with('/') {
ev.prevent_default();
ev.stop_propagation();
if let Ok(url) = seed::Url::from_str(href.as_str()) {
url.go_and_push();
if let Some(page) = resolve_page(url) {
return Some(Msg::ChangePage(page));
}
}
}
None as Option<Msg>
})
};
let on_click = super::events::on_click_change_page(self.href.to_string());
a![
C!["styledLink", self.class_list],

View File

@ -1,8 +1,8 @@
use seed::prelude::*;
use seed::*;
use seed::prelude::*;
use crate::components::styled_textarea::StyledTextarea;
use crate::{FieldChange, FieldId, Msg};
use crate::components::styled_textarea::StyledTextarea;
#[derive(Debug, Clone, PartialOrd, PartialEq, Hash)]
#[repr(C)]
@ -84,8 +84,9 @@ impl<'l> StyledMdEditor<'l> {
} = self;
let id = id.expect("Styled Editor requires ID");
let on_editor_clicked = click_handler(id.clone(), MdEditorMode::Editor);
let on_view_clicked = click_handler(id.clone(), MdEditorMode::View);
let on_editor_clicked =
super::events::on_click_change_tab(id.clone(), MdEditorMode::Editor);
let on_view_clicked = super::events::on_click_change_tab(id.clone(), MdEditorMode::View);
let editor_id = format!("editor-{}", id);
let view_id = format!("view-{}", id);
@ -140,11 +141,3 @@ impl<'l> StyledMdEditor<'l> {
]
}
}
#[inline(always)]
fn click_handler(field_id: FieldId, new_mode: MdEditorMode) -> EventHandler<Msg> {
mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::ModalChanged(FieldChange::TabChanged(field_id, new_mode))
})
}

View File

@ -1,5 +1,5 @@
use seed::prelude::*;
use seed::*;
use seed::prelude::*;
use crate::components::styled_icon::{Icon, StyledIcon};
use crate::Msg;
@ -76,19 +76,11 @@ impl<'l> StyledModal<'l> {
class_list,
} = self;
let close_handler = mouse_ev(Ev::Click, |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::ModalDropped
});
let body_handler = mouse_ev(Ev::Click, |ev| {
ev.stop_propagation();
ev.prevent_default();
None as Option<Msg>
});
let close_handler = super::events::on_click_drop_modal();
let body_handler = super::events::on_click_noop();
let styled_modal_style = match width {
Some(0) => "".to_string(),
Some(0) => String::from(""),
Some(n) => format!("max-width: {width}px", width = n),
_ => format!("max-width: {width}px", width = 130),
};

View File

@ -87,14 +87,15 @@ impl StyledSelectState {
self.text_filter = text.clone();
}
Msg::StyledSelectChanged(_, StyledSelectChanged::Changed(Some(v))) => {
self.values = vec![*v];
self.values.clear();
self.values.push(*v);
}
Msg::StyledSelectChanged(_, StyledSelectChanged::Changed(None)) => {
self.values.clear();
}
Msg::StyledSelectChanged(_, StyledSelectChanged::RemoveMulti(v)) => {
let mut old = vec![];
std::mem::swap(&mut old, &mut self.values);
let len = self.values.len();
let old = std::mem::replace(&mut self.values, Vec::with_capacity(len));
for u in old {
if u != *v {
@ -164,19 +165,10 @@ where
clearable,
} = self;
let on_text = {
let field_id = id.clone();
input_ev(Ev::KeyUp, move |value| {
Msg::StyledSelectChanged(field_id, StyledSelectChanged::Text(value))
})
};
let on_text = super::events::on_keyup_change_select_text(id.clone());
let on_handler = {
let field_id = id.clone();
mouse_ev(Ev::Click, move |_| {
Msg::StyledSelectChanged(field_id, StyledSelectChanged::DropDownVisibility(!opened))
})
};
let on_handler =
super::events::on_click_change_select_dropdown_visibility(id.clone(), opened);
let dropdown_style = dropdown_width.map_or_else(
|| "width: 100%;".to_string(),
@ -184,14 +176,7 @@ where
);
let action_icon = if clearable && !selected.is_empty() {
let on_click = {
let field_id = id.clone();
mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::StyledSelectChanged(field_id, StyledSelectChanged::Changed(None))
})
};
let on_click = super::events::on_click_change_select_selected(id.clone(), None);
StyledIcon {
icon: Icon::Close,
class_list: "chevronIcon",
@ -210,26 +195,24 @@ where
empty![]
};
let skip = selected.iter().fold(HashMap::new(), |mut h, o| {
h.insert(o.value, true);
h
});
let skip = {
let len = selected.len();
selected
.iter()
.fold(HashMap::with_capacity(len), |mut h, o| {
h.insert(o.value, true);
h
})
};
let children: Vec<Node<Msg>> = if let Some(options) = options {
options
.filter(|o| !skip.contains_key(&o.value) && o.match_text(text_filter))
.map(|child| {
let value = child.value();
let on_change = super::events::on_click_change_select_selected(
id.clone(),
Some(child.value()),
);
let node = child.render_option();
let on_change = {
let field_id = id.clone();
mouse_ev(Ev::Click, move |_| {
Msg::StyledSelectChanged(
field_id,
StyledSelectChanged::Changed(Some(value)),
)
})
};
div![C!["option"], on_change, on_handler.clone(), node]
})
.collect()
@ -240,10 +223,7 @@ where
seed::div![
C!["styledSelect", variant.to_str(), IF![!valid => "invalid"]],
attrs![At::Style => dropdown_style.as_str()],
keyboard_ev(Ev::KeyUp, |ev| {
ev.stop_propagation();
None as Option<Msg>
}),
super::events::on_keyup_noop(),
div![
C!["valueContainer", variant.to_str()],
on_handler,
@ -287,18 +267,8 @@ where
}
fn multi_value(child: StyledSelectOption, id: FieldId) -> Node<Msg> {
let value = child.value();
let handler = super::events::on_click_change_select_remove_multi(id, child.value());
let opt = child.render_multi_value();
let handler = {
let field_id = id;
mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::StyledSelectChanged(field_id, StyledSelectChanged::RemoveMulti(value))
})
};
div![C!["valueMultiItem"], opt, handler]
div![C!["valueMultiItem"], child.render_multi_value(), handler]
}
}

View File

@ -13,6 +13,7 @@ pub enum DisplayType {
SelectMultiValue,
}
#[derive(Default)]
pub struct StyledSelectOption<'l> {
pub name: Option<&'l str>,
pub icon: Option<Node<Msg>>,
@ -22,19 +23,6 @@ pub struct StyledSelectOption<'l> {
pub variant: SelectVariant,
}
impl<'l> Default for StyledSelectOption<'l> {
fn default() -> Self {
Self {
name: None,
icon: None,
text: None,
value: 0,
class_list: "",
variant: Default::default(),
}
}
}
impl<'l> StyledSelectOption<'l> {
#[inline(always)]
pub fn value(&self) -> u32 {
@ -75,31 +63,26 @@ impl<'l> StyledSelectOption<'l> {
variant,
} = self;
let label_node = text.map_or_else(
|| Node::Empty,
|text| {
div![
C![
variant.to_str(),
name.as_deref()
.map(|s| format!("{}Label", s))
.unwrap_or_default(),
match display_type {
DisplayType::SelectOption => "optionLabel",
DisplayType::SelectValue | DisplayType::SelectMultiValue =>
"selectItemLabel",
},
class_list
],
text
]
},
);
let label_node = text.map(|text| {
div![
C![
variant.to_str(),
name.map(|s| format!("{}Label", s)),
match display_type {
DisplayType::SelectOption => "optionLabel",
DisplayType::SelectValue | DisplayType::SelectMultiValue =>
"selectItemLabel",
},
class_list
],
text
]
});
div![
C![
variant.to_str(),
name.as_deref().unwrap_or_default(),
name,
match display_type {
DisplayType::SelectOption => "optionItem",
DisplayType::SelectValue | DisplayType::SelectMultiValue => "selectItem value",

View File

@ -49,7 +49,7 @@ impl<'l> StyledTextarea<'l> {
disable_auto_resize,
} = self;
let id = id.expect("Text area requires FieldId");
let mut style_list = vec![];
let mut style_list = Vec::with_capacity(3);
let min_height = get_min_height(value, height as f64, disable_auto_resize);
if min_height > 0f64 {
@ -88,30 +88,11 @@ impl<'l> StyledTextarea<'l> {
// });
let handler_disable_auto_resize = disable_auto_resize;
let text_input_handler = {
let id = id.clone();
ev(update_event, move |event| {
event.stop_propagation();
let value = event
.target()
.map(|target| seed::to_textarea(&target).value())
.unwrap_or_default();
if handler_disable_auto_resize && value.contains('\n') {
event.prevent_default();
}
Some(Msg::StrInputChanged(
id,
if handler_disable_auto_resize {
value.trim().to_string()
} else {
value
},
))
})
};
let text_input_handler = super::events::on_event_change_text_value(
id.clone(),
update_event,
handler_disable_auto_resize,
);
div![
id![format!("styledTextArea-{}", id)],

View File

@ -5,7 +5,7 @@ use jirs_data::{
pub type AvatarFilterActive = bool;
#[derive(Clone, Debug, PartialOrd, PartialEq, Hash)]
#[derive(Clone, Copy, Debug, PartialOrd, PartialEq, Hash)]
pub enum EditIssueModalSection {
Issue(IssueFieldId),
Comment(CommentFieldId),
@ -84,7 +84,7 @@ impl ButtonId {
}
}
#[derive(Clone, Debug, PartialOrd, PartialEq, Hash)]
#[derive(Clone, Copy, Debug, PartialOrd, PartialEq, Hash)]
pub enum IssuesAndFiltersId {
Jql,
}

View File

@ -0,0 +1,16 @@
use seed::prelude::*;
pub fn render() -> Node<crate::Msg> {
use seed::*;
svg![
attrs![
"xmlns"=>"http://www.w3.org/2000/svg",
"viewBox"=>"0 0 64 64",
"xml:space"=>"preserve",
],
path![attrs![
At::D=>"m63.94 39.721-3.956-22.388a3.856 3.856 0 0 0-4.464-3.125l-31.726 5.606c-1.97.348-3.308 2.148-3.15 4.102-.022.162 3.983 22.751 3.983 22.751a3.821 3.821 0 0 0 1.586 2.488 3.827 3.827 0 0 0 2.879.636l31.723-5.605a3.825 3.825 0 0 0 2.488-1.586c.59-.843.816-1.865.637-2.879zm-2.672-3.636L50.6 27.879c3.002-3.233 6.137-6.785 7.702-8.572l2.965 16.778zM24.143 21.783l31.724-5.606a1.855 1.855 0 0 1 1.839.772c-.932 1.072-9.375 10.756-13.433 14.56a3.537 3.537 0 0 1-3.918.631c-4.79-2.213-15.22-7.446-17.727-8.707a1.84 1.84 0 0 1 1.515-1.65zm-1.165 4.064c2.35 1.178 6.616 3.31 10.513 5.218l-7.523 11.702-2.99-16.92zm38.686 15.606a1.838 1.838 0 0 1-1.195.763l-31.725 5.607a1.851 1.851 0 0 1-1.386-.308 1.84 1.84 0 0 1-.762-1.195l-.115-.652 8.82-13.72c1.602.777 3.069 1.479 4.215 2.008a5.529 5.529 0 0 0 6.124-.99c1.005-.94 2.255-2.214 3.586-3.62l12.558 9.66.188 1.063c.086.487-.024.98-.308 1.384zM19.008 29.867a.997.997 0 0 0-1.158-.81L.826 32.065a.999.999 0 1 0 .348 1.969l17.023-3.008a.999.999 0 0 0 .81-1.159zM20.03 35.652a.998.998 0 0 0-1.159-.81L8.657 36.645a.999.999 0 1 0 .348 1.969l10.214-1.805a.999.999 0 0 0 .81-1.158zM19.888 40.597 13.079 41.8a.999.999 0 1 0 .348 1.969l6.808-1.203a.999.999 0 0 0 .81-1.158.996.996 0 0 0-1.157-.811z"
]]
]
}

View File

@ -1,2 +1,3 @@
pub mod project_avatar;
pub mod email_send;
pub mod logo;
pub mod project_avatar;

View File

@ -88,6 +88,7 @@ pub enum Msg {
AuthTokenErased,
SignInRequest,
BindClientRequest,
InvalidPair,
// users
InviteRequest,

View File

@ -1,57 +1,49 @@
use chrono::NaiveDateTime;
use jirs_data::{Issue, IssueStatus};
use seed::prelude::*;
use seed::*;
use seed::prelude::*;
use jirs_data::{Issue, IssueStatus};
use crate::components::styled_icon::{Icon, StyledIcon};
use crate::model::Model;
use crate::shared::inner_layout;
use crate::Msg;
use crate::shared::inner_layout;
pub fn view(model: &Model) -> Node<Msg> {
let page = crate::match_page!(model, Epics; Empty);
let epics: Vec<Node<Msg>> = model
.epics
.iter()
.map(|epic| {
let issues = page
.issues(epic.id)
.map(|v| {
v.iter()
.filter_map(|i| model.issues_by_id.get(i))
.collect::<Vec<&Issue>>()
let epics = model.epics.iter().map(|epic| {
let issues = page.issues(epic.id).map(|v| {
v.iter()
.filter_map(|i| model.issues_by_id.get(i))
.map(|issue| {
render_issue(
issue,
model.issue_statuses_by_id.get(&issue.issue_status_id),
)
})
.unwrap_or_default();
.collect::<Vec<Node<Msg>>>()
});
li![
C!["epic"],
li![
C!["epic"],
div![
C!["firstRow"],
div![C!["epicName"], &epic.name],
div![
C!["firstRow"],
div![C!["epicName"], &epic.name],
div![
C!["date"],
date_field("Starts at:", "startsAt", epic.starts_at.as_ref()),
date_field("Ends at:", "endsAt", epic.ends_at.as_ref()),
],
div![C!["counter"], "Number of issues:", issues.len()],
C!["date"],
date_field("Starts at:", "startsAt", epic.starts_at.as_ref()),
date_field("Ends at:", "endsAt", epic.ends_at.as_ref()),
],
div![
C!["secondRow"],
div![
C!["issues"],
issues
.into_iter()
.map(|issue| render_issue(
issue,
model.issue_statuses_by_id.get(&issue.issue_status_id)
))
.collect::<Vec<Node<Msg>>>()
]
]
]
})
.collect();
C!["counter"],
"Number of issues:",
issues.as_ref().map(Vec::len).unwrap_or(0)
],
],
div![C!["secondRow"], div![C!["issues"], issues]]
]
});
inner_layout(
model,
@ -83,12 +75,7 @@ fn render_issue(issue: &Issue, status: Option<&IssueStatus>) -> Node<Msg> {
div![
C!["issue"],
div![C!["name"], issue.title.as_str()],
div![
C!["status"],
status
.map(|status| status.name.as_str())
.unwrap_or_default()
],
div![C!["status"], status.map(|s| s.name.as_str())],
div![
C!["flags"],
div![

View File

@ -1,6 +1,7 @@
use jirs_data::{Issue, IssueId};
use seed::prelude::*;
use seed::*;
use seed::prelude::*;
use jirs_data::{Issue, IssueId};
use crate::components::styled_icon::*;
use crate::components::styled_link::*;
@ -163,7 +164,6 @@ fn issue_link(id: IssueId, project_name: &str) -> Node<Msg> {
],
disabled: true,
class_list: "withIcon issueLink",
..Default::default()
}
.render()
}

View File

@ -0,0 +1,12 @@
use seed::prelude::{ev, Ev, EventHandler};
type EvHandler = EventHandler<crate::Msg>;
pub fn on_submit_submit_profile() -> EvHandler {
ev(Ev::Submit, |ev| {
ev.prevent_default();
crate::Msg::PageChanged(crate::PageChanged::Profile(
crate::ProfilePageChange::SubmitForm,
))
})
}

View File

@ -2,6 +2,7 @@ pub use model::*;
pub use update::*;
pub use view::*;
mod events;
pub mod model;
pub mod update;
pub mod view;

View File

@ -77,10 +77,7 @@ pub fn view(model: &Model) -> Node<Msg> {
let content = StyledForm {
heading: "Profile",
on_submit: Some(ev(Ev::Submit, |ev| {
ev.prevent_default();
Msg::PageChanged(PageChanged::Profile(ProfilePageChange::SubmitForm))
})),
on_submit: Some(super::events::on_submit_submit_profile()),
fields: vec![
avatar,
username_field,
@ -96,18 +93,14 @@ pub fn view(model: &Model) -> Node<Msg> {
fn build_current_project(model: &Model, page: &ProfilePage) -> Node<Msg> {
let inner = if model.projects.len() <= 1 {
let name = model
.project
.as_ref()
.map(|p| p.name.as_str())
.unwrap_or_default();
let name = model.project.as_ref().map(|p| p.name.as_str());
span![name]
} else {
let mut project_by_id = HashMap::new();
let mut project_by_id = HashMap::with_capacity(model.projects.len());
for p in model.projects.iter() {
project_by_id.insert(p.id, p);
}
let mut joined_projects = HashMap::new();
let mut joined_projects = HashMap::with_capacity(model.user_projects.len());
for p in model.user_projects.iter() {
joined_projects.insert(p.project_id, p);
}
@ -147,7 +140,7 @@ fn build_current_project(model: &Model, page: &ProfilePage) -> Node<Msg> {
}
#[inline(always)]
fn project_select_option<'l>(project: &'l Project) -> StyledSelectOption<'l> {
fn project_select_option(project: &Project) -> StyledSelectOption<'_> {
StyledSelectOption {
text: Some(project.name.as_str()),
value: project.id as u32,
@ -160,12 +153,11 @@ fn editor_mode_select(page: &ProfilePage) -> Node<Msg> {
let time_tracking = StyledCheckbox {
options: Some(
vec![
TextEditorMode::MdOnly,
TextEditorMode::RteOnly,
TextEditorMode::Mixed,
editor_mode_checkbox_option(TextEditorMode::MdOnly, &page.text_editor_mode),
editor_mode_checkbox_option(TextEditorMode::RteOnly, &page.text_editor_mode),
editor_mode_checkbox_option(TextEditorMode::Mixed, &page.text_editor_mode),
]
.into_iter()
.map(|tem| editor_mode_checkbox_option(tem, &page.text_editor_mode)),
.into_iter(),
),
class_list: "timeTracking",
}

View File

@ -1,7 +1,8 @@
use jirs_data::{EpicId, UserId};
use seed::prelude::*;
use crate::model::Page;
use crate::{BoardPageChange, Msg, PageChanged};
use crate::{AvatarFilterActive, BoardPageChange, Msg, PageChanged};
pub type EvHandler = seed::EventHandler<Msg>;
@ -72,3 +73,55 @@ pub fn on_click_edit_issue(issue_id: i32) -> EvHandler {
Msg::ChangePage(Page::EditIssue(issue_id))
})
}
pub fn on_click_toggle_only_my() -> EvHandler {
ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::ProjectToggleOnlyMy
})
}
pub fn on_click_toggle_recent() -> EvHandler {
ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::ProjectToggleRecentlyUpdated
})
}
pub fn on_click_filter_by_user(user_id: UserId, active: AvatarFilterActive) -> EvHandler {
ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::ProjectAvatarFilterChanged(user_id, active)
})
}
pub fn on_click_clear_filters() -> EvHandler {
ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::ProjectClearFilters
})
}
pub fn on_click_goto_delete_epic(epic_id: EpicId) -> EvHandler {
ev(Ev::Click, move |ev| {
ev.stop_propagation();
ev.prevent_default();
seed::Url::new()
.add_path_part("delete-epic")
.add_path_part(epic_id.to_string())
.go_and_push();
Msg::ChangePage(Page::DeleteEpic(epic_id))
})
}
pub fn on_click_goto_edit_epic(epic_id: EpicId) -> EvHandler {
ev(Ev::Click, move |ev| {
ev.stop_propagation();
ev.prevent_default();
seed::Url::new()
.add_path_part("edit-epic")
.add_path_part(epic_id.to_string())
.go_and_push();
Msg::ChangePage(Page::EditEpic(epic_id))
})
}

View File

@ -26,7 +26,7 @@ fn breadcrumbs(model: &Model) -> Node<Msg> {
C!["breadcrumbsContainer"],
span!["Projects"],
span![C!["breadcrumbsDivider"], "/"],
span![model.project_name().unwrap_or_default()],
span![model.project_name()],
span![C!["breadcrumbsDivider"], "/"],
span!["Kanban Board"]
]

View File

@ -1,13 +1,14 @@
use jirs_data::*;
use seed::prelude::*;
use seed::*;
use seed::prelude::*;
use jirs_data::*;
use crate::{match_page, Model, Msg};
use crate::components::styled_avatar::*;
use crate::components::styled_button::{ButtonVariant, StyledButton};
use crate::components::styled_icon::*;
use crate::model::PageContent;
use crate::pages::project_page::{events, StatusIssueIds};
use crate::{match_page, Model, Msg, Page};
#[inline(always)]
pub fn project_board_lists(model: &Model) -> Node<Msg> {
@ -26,30 +27,14 @@ pub fn project_board_lists(model: &Model) -> Node<Msg> {
let edit_button = StyledButton {
variant: ButtonVariant::Empty,
icon: Some(StyledIcon::from(Icon::EditAlt).render()),
on_click: Some(mouse_ev("click", move |ev| {
ev.stop_propagation();
ev.prevent_default();
seed::Url::new()
.add_path_part("edit-epic")
.add_path_part(id.to_string())
.go_and_push();
Msg::ChangePage(Page::EditEpic(id))
})),
on_click: Some(events::on_click_goto_edit_epic(id)),
..Default::default()
}
.render();
let delete_button = StyledButton {
variant: ButtonVariant::Empty,
icon: Some(StyledIcon::from(Icon::DeleteAlt).render()),
on_click: Some(mouse_ev("click", move |ev| {
ev.stop_propagation();
ev.prevent_default();
seed::Url::new()
.add_path_part("delete-epic")
.add_path_part(id.to_string())
.go_and_push();
Msg::ChangePage(Page::DeleteEpic(id))
})),
on_click: Some(events::on_click_goto_delete_epic(id)),
..Default::default()
}
.render();

View File

@ -1,12 +1,14 @@
use seed::prelude::*;
use seed::*;
use seed::prelude::*;
use crate::{FieldId, Model, Msg};
use crate::components::styled_avatar::*;
use crate::components::styled_button::*;
use crate::components::styled_icon::*;
use crate::components::styled_input::*;
use crate::model::PageContent;
use crate::{FieldId, Model, Msg};
use super::super::events;
pub fn project_board_filters(model: &Model) -> Node<Msg> {
let project_page = match &model.page_content {
@ -28,7 +30,7 @@ pub fn project_board_filters(model: &Model) -> Node<Msg> {
active: project_page.only_my_filter,
text: Some("Only My Issues"),
class_list: "filterChild",
on_click: Some(mouse_ev(Ev::Click, |_| Msg::ProjectToggleOnlyMy)),
on_click: Some(events::on_click_toggle_only_my()),
..Default::default()
}
.render();
@ -37,7 +39,7 @@ pub fn project_board_filters(model: &Model) -> Node<Msg> {
variant: ButtonVariant::Empty,
text: Some("Recently Updated"),
class_list: "filterChild",
on_click: Some(mouse_ev(Ev::Click, |_| Msg::ProjectToggleRecentlyUpdated)),
on_click: Some(events::on_click_toggle_recent()),
..Default::default()
}
.render();
@ -50,7 +52,7 @@ pub fn project_board_filters(model: &Model) -> Node<Msg> {
id!["clearAllFilters"],
C!["filterChild"],
"Clear all",
mouse_ev(Ev::Click, |_| Msg::ProjectClearFilters),
events::on_click_clear_filters()
]
} else {
empty![]
@ -72,7 +74,7 @@ pub fn avatars_filters(model: &Model) -> Node<Msg> {
_ => return empty![],
};
let active_avatar_filters = &project_page.active_avatar_filters;
let avatars: Vec<Node<Msg>> = model
let avatars = model
.user_ids
.iter()
.filter_map(|id| model.users_by_id.get(id))
@ -83,9 +85,7 @@ pub fn avatars_filters(model: &Model) -> Node<Msg> {
let styled_avatar = StyledAvatar {
avatar_url: user.avatar_url.as_deref(),
name: &user.name,
on_click: Some(mouse_ev(Ev::Click, move |_| {
Msg::ProjectAvatarFilterChanged(user_id, active)
})),
on_click: Some(events::on_click_filter_by_user(user_id, active)),
user_index: idx,
..StyledAvatar::default()
}
@ -95,8 +95,7 @@ pub fn avatars_filters(model: &Model) -> Node<Msg> {
C!["avatarIsActiveBorder"],
styled_avatar
]
})
.collect();
});
div![id!["avatars"], C!["filterChild"], avatars]
}

View File

@ -1,9 +1,11 @@
use std::collections::HashMap;
use jirs_data::{IssueStatus, ProjectCategory, TimeTracking};
use seed::prelude::*;
use seed::*;
use seed::prelude::*;
use jirs_data::{IssueStatus, ProjectCategory, TimeTracking};
use crate::{FieldId, Msg, ProjectFieldId};
use crate::components::styled_button::{ButtonVariant, StyledButton};
use crate::components::styled_checkbox::{ChildBuilder, StyledCheckbox, StyledCheckboxState};
use crate::components::styled_editor::render_styled_editor;
@ -17,7 +19,6 @@ use crate::components::styled_textarea::StyledTextarea;
use crate::model::{self, Model, PageContent};
use crate::pages::project_settings_page::{events, ProjectSettingsPage};
use crate::shared::inner_layout;
use crate::{FieldId, Msg, ProjectFieldId};
static TIME_TRACKING_FIBONACCI: &str = include_str!("./time_tracking_fibonacci.txt");
static TIME_TRACKING_HOURLY: &str = include_str!("./time_tracking_hourly.txt");
@ -246,7 +247,6 @@ fn add_column(page: &ProjectSettingsPage, column_style: &str) -> Node<Msg> {
auto_focus: true,
variant: InputVariant::Primary,
id: Some(FieldId::ProjectSettings(ProjectFieldId::IssueStatusName)),
input_handlers: vec![],
..Default::default()
}
.render();
@ -282,7 +282,6 @@ fn column_preview(
valid: page.name.is_valid(),
variant: InputVariant::Primary,
auto_focus: true,
input_handlers: vec![],
id: Some(FieldId::ProjectSettings(ProjectFieldId::IssueStatusName)),
..Default::default()
}
@ -300,14 +299,16 @@ fn show_column_preview(
per_column_issue_count: &HashMap<i32, i32>,
column_style: &str,
) -> Node<Msg> {
let id = is.id;
let drag_started = events::on_drag_start_start_drag_column(is.id);
let drag_stopped = events::on_drag_end_stop_drag_column(is.id);
let drag_over_handler = events::on_drag_over_exchange_position(is.id);
let drag_out = events::on_drag_leave_leave_drag_column(is.id);
let on_edit = events::on_click_edit_column(is.id);
let issue_count_in_column = per_column_issue_count.get(&id).cloned().unwrap_or_default();
let issue_count_in_column = per_column_issue_count
.get(&is.id)
.cloned()
.unwrap_or_default();
let delete_row = if issue_count_in_column == 0 {
let delete = StyledButton {
variant: ButtonVariant::Primary,

View File

@ -55,7 +55,7 @@ fn this_month_graph(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node<M
let legend_margin_width = (dominant as f64).log10() * SVG_MARGIN_X as f64;
// shapes, groups and texts
let mut svg_parts: Vec<Node<Msg>> = vec![];
let mut svg_parts: Vec<Node<Msg>> = Vec::with_capacity(40);
// each piece is part of column drawable view where number of parts depends on
// number of issues which have largest amount of issues
@ -68,7 +68,7 @@ fn this_month_graph(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node<M
/ page.last_day.day() as f64;
let resolution = 10;
let mut legend_parts: Vec<Node<Msg>> = vec![];
let mut legend_parts: Vec<Node<Msg>> = Vec::with_capacity((resolution + 1) * 2);
for y in 0..(resolution + 1) {
let current = dominant as f64 * (y as f64 / resolution as f64);
@ -86,7 +86,7 @@ fn this_month_graph(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node<M
At::Width => SVG_WIDTH as f64 - (legend_margin_width + SVG_MARGIN_X as f64),
At::Height => 1,
At::Style => "fill: var(--textLight);",
],]);
]]);
}
svg_parts.push(seed::g![legend_parts]);
@ -115,7 +115,7 @@ fn this_month_graph(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node<M
let selected = page.selected_day;
let current_date = day;
let on_click = mouse_ev("click", move |ev| {
let on_click = mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::PageChanged(PageChanged::Reports(ReportsPageChange::DaySelected(

View File

@ -0,0 +1,37 @@
use seed::prelude::{ev, Ev, EventHandler};
use crate::Msg;
type EvHandler = EventHandler<Msg>;
pub fn on_submit_send_sign_in() -> EvHandler {
send_sign_in(Ev::Submit)
}
pub fn on_click_send_sign_in() -> EvHandler {
send_sign_in(Ev::Click)
}
fn send_sign_in(event: Ev) -> EvHandler {
ev(event, |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::SignInRequest
})
}
pub fn on_submit_bind_client() -> EvHandler {
bind_token(Ev::Submit)
}
pub fn on_click_bind_client() -> EvHandler {
bind_token(Ev::Click)
}
fn bind_token(event: Ev) -> EvHandler {
ev(event, |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::BindClientRequest
})
}

View File

@ -2,6 +2,7 @@ pub use model::*;
pub use update::*;
pub use view::*;
mod events;
pub mod model;
pub mod update;
pub mod view;

View File

@ -9,15 +9,30 @@ pub type UsernameValidator = Touched<Between<4, 36>>;
pub type EmailValidator = Touched<Chain<Changed<AtLeast<6>>, Changed<EmailFormat>>>;
pub type TokenValidator = Touched<Chain<Between<10, 36>, Changed<UuidFormat>>>;
#[derive(Debug, PartialOrd, PartialEq)]
pub enum SignInState {
Initial,
RequestSend,
EmailSend,
InvalidPair,
}
impl Default for SignInState {
fn default() -> Self {
Self::Initial
}
}
#[derive(Debug, Default)]
pub struct SignInPage {
pub username: String,
pub email: String,
pub token: String,
pub login_success: bool,
pub bad_token: String,
// validators
pub username_v: UsernameValidator,
pub email_v: EmailValidator,
pub token_v: TokenValidator,
pub state: SignInState,
}

View File

@ -1,6 +1,6 @@
use std::str::FromStr;
use jirs_data::msg::WsMsgSession;
use jirs_data::msg::{WsError, WsMsgSession};
use jirs_data::{SignInFieldId, WsMsg};
use seed::prelude::*;
use seed::*;
@ -8,6 +8,7 @@ use uuid::Uuid;
use crate::model::{self, Model, Page, PageContent};
use crate::pages::sign_in_page::model::SignInPage;
use crate::pages::sign_in_page::SignInState;
use crate::shared::validate::*;
use crate::shared::write_auth_token;
use crate::ws::send_ws_msg;
@ -39,11 +40,16 @@ pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>)
page.token = value;
}
Msg::SignInRequest => {
if page.email.is_empty() || page.username.is_empty() {
return;
}
send_ws_msg(
WsMsgSession::AuthenticateRequest(page.email.clone(), page.username.clone()).into(),
model.ws.as_ref(),
orders,
);
page.state = SignInState::RequestSend;
}
Msg::BindClientRequest => {
let bind_token: uuid::Uuid = match Uuid::from_str(page.token.as_str()) {
@ -59,9 +65,15 @@ pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>)
orders,
);
}
Msg::InvalidPair => {
page.state = SignInState::InvalidPair;
}
Msg::WebSocketChange(change) => match change {
WebSocketChanged::WsMsg(WsMsg::Session(WsMsgSession::AuthenticateSuccess)) => {
page.login_success = true;
page.state = SignInState::EmailSend;
}
WebSocketChanged::WsMsg(WsMsg::Error(WsError::InvalidPair(_, _))) => {
page.state = SignInState::EmailSend;
}
WebSocketChanged::WsMsg(WsMsg::Session(WsMsgSession::BindTokenOk(access_token))) => {
match write_auth_token(Some(access_token)) {

View File

@ -7,6 +7,7 @@ use crate::components::styled_form::StyledForm;
use crate::components::styled_icon::{Icon, StyledIcon};
use crate::components::styled_input::StyledInput;
use crate::components::styled_link::StyledLink;
use crate::pages::sign_in_page::{events, SignInPage, SignInState};
use crate::shared::outer_layout;
use crate::shared::validate::Validator;
use crate::{match_page, model, FieldId, Msg, SignInFieldId};
@ -14,47 +15,107 @@ use crate::{match_page, model, FieldId, Msg, SignInFieldId};
pub fn view(model: &model::Model) -> Node<Msg> {
let page = match_page!(model, SignIn; Empty);
let username = StyledInput {
value: page.username.as_str(),
valid: page.username_v.is_valid(),
id: Some(FieldId::SignIn(SignInFieldId::Username)),
err_msg: page.username_v.message(),
..Default::default()
}
.render();
let username_field = StyledField {
label: "Username",
input: username,
let sign_in_form = if page.state == SignInState::EmailSend {
StyledForm {
fields: vec![StyledField {
input: div![
C!["emailSend"],
div![crate::images::email_send::render()],
div!["E-Mail send"]
],
..Default::default()
}
.render()],
..Default::default()
}
.render()
} else {
StyledForm {
heading: "Sign In to your account",
fields: vec![
username_field(page),
email_field(page),
submit_field(page),
if page.state == SignInState::InvalidPair {
div![C!["error"], p!["Invalid credentials"]]
} else {
Node::Empty
},
no_pass_section(),
],
on_submit: Some(events::on_submit_send_sign_in()),
}
.render()
};
let bind_token_form = StyledForm {
on_submit: Some(events::on_submit_bind_client()),
fields: vec![token_field(page), submit_token_field()],
..Default::default()
}
.render();
let email = StyledInput {
value: page.email.as_str(),
valid: page.email_v.is_valid(),
id: Some(FieldId::SignIn(SignInFieldId::Email)),
err_msg: page.email_v.message(),
..Default::default()
}
.render();
let email_field = StyledField {
label: "E-Mail",
input: email,
..Default::default()
}
.render();
outer_layout(model, "login", vec![sign_in_form, bind_token_form])
}
let submit = if page.login_success {
fn submit_token_field() -> Node<Msg> {
StyledField {
input: StyledButton {
variant: ButtonVariant::Primary,
text: Some("Authorize"),
on_click: Some(events::on_click_bind_client()),
..Default::default()
}
.render(),
..Default::default()
}
.render()
}
fn token_field(page: &SignInPage) -> Node<Msg> {
StyledField {
label: "Single use token",
input: StyledInput {
id: Some(FieldId::SignIn(SignInFieldId::Token)),
valid: page.token_v.is_valid(),
value: page.token.as_str(),
err_msg: page.token_v.message(),
..Default::default()
}
.render(),
..Default::default()
}
.render()
}
fn no_pass_section() -> Node<Msg> {
let help_icon = StyledIcon {
icon: Icon::Help,
class_list: "noPasswordHelp",
size: Some(22),
..Default::default()
}
.render();
div![
C!["noPasswordSection"],
attrs![At::Title => "We don't believe password is helping anyone. Instead after user provide correct login and e-mail he'll receive mail with 1-use token."],
help_icon,
span!["Why I don't see password?"]
]
}
fn submit_field(page: &SignInPage) -> Node<Msg> {
let submit = if page.state == SignInState::RequestSend {
StyledButton {
variant: ButtonVariant::Success,
text: Some("✓ Please check your mail"),
text: Some("Checking..."),
..Default::default()
}
} else {
StyledButton {
variant: ButtonVariant::Primary,
text: Some("Sign In"),
on_click: Some(mouse_ev(Ev::Click, |_| Msg::SignInRequest)),
on_click: Some(events::on_click_send_sign_in()),
..Default::default()
}
}
@ -66,76 +127,41 @@ pub fn view(model: &model::Model) -> Node<Msg> {
..Default::default()
}
.render();
let submit_field = StyledField {
StyledField {
input: div![C!["twoRow"], submit, register_link],
..Default::default()
}
.render();
let help_icon = StyledIcon {
icon: Icon::Help,
class_list: "noPasswordHelp",
size: Some(22),
..Default::default()
}
.render();
let no_pass_section = div![
C!["noPasswordSection"],
attrs![At::Title => "We don't believe password is helping anyone. Instead after user provide correct login and e-mail he'll receive mail with 1-use token."],
help_icon,
span!["Why I don't see password?"]
];
let sign_in_form = StyledForm {
heading: "Sign In to your account",
fields: vec![username_field, email_field, submit_field, no_pass_section],
on_submit: Some(ev(Ev::Submit, |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::SignInRequest
})),
}
.render();
let token = StyledInput {
id: Some(FieldId::SignIn(SignInFieldId::Token)),
valid: page.token_v.is_valid(),
value: page.token.as_str(),
err_msg: page.token_v.message(),
..Default::default()
}
.render();
let token_field = StyledField {
label: "Single use token",
input: token,
..Default::default()
}
.render();
let submit_token = StyledButton {
variant: ButtonVariant::Primary,
text: Some("Authorize"),
on_click: Some(mouse_ev(Ev::Click, |_| Msg::BindClientRequest)),
..Default::default()
}
.render();
let submit_token_field = StyledField {
input: submit_token,
..Default::default()
}
.render();
let bind_token_form = StyledForm {
on_submit: Some(ev(Ev::Submit, |ev| {
ev.stop_propagation();
ev.prevent_default();
Msg::BindClientRequest
})),
fields: vec![token_field, submit_token_field],
..Default::default()
}
.render();
let children = vec![sign_in_form, bind_token_form];
outer_layout(model, "login", children)
.render()
}
fn username_field(page: &SignInPage) -> Node<Msg> {
StyledField {
label: "Username",
input: StyledInput {
value: page.username.as_str(),
valid: page.username_v.is_valid(),
id: Some(FieldId::SignIn(SignInFieldId::Username)),
err_msg: page.username_v.message(),
..Default::default()
}
.render(),
..Default::default()
}
.render()
}
fn email_field(page: &SignInPage) -> Node<Msg> {
StyledField {
label: "E-Mail",
input: StyledInput {
value: page.email.as_str(),
valid: page.email_v.is_valid(),
id: Some(FieldId::SignIn(SignInFieldId::Email)),
err_msg: page.email_v.message(),
..Default::default()
}
.render(),
..Default::default()
}
.render()
}

View File

@ -32,6 +32,10 @@ pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>)
page.email_touched = true;
}
Msg::SignUpRequest => {
if page.email.is_empty() || page.username.is_empty() {
return;
}
send_ws_msg(
WsMsgSession::SignUpRequest(page.email.clone(), page.username.clone()).into(),
model.ws.as_ref(),
@ -47,6 +51,9 @@ pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>)
}
_ => (),
},
Msg::InvalidPair => {
page.error = String::from("Pair already taken");
}
_ => (),
}
}

View File

@ -15,30 +15,28 @@ use crate::{match_page, model, FieldId, Msg};
pub fn view(model: &model::Model) -> Node<Msg> {
let page = match_page!(model, SignUp; Empty);
let username = StyledInput {
value: page.username.as_str(),
valid: !page.username_touched || page.username.len() > 1,
id: Some(FieldId::SignUp(SignUpFieldId::Username)),
..Default::default()
}
.render();
let username_field = StyledField {
label: "Username",
input: username,
input: StyledInput {
value: page.username.as_str(),
valid: !page.username_touched || page.username.len() > 1,
id: Some(FieldId::SignUp(SignUpFieldId::Username)),
..Default::default()
}
.render(),
..Default::default()
}
.render();
let email = StyledInput {
value: page.email.as_str(),
valid: !page.email_touched || is_email(page.email.as_str()),
id: Some(FieldId::SignUp(SignUpFieldId::Email)),
..Default::default()
}
.render();
let email_field = StyledField {
label: "E-Mail",
input: email,
input: StyledInput {
value: page.email.as_str(),
valid: !page.email_touched || is_email(page.email.as_str()),
id: Some(FieldId::SignUp(SignUpFieldId::Email)),
..Default::default()
}
.render(),
..Default::default()
}
.render();
@ -59,16 +57,18 @@ pub fn view(model: &model::Model) -> Node<Msg> {
}
.render();
let sign_in_link = StyledLink {
children: vec![span!["Sign In"]],
class_list: "signInLink",
href: "/login",
..Default::default()
}
.render();
let submit_field = StyledField {
input: div![C!["twoRow"], submit, sign_in_link],
input: div![
C!["twoRow"],
submit,
StyledLink {
children: vec![span!["Sign In"]],
class_list: "signInLink",
href: "/login",
..Default::default()
}
.render()
],
..Default::default()
}
.render();

View File

@ -1,12 +1,13 @@
pub use init_load_sets::*;
use jirs_data::msg::{
WsMsgComment, WsMsgEpic, WsMsgIssue, WsMsgIssueStatus, WsMsgMessage, WsMsgProject,
WsError, WsMsgComment, WsMsgEpic, WsMsgIssue, WsMsgIssueStatus, WsMsgMessage, WsMsgProject,
WsMsgSession, WsMsgUser,
};
use jirs_data::*;
use seed::prelude::*;
use crate::model::*;
use crate::pages::sign_in_page::SignInState;
use crate::shared::{go_to_board, write_auth_token};
use crate::{Msg, OperationKind, ResourceKind, WebSocketChanged};
@ -432,7 +433,7 @@ pub fn update(msg: &mut WsMsg, model: &mut Model, orders: &mut impl Orders<Msg>)
if let Some(idx) = model.epics.iter().position(|e| e.id == *id) {
model.epics.remove(idx);
}
model.epics_by_id.remove(&id);
model.epics_by_id.remove(id);
model.epics.sort_by(|a, b| a.id.cmp(&b.id));
orders.send_msg(Msg::ResourceChanged(
ResourceKind::Epic,
@ -442,7 +443,10 @@ pub fn update(msg: &mut WsMsg, model: &mut Model, orders: &mut impl Orders<Msg>)
}
WsMsg::Session(WsMsgSession::AuthenticateSuccess) => {
let page = crate::match_page_mut!(model, SignIn);
page.login_success = true;
page.state = SignInState::EmailSend;
}
WsMsg::Error(WsError::InvalidLoginPair) => {
orders.send_msg(Msg::InvalidPair);
}
WsMsg::Session(WsMsgSession::BindTokenOk(access_token)) => {
match write_auth_token(Some(*access_token)) {
@ -454,6 +458,9 @@ pub fn update(msg: &mut WsMsg, model: &mut Model, orders: &mut impl Orders<Msg>)
}
}
}
WsMsg::Session(WsMsgSession::SignUpPairTaken) => {
orders.send_msg(Msg::InvalidPair);
}
_ => {
log::info!(
"got web socket message but don't know what to do with it {:?}",