Add modal to add issue

This commit is contained in:
Adrian Wozniak 2020-04-05 15:15:09 +02:00
parent a0f1b90d9d
commit 780a0c498a
17 changed files with 396 additions and 57 deletions

29
Cargo.lock generated
View File

@ -283,6 +283,21 @@ dependencies = [
"url 2.1.1",
]
[[package]]
name = "actix-web-actors"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1bd41bd66c4e9b5274cec87aac30168e63d64e96fd19db38edef6b46ba2982"
dependencies = [
"actix",
"actix-codec",
"actix-http",
"actix-web",
"bytes",
"futures 0.3.4",
"pin-project",
]
[[package]]
name = "actix-web-codegen"
version = "0.2.1"
@ -425,6 +440,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "bincode"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5753e2a71534719bf3f4e57006c3a4f0d2c672a4b676eec84161f763eca87dbf"
dependencies = [
"byteorder",
"serde",
]
[[package]]
name = "bitflags"
version = "1.2.1"
@ -1038,10 +1063,12 @@ version = "0.1.0"
name = "jirs-client"
version = "0.1.0"
dependencies = [
"bincode",
"chrono",
"futures 0.1.29",
"jirs-data",
"js-sys",
"lazy_static",
"seed",
"serde",
"serde_json",
@ -1070,7 +1097,9 @@ dependencies = [
"actix-rt",
"actix-service",
"actix-web",
"actix-web-actors",
"bigdecimal",
"bincode",
"bitflags",
"byteorder",
"chrono",

View File

@ -18,11 +18,15 @@ jirs-data = { path = "../jirs-data" }
seed = { version = "*" }
serde = "*"
serde_json = "*"
bincode = "1.2.1"
chrono = { version = "*", features = [ "serde" ] }
uuid = { version = "*", features = [ "serde" ] }
wasm-bindgen = "*"
js-sys = "*"
futures = "^0.1.26"
lazy_static = "*"
[dependencies.js-sys]
js-sys = "*"
[dependencies.web-sys]
version = "*"
@ -34,5 +38,9 @@ features = [
"DomRect",
"HtmlDocument",
"Document",
"Selection"
"Selection",
"CssStyleDeclaration",
"WebSocket",
"BinaryType",
"Blob",
]

View File

@ -62,3 +62,31 @@
.styledField > * {
display: block;
}
.styledTextArea {
display: inline-block;
width: 100%;
}
.styledTextArea > textarea {
overflow-y: hidden;
width: 100%;
padding: 8px 12px 9px;
border-radius: 3px;
border: 1px solid var(--borderLightest);
color: var(--textDarkest);
background: var(--backgroundLightest);
font-family: var(--font-regular);
font-weight: normal;
font-size: 15px
}
.styledTextArea > textarea:focus {
background: #fff;
border: 1px solid var(--borderInputFocus);
box-shadow: 0 0 0 1px var(--borderInputFocus);
}
.styledTextArea > textarea.invalid:focus {
border: 1px solid var(--danger);
}

View File

@ -1,9 +1,13 @@
use seed::Method;
use wasm_bindgen::prelude::*;
use jirs_data::UpdateIssuePayload;
use jirs_data::{UpdateIssuePayload, WsMsg};
use crate::shared::host_client;
use crate::Msg;
use seed::prelude::Closure;
use std::sync::Once;
use wasm_bindgen::JsCast;
pub async fn fetch_current_project(host_url: String) -> Result<Msg, Msg> {
match host_client(host_url, "/project") {
@ -49,3 +53,93 @@ pub async fn delete_issue(host_url: String, id: i32) -> Result<Msg, Msg> {
Err(e) => return Ok(Msg::InternalFailure(e)),
}
}
pub struct WebSocket {
ws: web_sys::WebSocket,
queue: Vec<WsMsg>,
}
impl Default for WebSocket {
fn default() -> WebSocket {
use js_sys::*;
use seed::prelude::*;
use web_sys::*;
let native = web_sys::WebSocket::new("ws://localhost:5000/ws/").unwrap();
native.set_binary_type(web_sys::BinaryType::Arraybuffer);
let onmessage_callback =
Closure::wrap(Box::new(move |e: MessageEvent| {}) as Box<dyn FnMut(MessageEvent)>);
native.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref()));
onmessage_callback.forget();
// let onerror_callback = Closure::wrap(Box::new(move |e: ErrorEvent| {
// seed::log!("error event: {:?}", e);
// }) as Box<dyn FnMut(ErrorEvent)>);
// native.set_onerror(Some(onerror_callback.as_ref().unchecked_ref()));
// onerror_callback.forget();
let cloned_ws = native.clone();
let onopen_callback = Closure::wrap(Box::new(move |_| {
seed::log!("socket opened");
match cloned_ws.send_with_str("ping") {
Ok(_) => seed::log!("message successfully sent"),
Err(err) => seed::log!("error sending message: {:?}", err),
}
}) as Box<dyn FnMut(JsValue)>);
native.set_onopen(Some(onopen_callback.as_ref().unchecked_ref()));
onopen_callback.forget();
Self {
ws: native,
queue: vec![],
}
}
}
impl WebSocket {
pub fn send_with_u8_array(&self, buffer: &[u8]) {
use seed::*;
self.ws
.send_with_u8_array(buffer)
.unwrap_or_else(|e| error!(e));
}
pub fn send(&mut self) {
use bincode;
for msg in self.queue.iter() {
let encoded: Vec<u8> = bincode::serialize(msg).unwrap();
self.send_with_u8_array(encoded.as_slice());
}
self.queue.clear();
}
}
static INIT_WS: Once = Once::new();
static mut WS: Option<WebSocket> = None;
pub fn ws() -> &'static mut WebSocket {
unsafe {
INIT_WS.call_once(|| WS = Some(WebSocket::default()));
let ws_ping = Box::new(|| match WS.as_mut().map(|ws| ws.ws.ready_state()) {
Some(0) => {}
Some(1) => {
ws_send(WsMsg::Ping);
WS.as_mut().unwrap().send();
}
_ => {
WS = Some(WebSocket::default());
}
}) as Box<dyn Fn()>;
seed::set_interval(ws_ping, 10_000);
WS.as_mut().unwrap()
}
}
// pub fn ws_received() {}
//
pub fn ws_send(msg: jirs_data::WsMsg) {
ws().queue.push(msg);
}

