Add date time picker

This commit is contained in:
Adrian Woźniak 2020-08-17 23:18:51 +02:00
parent 493ab96198
commit 5c98e51a10
18 changed files with 415 additions and 32 deletions

View File

@ -29,7 +29,7 @@ tasks:
export NODE_ENV=production
./scripts/prod.sh
export TAR_NAME=$(date -u +"%Y%m%d%H%M%s")
tar -czvf ~/${TAR_NAME}.tar.gz ./build
cp ~/${TAR_NAME}.tar.gz ~/latest.tar.gz
tar -cJvf ~/${TAR_NAME}.tar.xz ./build
cp ~/${TAR_NAME}.tar.xz ~/latest.tar.xz
artifacts:
- latest.tar.gz

View File

@ -39,7 +39,8 @@ tasks:
cd jirs
cargo build --all --release
strip -s ./target/release/jirs_server
tar -cJvf ~/server.tar.xz ./target/release/jirs_server
- deploy: |
cp ~/jirs/target/release/jirs_server ~/
cp ~/server.tar.xz ~/latest.tar.xz
artifacts:
- jirs_server

View File

@ -50,7 +50,7 @@ https://git.sr.ht/~tsumanu/jirs
* [ ] Epic `starts` and `ends` date
* [X] Grouping by Epic
* [X] Basic Rich Text Editor
* [ ] Insert Code in Rich Text Editor
* [X] Insert Code in Rich Text Editor
* [X] Code syntax
* [ ] Personal settings to choose MDE (Markdown Editor) or RTE
* [ ] Issues and filters view

View File

@ -0,0 +1,64 @@
.styledDateTimeInput {
position: relative;
}
/* TOOLTIP */
.dateTimeTooltip > .actions {
}
.dateTimeTooltip > .calendar {
}
.dateTimeTooltip > .calendar > .week {
display: flex;
border-left: 1px solid var(--textDarkest);
border-right: 1px solid var(--textDarkest);
}
.dateTimeTooltip > .calendar > .week:first-child {
border-top: 1px solid var(--textDarkest);
}
.dateTimeTooltip > .calendar > .week:last-child {
border-bottom: 1px solid var(--textDarkest);
}
.dateTimeTooltip > .calendar > .week.weekHeader {
border-bottom: 1px solid var(--textDarkest);
}
.dateTimeTooltip > .calendar > .week > .day {
width: calc(100% / 7);
text-align: center;
height: 3rem;
line-height: 3rem;
cursor: pointer;
}
.dateTimeTooltip > .calendar > .week > .day.inCurrentMonth:hover,
.dateTimeTooltip > .calendar > .week > .day.outCurrentMonth:hover {
background: var(--primary);
color: var(--asideIcon);
}
.dateTimeTooltip > .calendar > .week > .day.inCurrentMonth {
color: var(--textDarkest);
}
.dateTimeTooltip > .calendar > .week > .day.outCurrentMonth {
color: var(--textLight);
}
/*.styledDateTimeInput > .calendar > .week > .day > .weekday {*/
/* font-family: var(--font-bold);*/
/* line-height: 1.5rem;*/
/* font-size: 1rem;*/
/* cursor: pointer;*/
/*}*/
.dateTimeTooltip > .calendar > .week > .day {
font-family: var(--font-medium);
font-size: 1rem;
cursor: pointer;
}

View File

@ -22,6 +22,7 @@
@import "./css/styledPage.css";
@import "./css/styledLink.css";
@import "./css/styledRte.css";
@import "./css/styledDateTimeInput.css";
@import "./css/app.css";
@import "./css/issue.css";
@import "./css/project.css";

View File