View File

@ -1,9 +1,13 @@
#[macro_use]
extern crate lazy_static;
use seed::fetch::FetchObject;
use seed::{prelude::*, *};
use jirs_data::IssueStatus;
use crate::model::{ModalType, Page};
use crate::api::ws;
use crate::model::{ModalType, Model, Page};
use crate::shared::styled_select::StyledSelectChange;
mod api;
@ -22,9 +26,16 @@ pub type AvatarFilterActive = bool;
#[derive(Clone, Debug)]
pub enum FieldId {
// edit issue
IssueTypeEditModalTop,
// project boards
TextFilterBoard,
//
CopyButtonLabel,
// add issue
IssueTypeAddIssueModal,
SummaryAddIssueModal,
DescriptionAddIssueModal,
}
#[derive(Clone, Debug)]
@ -55,6 +66,9 @@ pub enum Msg {
IssueDragStopped(IssueId),
IssueDropZone(IssueStatus),
// inputs
InputChanged(FieldId, String),
// issues
IssueUpdateResult(FetchObject<String>),
IssueDeleteResult(FetchObject<String>),
@ -128,7 +142,16 @@ pub fn set_host_url(url: String) {
}
}
fn after_mount(_url: Url, _orders: &mut impl Orders<Msg>) -> AfterMount<Model> {
ws();
let model = Model::default();
AfterMount::new(model).url_handling(UrlHandling::None)
}
#[wasm_bindgen]
pub fn render() {
App::builder(update, view).routes(routes).build_and_start();
App::builder(update, view)
.routes(routes)
.after_mount(after_mount)
.build_and_start();
}

View File