@ -74,7 +74,15 @@ impl std::fmt::Display for FieldId {
EditIssueModalSection::Issue(IssueFieldId::ListPosition) => {
f.write_str("editIssue-listPosition")
}
EditIssueModalSection::Issue(IssueFieldId::Epic) => f.write_str("editIssue-epic"),
EditIssueModalSection::Issue(IssueFieldId::EpicName) => {
f.write_str("editIssue-epicName")
}
EditIssueModalSection::Issue(IssueFieldId::EpicStartsAt) => {
f.write_str("editIssue-epicStartsAt")
}
EditIssueModalSection::Issue(IssueFieldId::EpicEndsAt) => {
f.write_str("editIssue-epicEndsAt")
}
},
FieldId::AddIssueModal(sub) => match sub {
IssueFieldId::Type => f.write_str("issueTypeAddIssueModal"),
@ -88,7 +96,9 @@ impl std::fmt::Display for FieldId {
IssueFieldId::TimeSpent => f.write_str("addIssueModal-timeSpend"),
IssueFieldId::TimeRemaining => f.write_str("addIssueModal-timeRemaining"),
IssueFieldId::ListPosition => f.write_str("addIssueModal-listPosition"),
IssueFieldId::Epic => f.write_str("addIssueModal-epic"),
IssueFieldId::EpicName => f.write_str("addIssueModal-epicName"),
IssueFieldId::EpicStartsAt => f.write_str("addIssueModal-epicStartsAt"),
IssueFieldId::EpicEndsAt => f.write_str("addIssueModal-epicEndsAt"),
},
FieldId::TextFilterBoard => f.write_str("textFilterBoard"),
FieldId::CopyButtonLabel => f.write_str("copyButtonLabel"),

View File

@ -8,6 +8,7 @@ pub use fields::*;
use jirs_data::*;
use crate::model::{ModalType, Model, Page};
use crate::shared::styled_date_time_input::DateTimeMsg;
use crate::shared::styled_rte::RteMsg;
use crate::shared::styled_select::StyledSelectChange;
use crate::shared::styled_tooltip::{Variant as StyledTooltip, Variant};
@ -50,6 +51,7 @@ pub enum Msg {
ProjectChanged(Option<Project>),
StyledSelectChanged(FieldId, StyledSelectChange),
StyledDateTimeInputChanged(FieldId, DateTimeMsg),
InternalFailure(String),
ToggleTooltip(StyledTooltip),

View File

@ -2,6 +2,7 @@ use seed::{prelude::*, *};
use jirs_data::{IssueFieldId, IssuePriority, ToVec, UserId, WsMsg};
use crate::shared::styled_date_time_input::StyledDateTimeInput;
use crate::{
modal::issues::epic_field,
model::{AddIssueModal, IssueModal, ModalType, Model},
@ -226,7 +227,7 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node<Msg> {
.values
.get(0)
.cloned()
.map(|n| Type::from(n))
.map(Type::from)
.unwrap_or_else(|| Type::Task);
let issue_type_field = issue_type_field(modal);
@ -238,7 +239,18 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node<Msg> {
let form = match issue_type {
Type::Epic => {
let name_field = name_field(modal);
form.add_field(name_field)
let starts = StyledDateTimeInput::build()
.state(&modal.epic_starts_at_state)
.build(FieldId::AddIssueModal(IssueFieldId::EpicStartsAt))
.into_node();
let end = StyledDateTimeInput::build()
.state(&modal.epic_ends_at_state)
.build(FieldId::AddIssueModal(IssueFieldId::EpicEndsAt))
.into_node();
form.add_field(name_field).add_field(starts).add_field(end)
}
Type::Task | Type::Story | Type::Bug => {
let short_summary_field = short_summary_field(modal);
@ -246,7 +258,8 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node<Msg> {
let reporter_field = reporter_field(model, modal);
let assignees_field = assignees_field(model, modal);
let issue_priority_field = issue_priority_field(modal);
let epic_field = epic_field(model, modal, FieldId::AddIssueModal(IssueFieldId::Epic));
let epic_field =
epic_field(model, modal, FieldId::AddIssueModal(IssueFieldId::EpicName));
form.add_field(short_summary_field)
.add_field(description_field)
@ -258,7 +271,6 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node<Msg> {
};
let submit = {
let issue_type = issue_type.clone();
StyledButton::build()
.primary()
.text(issue_type.submit_label())

View File

@ -262,13 +262,13 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
);
}
Msg::StyledSelectChanged(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Epic)),
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::EpicName)),
StyledSelectChange::Changed(v),
) => {
send_ws_msg(
WsMsg::IssueUpdate(
modal.id,
IssueFieldId::Epic,
IssueFieldId::EpicName,
PayloadVariant::OptionI32(v.map(|n| n as EpicId).clone()),
),
model.ws.as_ref(),
@ -803,7 +803,7 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
let epic_field = epic_field(
model,
modal,
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Epic)),
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::EpicName)),
)
.unwrap_or_else(|| empty![]);

View File

@ -11,6 +11,7 @@ use jirs_data::*;
use crate::modal::time_tracking::value_for_time_tracking;
use crate::shared::drag::DragState;
use crate::shared::styled_checkbox::StyledCheckboxState;
use crate::shared::styled_date_time_input::StyledDateTimeInputState;
use crate::shared::styled_editor::Mode;
use crate::shared::styled_image_input::StyledImageInputState;
use crate::shared::styled_input::StyledInputState;
@ -69,7 +70,9 @@ pub struct EditIssueModal {
pub reporter_state: StyledSelectState,
pub assignees_state: StyledSelectState,
pub priority_state: StyledSelectState,
pub epic_state: StyledSelectState,
pub epic_name_state: StyledSelectState,
pub epic_starts_at_state: StyledDateTimeInputState,
pub epic_ends_at_state: StyledDateTimeInputState,
pub estimate: StyledInputState,
pub estimate_select: StyledSelectState,
@ -86,11 +89,11 @@ pub struct EditIssueModal {
impl IssueModal for EditIssueModal {
fn epic_id_value(&self) -> Option<u32> {
self.epic_state.values.get(0).cloned()
self.epic_name_state.values.get(0).cloned()
}
fn epic_state(&self) -> &StyledSelectState {
&self.epic_state
&self.epic_name_state
}
fn update_states(&mut self, msg: &Msg, orders: &mut impl Orders<Msg>) {
@ -105,7 +108,7 @@ impl IssueModal for EditIssueModal {
self.time_spent_select.update(msg, orders);
self.time_remaining.update(msg);
self.time_remaining_select.update(msg, orders);
self.epic_state.update(msg, orders);
self.epic_name_state.update(msg, orders);
}
}
@ -149,14 +152,6 @@ impl EditIssueModal {
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Priority)),
vec![issue.priority.into()],
),
epic_state: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Epic)),
issue
.epic_id
.as_ref()
.map(|id| vec![*id as u32])
.unwrap_or_default(),
),
estimate: StyledInputState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Estimate)),
value_for_time_tracking(&issue.estimate, &time_tracking_type),
@ -190,6 +185,23 @@ impl EditIssueModal {
body: String::new(),
creating: false,
},
// epic
epic_name_state: StyledSelectState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::EpicName)),
issue
.epic_id
.as_ref()
.map(|id| vec![*id as u32])
.unwrap_or_default(),
),
epic_starts_at_state: StyledDateTimeInputState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::EpicStartsAt)),
None,
),
epic_ends_at_state: StyledDateTimeInputState::new(
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::EpicStartsAt)),
None,
),
}
}
}
@ -214,16 +226,19 @@ pub struct AddIssueModal {
pub reporter_state: StyledSelectState,
pub assignees_state: StyledSelectState,
pub priority_state: StyledSelectState,
pub epic_state: StyledSelectState,
// epic
pub epic_name_state: StyledSelectState,
pub epic_starts_at_state: StyledDateTimeInputState,
pub epic_ends_at_state: StyledDateTimeInputState,
}
impl IssueModal for AddIssueModal {
fn epic_id_value(&self) -> Option<u32> {
self.epic_state.values.get(0).cloned()
self.epic_name_state.values.get(0).cloned()
}
fn epic_state(&self) -> &StyledSelectState {
&self.epic_state
&self.epic_name_state
}
fn update_states(&mut self, msg: &Msg, orders: &mut impl Orders<Msg>) {
@ -232,7 +247,9 @@ impl IssueModal for AddIssueModal {
self.reporter_state.update(msg, orders);
self.type_state.update(msg, orders);
self.priority_state.update(msg, orders);
self.epic_state.update(msg, orders);
self.epic_name_state.update(msg, orders);
self.epic_starts_at_state.update(msg, orders);
self.epic_ends_at_state.update(msg, orders);
}
}
@ -264,7 +281,19 @@ impl Default for AddIssueModal {
FieldId::AddIssueModal(IssueFieldId::Priority),
vec![],
),
epic_state: StyledSelectState::new(FieldId::AddIssueModal(IssueFieldId::Epic), vec![]),
// epic
epic_name_state: StyledSelectState::new(
FieldId::AddIssueModal(IssueFieldId::EpicName),
vec![],
),
epic_starts_at_state: StyledDateTimeInputState::new(
FieldId::AddIssueModal(IssueFieldId::EpicStartsAt),
None,
),
epic_ends_at_state: StyledDateTimeInputState::new(
FieldId::AddIssueModal(IssueFieldId::EpicEndsAt),
None,
),
}
}
}