@ -6,10 +6,11 @@ use crate::model::{AddIssueModal, Model};
use crate::shared::styled_button::StyledButton;
use crate::shared::styled_field::StyledField;
use crate::shared::styled_form::StyledForm;
use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::styled_icon::StyledIcon;
use crate::shared::styled_input::StyledInput;
use crate::shared::styled_modal::{StyledModal, Variant as ModalVariant};
use crate::shared::styled_select::StyledSelect;
use crate::shared::styled_textarea::StyledTextarea;
use crate::shared::ToNode;
use crate::{FieldId, Msg};
@ -35,8 +36,7 @@ pub fn view(_model: &Model, modal: &AddIssueModal) -> Node<Msg> {
.build()
.into_node();
let short_summary = StyledInput::build()
.id("issue-short-summary")
let short_summary = StyledInput::build(FieldId::SummaryAddIssueModal)
.valid(true)
.build()
.into_node();
@ -47,6 +47,17 @@ pub fn view(_model: &Model, modal: &AddIssueModal) -> Node<Msg> {
.build()
.into_node();
let description = StyledTextarea::build()
.height(110)
.build(FieldId::DescriptionAddIssueModal)
.into_node();
let description_field = StyledField::build()
.label("Description")
.tip("Describe the issue in as much detail as you'd like.")
.input(description)
.build()
.into_node();
let submit = StyledButton::build()
.primary()
.text("Create Issue")
@ -66,6 +77,7 @@ pub fn view(_model: &Model, modal: &AddIssueModal) -> Node<Msg> {
.add_field(issue_type_field)
.add_field(crate::shared::divider())
.add_field(short_summary_field)
.add_field(description_field)
.add_field(actions)
.build()
.into_node();

View File

@ -94,7 +94,7 @@ pub struct ProjectPage {
pub dragged_issue_id: Option<IssueId>,
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Debug)]
pub struct Model {
pub access_token: Option<Uuid>,
pub user: Option<User>,

View File

@ -4,11 +4,11 @@ use jirs_data::*;
use crate::model::{Model, Page};
use crate::shared::styled_avatar::StyledAvatar;
use crate::shared::styled_button::{StyledButton, Variant as ButtonVariant};
use crate::shared::styled_button::StyledButton;
use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::styled_input::StyledInput;
use crate::shared::{drag_ev, inner_layout, ToNode};
use crate::Msg;
use crate::{FieldId, Msg};
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
match msg {
@ -172,11 +172,11 @@ fn header() -> Node<Msg> {
}
fn project_board_filters(model: &Model) -> Node<Msg> {
let search_input = StyledInput::build()
let search_input = StyledInput::build(FieldId::TextFilterBoard)
.icon(Icon::Search)
.id("searchInput")
.valid(true)
.on_change(input_ev(Ev::Change, |value| {
crate::api::ws_send(WsMsg::Ping);
Msg::ProjectTextFilterChanged(value)
}))
.build()

View File

@ -17,6 +17,7 @@ pub mod styled_icon;
pub mod styled_input;
pub mod styled_modal;
pub mod styled_select;
pub mod styled_textarea;
pub mod styled_tooltip;
pub fn find_issue(model: &Model, issue_id: IssueId) -> Option<&Issue> {

View File

@ -2,39 +2,36 @@ use seed::{prelude::*, *};
use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::ToNode;
use crate::Msg;
use crate::{FieldId, Msg};
#[derive(Debug)]
pub struct StyledInput {
id: Option<String>,
id: FieldId,
icon: Option<Icon>,
valid: bool,
on_change: Option<EventHandler<Msg>>,
}
impl StyledInput {
pub fn build() -> StyledInputBuilder {
StyledInputBuilder::default()
pub fn build(id: FieldId) -> StyledInputBuilder {
StyledInputBuilder {
id,
icon: None,
valid: None,
on_change: None,
}
}
}
#[derive(Default, Debug)]
#[derive(Debug)]
pub struct StyledInputBuilder {
id: Option<String>,
id: FieldId,
icon: Option<Icon>,
valid: Option<bool>,
on_change: Option<EventHandler<Msg>>,
}
impl StyledInputBuilder {
pub fn id<S>(mut self, id: S) -> Self
where
S: Into<String>,
{
self.id = Some(id.into());
self
}
pub fn icon(mut self, icon: Icon) -> Self {
self.icon = Some(icon);
self
@ -89,15 +86,17 @@ pub fn render(values: StyledInput) -> Node<Msg> {
_ => empty![],
};
let input_node = match on_change {
Some(on_change) => seed::input![attrs![At::Class => input_class_list.join(" ")], on_change],
_ => seed::input![attrs![At::Class => input_class_list.join(" ")]],
};
let mut handlers = vec![];
if let Some(handler) = on_change {
handlers.push(handler);
}
let input_handler = input_ev(Ev::KeyPress, move |value| Msg::InputChanged(id, value));
handlers.push(input_handler);
div![
id![id.unwrap_or_default()],
attrs!(At::Class => wrapper_class_list.join(" ")),
icon,
input_node,
seed::input![attrs![At::Class => input_class_list.join(" ")], handlers],
]
}

View File

@ -1,6 +1,5 @@
use seed::{prelude::*, *};
use crate::shared::styled_button::StyledButton;
use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::ToNode;
use crate::{FieldId, Msg};
@ -51,7 +50,6 @@ where
variant: Variant,
dropdown_width: Option<usize>,
name: Option<String>,
placeholder: Option<String>,
valid: bool,
is_multi: bool,
allow_clear: bool,
@ -80,7 +78,6 @@ where
variant: None,
dropdown_width: None,
name: None,
placeholder: None,
valid: None,
is_multi: None,
allow_clear: None,
@ -101,7 +98,6 @@ where
variant: Option<Variant>,
dropdown_width: Option<Option<usize>>,
name: Option<Option<String>>,
placeholder: Option<Option<String>>,
valid: Option<bool>,
is_multi: Option<bool>,
allow_clear: Option<bool>,
@ -121,7 +117,6 @@ where
variant: self.variant.unwrap_or_default(),
dropdown_width: self.dropdown_width.unwrap_or_default(),
name: self.name.unwrap_or_default(),
placeholder: self.placeholder.unwrap_or_default(),
valid: self.valid.unwrap_or(true),
is_multi: self.is_multi.unwrap_or_default(),
allow_clear: self.allow_clear.unwrap_or_default(),
@ -188,7 +183,6 @@ where
variant,
dropdown_width,
name,
placeholder: _,
valid,
is_multi,
allow_clear,

View File

@ -0,0 +1,116 @@
use crate::shared::ToNode;
use crate::{FieldId, Msg};
use seed::{prelude::*, *};
#[derive(Debug)]
pub struct StyledTextarea {
id: FieldId,
height: usize,
on_change: Option<EventHandler<Msg>>,
}
impl ToNode for StyledTextarea {
fn into_node(self) -> Node<Msg> {
render(self)
}
}
impl StyledTextarea {
pub fn build() -> StyledTextareaBuilder {
StyledTextareaBuilder::default()
}
}
#[derive(Debug, Default)]
pub struct StyledTextareaBuilder {
height: Option<usize>,
on_change: Option<EventHandler<Msg>>,
}
impl StyledTextareaBuilder {
pub fn height(mut self, height: usize) -> Self {
self.height = Some(height);
self
}
pub fn on_change(mut self, on_change: EventHandler<Msg>) -> Self {
self.on_change = Some(on_change);
self
}
pub fn build(self, id: FieldId) -> StyledTextarea {
StyledTextarea {
id,
height: self.height.unwrap_or(110),
on_change: self.on_change,
}
}
}
const FONT_SIZE: f64 = 15f64;
const LINE_HEIGHT: f64 = 1.4285;
const LETTER_HEIGHT: f64 = FONT_SIZE * LINE_HEIGHT;
const PADDING_TOP_BOTTOM: f64 = 17f64;
const BORDER_TOP_BOTTOM: f64 = 2f64;
const ADDITIONAL_HEIGHT: f64 = PADDING_TOP_BOTTOM + BORDER_TOP_BOTTOM;
// height = `calc( (${$0.value.split("\n").length}px * ( 15 * 1.4285 )) + 17px + 2px)`
// where:
// * 15 is font-size
// * 1.4285 is line-height
// * 17 is padding top + bottom
// * 2 is border top + bottom
pub fn render(values: StyledTextarea) -> Node<Msg> {
let StyledTextarea {
id,
height,
on_change,
} = values;
let mut style_list = vec![];
style_list.push(format!("min-height: {}px", height));
let mut handlers = vec![];
if let Some(handler) = on_change {
handlers.push(handler);
}
let resize_handler = ev(Ev::KeyPress, move |event| {
use wasm_bindgen::JsCast;
let target = match event.target() {
Some(el) => el,
_ => return Msg::NoOp,
};
let text_area = target.dyn_ref::<web_sys::HtmlTextAreaElement>().unwrap();
let value: String = text_area.value();
let len = value.lines().count() as f64;
let calc_height = (len * LETTER_HEIGHT) + ADDITIONAL_HEIGHT;
let height = if calc_height + ADDITIONAL_HEIGHT < height as f64 {
height as f64
} else {
calc_height + ADDITIONAL_HEIGHT
};
text_area
.style()
.set_css_text(format!("height: {height}px", height = height).as_str());
Msg::NoOp
});
handlers.push(resize_handler);
let text_input_handler = input_ev(Ev::KeyPress, move |value| Msg::InputChanged(id, value));
handlers.push(text_input_handler);
div![
attrs![At::Class => "styledTextArea"],
div![attrs![At::Class => "textAreaHeading"]],
textarea![
attrs![
At::Class => "textAreaInput";
At::ContentEditable => "true";
At::Style => style_list.join(";");
],
handlers,
]
]
}

View File

@ -386,3 +386,9 @@ pub struct UpdateProjectPayload {
pub description: Option<String>,
pub category: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Copy, Clone)]
pub enum WsMsg {
Ping,
Pong,
}

View File

@ -19,6 +19,8 @@ actix-web = { version = "*" }
actix-cors = { version = "*" }
actix-service = { version = "*" }
actix-rt = "1"
actix-web-actors = "*"
dotenv = { version = "*" }
byteorder = "1.0"
chrono = { version = "0.4", features = [ "serde" ] }
@ -26,6 +28,7 @@ libc = { version = "0.2.0" }
pq-sys = { version = ">=0.3.0, <0.5.0" }
quickcheck = { version = "0.4" }
serde_json = { version = ">=0.8.0, <2.0" }
bincode = "1.2.1"
time = { version = "0.1" }
url = { version = "2.1.0" }
percent-encoding = { version = "2.1.0" }

View File

@ -12,6 +12,7 @@ pub mod middleware;
pub mod models;
pub mod routes;
pub mod schema;
pub mod ws;
#[actix_rt::main]
async fn main() -> Result<(), String> {
@ -30,6 +31,7 @@ async fn main() -> Result<(), String> {
.wrap(Cors::default())
.data(db_addr.clone())
.data(crate::db::build_pool())
.service(crate::ws::index)
.service(
web::scope("/issues")
.service(crate::routes::issues::project_issues)

27
jirs-server/src/ws/mod.rs Normal file
View File

@ -0,0 +1,27 @@
use actix::{Actor, StreamHandler};
use actix_web::{get, web, Error, HttpRequest, HttpResponse};
use actix_web_actors::ws;
struct MyWs;
impl Actor for MyWs {
type Context = ws::WebsocketContext<Self>;
}
impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for MyWs {
fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
match msg {
Ok(ws::Message::Ping(msg)) => ctx.pong(&msg),
Ok(ws::Message::Text(text)) => ctx.text(text),
Ok(ws::Message::Binary(bin)) => ctx.binary(bin),
_ => (),
}
}
}
#[get("/ws/")]
pub async fn index(req: HttpRequest, stream: web::Payload) -> Result<HttpResponse, Error> {
let resp = ws::start(MyWs {}, &req, stream);
println!("{:?}", resp);
resp
}

View File

@ -4,22 +4,6 @@ import TextareaAutoSize from 'react-textarea-autosize';
import { StyledTextarea } from './Styles';
const propTypes = {
className: PropTypes.string,
invalid: PropTypes.bool,
minRows: PropTypes.number,
value: PropTypes.string,
onChange: PropTypes.func,
};
const defaultProps = {
className: undefined,
invalid: false,
minRows: 2,
value: undefined,
onChange: () => {},
};
const Textarea = forwardRef(({ className, invalid, onChange, ...textareaProps }, ref) => (
<StyledTextarea className={className} invalid={invalid}>
<TextareaAutoSize
@ -30,7 +14,20 @@ const Textarea = forwardRef(({ className, invalid, onChange, ...textareaProps },
</StyledTextarea>
));
Textarea.propTypes = propTypes;
Textarea.defaultProps = defaultProps;
Textarea.propTypes = {
className: PropTypes.string,
invalid: PropTypes.bool,
minRows: PropTypes.number,
value: PropTypes.string,
onChange: PropTypes.func,
};
Textarea.defaultProps = {
className: undefined,
invalid: false,
minRows: 2,
value: undefined,
onChange: () => {},
};
export default Textarea;