View File

@ -15,6 +15,7 @@ pub mod styled_avatar;
pub mod styled_button;
pub mod styled_checkbox;
pub mod styled_confirm_modal;
pub mod styled_date_time_input;
pub mod styled_editor;
pub mod styled_field;
pub mod styled_form;

View File

@ -0,0 +1,232 @@
use std::ops::RangeInclusive;
use chrono::Duration;
use {
chrono::prelude::*,
seed::{prelude::*, *},
};
use crate::shared::styled_button::StyledButton;
use crate::shared::styled_icon::Icon;
use crate::shared::styled_tooltip::StyledTooltip;
use crate::{shared::ToNode, FieldId, Msg};
#[derive(Debug)]
pub enum DateTimeMsg {
MonthChanged(Option<NaiveDateTime>),
DayChanged(Option<NaiveDateTime>),
PopupVisibilityChanged(bool),
}
#[derive(Clone, Debug, PartialOrd, PartialEq)]
pub struct StyledDateTimeInputState {
field_id: FieldId,
timestamp: Option<chrono::NaiveDateTime>,
popup_visible: bool,
}
impl StyledDateTimeInputState {
pub fn new(field_id: FieldId, timestamp: Option<NaiveDateTime>) -> Self {
Self {
field_id,
timestamp,
popup_visible: false,
}
}
pub fn update(&mut self, msg: &Msg, _orders: &mut impl Orders<Msg>) {
match msg {
Msg::StyledDateTimeInputChanged(field_id, DateTimeMsg::MonthChanged(new_date))
if field_id == &self.field_id =>
{
self.timestamp = *new_date;
}
Msg::StyledDateTimeInputChanged(field_id, DateTimeMsg::DayChanged(new_date))
if field_id == &self.field_id =>
{
self.timestamp = *new_date;
}
Msg::StyledDateTimeInputChanged(field_id, DateTimeMsg::PopupVisibilityChanged(b))
if field_id == &self.field_id =>
{
self.popup_visible = *b;
}
_ => {}
}
}
}
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,
}
}
}
impl ToNode for StyledDateTimeInput {
fn into_node(self) -> Node<Msg> {
render(self)
}
}
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 current = values
.timestamp
.unwrap_or_else(|| chrono::Utc::now().naive_utc());
let start = current.with_day0(0).unwrap().date();
let end = (start + Duration::days(32)).with_day0(0).unwrap();
let calendar_start = match start.weekday() {
Weekday::Mon => start,
Weekday::Tue => start - Duration::days(1),
Weekday::Wed => start - Duration::days(2),
Weekday::Thu => start - Duration::days(3),
Weekday::Fri => start - Duration::days(4),
Weekday::Sat => start - Duration::days(5),
Weekday::Sun => start - Duration::days(6),
};
let calendar_end = match end.weekday() {
Weekday::Mon => end + Duration::days(6),
Weekday::Tue => end + Duration::days(5),
Weekday::Wed => end + Duration::days(4),
Weekday::Thu => end + Duration::days(3),
Weekday::Fri => end + Duration::days(2),
Weekday::Sat => end + Duration::days(1),
Weekday::Sun => end,
};
let current_month_range = start..=end;
let mut current = calendar_start;
let mut weeks = vec![];
let range = calendar_start..=calendar_end;
let mut current_week = vec![];
loop {
if !range.contains(&current) {
break;
}
if current.weekday() == Weekday::Mon && !current_week.is_empty() {
weeks.push(div![C!["week"], current_week]);
current_week = vec![];
}
current_week.push(day_cell(&current, &current_month_range));
current += Duration::days(1);
}
if !current_week.is_empty() {
weeks.push(div![C!["week"], current_week]);
}
let close_tooltip = {
let field_id = values.field_id.clone();
StyledButton::build()
.empty()
.icon(Icon::Close)
.on_click(mouse_ev(Ev::Click, move |ev| {
ev.prevent_default();
Some(Msg::StyledDateTimeInputChanged(
field_id,
DateTimeMsg::PopupVisibilityChanged(false),
))
}))
.build()
.into_node()
};
let tooltip = StyledTooltip::build()
.visible(values.popup_visible)
.add_class("dateTimeTooltip")
.add_child(h2![span!["Add table"], close_tooltip])
.add_child(div![
C!["actions"],
button![C!["prev"], "Prev"],
button![C!["next"], "Next"],
])
.add_child(div![
C!["calendar"],
div![
C!["weekHeader week"],
div![C!["day"], format!("{}", Weekday::Mon).as_str()],
div![C!["day"], format!("{}", Weekday::Tue).as_str()],
div![C!["day"], format!("{}", Weekday::Wed).as_str()],
div![C!["day"], format!("{}", Weekday::Thu).as_str()],
div![C!["day"], format!("{}", Weekday::Fri).as_str()],
div![C!["day"], format!("{}", Weekday::Sat).as_str()],
div![C!["day"], format!("{}", Weekday::Sun).as_str()],
],
weeks
])
.build()
.into_node();
let input = {
let field_id = values.field_id.clone();
let on_focus = ev(Ev::Click, move |ev| {
ev.prevent_default();
ev.stop_propagation();
Msg::StyledDateTimeInputChanged(field_id, DateTimeMsg::PopupVisibilityChanged(true))
});
let text = values
.timestamp
.map(|d| format!("{}", d))
.unwrap_or_default();
StyledButton::build()
.add_class("")
.on_click(on_focus)
.text(text)
.active(true)
.empty()
.build()
.into_node()
};
div![
C!["styledDateTimeInput"],
attrs![At::Class => format!("{}", values.field_id).as_str()],
input,
tooltip,
]
}
fn day_cell(current: &NaiveDate, current_month_range: &RangeInclusive<NaiveDate>) -> Node<Msg> {
div![
C!["day"],
attrs![At::Class => format!("{}", current.weekday())],
if current_month_range.contains(&current) {
C!["inCurrentMonth"]
} else {
C!["outCurrentMonth"]
},
format!("{}", current.day()).as_str(),
]
}

View File

@ -55,5 +55,14 @@ pub enum IssueFieldId {
TimeSpent,
TimeRemaining,
IssueStatusId,
Epic,
EpicName,
EpicStartsAt,
EpicEndsAt,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)]
pub enum EpicFieldId {
Name,
StartsAt,
EndsAt,
}

View File

@ -588,4 +588,6 @@ pub struct Epic {
pub project_id: ProjectId,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub starts_at: Option<NaiveDateTime>,
pub ends_at: Option<NaiveDateTime>,
}

View File

@ -0,0 +1,4 @@
ALTER TABLE epics
DROP COLUMN starts_at;
ALTER TABLE epics
DROP COLUMN ends_at;

View File

@ -0,0 +1,4 @@
ALTER TABLE epics
ADD COLUMN starts_at TIMESTAMP;
ALTER TABLE epics
ADD COLUMN ends_at TIMESTAMP;

View File

@ -91,6 +91,18 @@ table! {
///
/// (Automatically generated by Diesel.)
updated_at -> Timestamp,
/// The `starts_at` column of the `epics` table.
///
/// Its SQL type is `Nullable<Timestamp>`.
///
/// (Automatically generated by Diesel.)
starts_at -> Nullable<Timestamp>,
/// The `ends_at` column of the `epics` table.
///
/// Its SQL type is `Nullable<Timestamp>`.
///
/// (Automatically generated by Diesel.)
ends_at -> Nullable<Timestamp>,
}
}

View File

@ -60,7 +60,7 @@ impl WsHandler<UpdateIssueHandler> for WebSocketActor {
(IssueFieldId::TimeRemaining, PayloadVariant::OptionI32(o)) => {
msg.time_remaining = o;
}
(IssueFieldId::Epic, PayloadVariant::OptionI32(o)) => {
(IssueFieldId::EpicName, PayloadVariant::OptionI32(o)) => {
msg.epic_id = Some(o);
}
_ => (),