Full basic support

This commit is contained in:
Adrian Wozniak 2020-03-29 19:56:55 +02:00
parent e388d89494
commit 488425ce75
51 changed files with 5980 additions and 478 deletions

2
.env
View File

@ -1,2 +1,2 @@
DATABASE_URL=postgres://postgres@localhost:5432/jirs
RUST_LOG=actix_web=info,diesel=info

244
Cargo.lock generated
View File

@ -329,6 +329,18 @@ dependencies = [
"memchr 2.3.3",
]
[[package]]
name = "anyhow"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "013a6e0a2cbe3d20f9c60b65458f7a7f7a5e636c5d0f45a5a6aee5d4b1f01785"
[[package]]
name = "anymap"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33954243bd79057c2de7338850b85983a44588021f8a5fee574a8888c6de4344"
[[package]]
name = "arc-swap"
version = "0.4.5"
@ -408,6 +420,12 @@ dependencies = [
"libc",
]
[[package]]
name = "base-x"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b20b618342cf9891c292c4f5ac2cde7287cc5c87e87e9c769d617793607dec1"
[[package]]
name = "base64"
version = "0.11.0"
@ -425,12 +443,28 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
[[package]]
name = "boolinator"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9"
[[package]]
name = "brotli-sys"
version = "0.3.2"
@ -451,6 +485,12 @@ dependencies = [
"libc",
]
[[package]]
name = "bumpalo"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12ae9db68ad7fac5fe51304d20f016c911539251075a214f8e663babefa35187"
[[package]]
name = "byteorder"
version = "1.3.4"
@ -484,6 +524,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
[[package]]
name = "cfg-match"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8100e46ff92eb85bf6dc2930c73f2a4f7176393c84a9446b3d501e1b354e7b34"
[[package]]
name = "chrono"
version = "0.4.11"
@ -587,6 +633,12 @@ dependencies = [
"syn",
]
[[package]]
name = "discard"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0"
[[package]]
name = "dotenv"
version = "0.15.0"
@ -959,6 +1011,9 @@ version = "0.1.0"
[[package]]
name = "jirs-client"
version = "0.1.0"
dependencies = [
"yew",
]
[[package]]
name = "jirs-data"
@ -1497,6 +1552,15 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c691c0e608126e00913e33f0ccf3727d5fc84573623b8d65b2df340b5201783"
[[package]]
name = "rustc_version"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a"
dependencies = [
"semver",
]
[[package]]
name = "ryu"
version = "1.0.3"
@ -1518,6 +1582,21 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "semver"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403"
dependencies = [
"semver-parser",
]
[[package]]
name = "semver-parser"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3"
[[package]]
name = "serde"
version = "1.0.105"
@ -1601,6 +1680,57 @@ dependencies = [
"winapi 0.3.8",
]
[[package]]
name = "stdweb"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5"
dependencies = [
"discard",
"rustc_version",
"serde",
"serde_json",
"stdweb-derive",
"stdweb-internal-macros",
"stdweb-internal-runtime",
"wasm-bindgen",
]
[[package]]
name = "stdweb-derive"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c87a60a40fccc84bef0652345bbbbbe20a605bf5d0ce81719fc476f5c03b50ef"
dependencies = [
"proc-macro2",
"quote",
"serde",
"serde_derive",
"syn",
]
[[package]]
name = "stdweb-internal-macros"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58fa5ff6ad0d98d1ffa8cb115892b6e69d67799f6763e162a1c9db421dc22e11"
dependencies = [
"base-x",
"proc-macro2",
"quote",
"serde",
"serde_derive",
"serde_json",
"sha1",
"syn",
]
[[package]]
name = "stdweb-internal-runtime"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0"
[[package]]
name = "syn"
version = "1.0.17"
@ -1633,6 +1763,26 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "thiserror"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3711fd1c4e75b3eff12ba5c40dba762b6b65c5476e8174c1a664772060c49bf"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae2b85ba4c9aa32dd3343bd80eb8d22e9b54b7688c17ea3907f236885353b233"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thread-id"
version = "2.0.0"
@ -1832,6 +1982,60 @@ version = "0.9.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
[[package]]
name = "wasm-bindgen"
version = "0.2.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cc57ce05287f8376e998cbddfb4c8cb43b84a7ec55cf4551d7c00eef317a47f"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d967d37bf6c16cca2973ca3af071d0a2523392e4a594548155d89a678f4237cd"
dependencies = [
"bumpalo",
"lazy_static",
"log 0.4.8",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bd151b63e1ea881bb742cd20e1d6127cef28399558f3b5d415289bc41eee3a4"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68a5b36eef1be7868f668632863292e37739656a80fc4b9acec7b0bd35a4931"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf76fe7d25ac79748a37538b7daeed1c7a6867c92d3245c12c6222e4a20d639"
[[package]]
name = "widestring"
version = "0.4.0"
@ -1899,3 +2103,43 @@ dependencies = [
"winapi 0.2.8",
"winapi-build",
]
[[package]]
name = "yew"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20c1d343cdf3d625752fd4d9fef73e1b6b2b8a08b00c832b0fc4a47f3b10fbef"
dependencies = [
"anyhow",
"anymap",
"bincode",
"cfg-if",
"cfg-match",
"http",
"indexmap",
"log 0.4.8",
"proc-macro-hack",
"proc-macro-nested",
"ryu",
"serde",
"serde_json",
"slab",
"stdweb",
"thiserror",
"wasm-bindgen",
"yew-macro",
]
[[package]]
name = "yew-macro"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb25d91f16265dd1643497ac3252be6c21c169d9cf3babcae32325c74200499f"
dependencies = [
"boolinator",
"lazy_static",
"proc-macro-hack",
"proc-macro2",
"quote",
"syn",
]

3
jirs-client/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
pkg
node_modules

View File

@ -5,7 +5,9 @@ authors = ["Adrian Wozniak <adrian.wozniak@ita-prog.pl>"]
edition = "2018"
[lib]
crate-type = ["cdylib", "rlib"]
name = "jirs_client"
path = "./src/lib.rs"
[dependencies]
yew = { version = "*", features = ["std_web"] }

1
jirs-client/js/index.js Normal file
View File

@ -0,0 +1 @@
import("../pkg/index");

11
jirs-client/package.json Normal file
View File

@ -0,0 +1,11 @@
{
"devDependencies": {
"@swc/core": "^1.1.37",
"@wasm-tool/wasm-pack-plugin": "^1.2.0",
"html-webpack-plugin": "^4.0.3",
"swc-loader": "^0.1.8",
"webpack": "^4.42.1",
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.10.3"
}
}

View File

@ -1 +1,47 @@
use yew::{html, Callback, ClickEvent, Component, ComponentLink, Html, ShouldRender};
struct App {
clicked: bool,
onclick: Callback<ClickEvent>,
}
enum Msg {
Click,
}
impl Component for App {
type Message = Msg;
type Properties = ();
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
App {
clicked: false,
onclick: link.callback(|_| Msg::Click),
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
match msg {
Msg::Click => {
self.clicked = true;
true // Indicate that the Component should re-render
}
}
}
fn view(&self) -> Html {
let button_text = if self.clicked {
"Clicked!"
} else {
"Click me!"
};
html! {
<button onclick=&self.onclick>{ button_text }</button>
}
}
}
fn main() {
yew::start_app::<App>();
}

View File

@ -0,0 +1,35 @@
const path = require("path");
const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin");
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: path.resolve(__dirname, 'js', 'index.js'),
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dev'),
publicPath: '/',
},
devtool: 'source-map',
devServer: {
contentBase: path.join(__dirname, 'dev'),
historyApiFallback: true,
hot: true,
port: 4000,
host: '0.0.0.0',
allowedHosts: [
'localhost:4000',
'localhost:8000',
],
headers: {
'Access-Control-Allow-Origin': '*',
}
},
plugins: [
new WasmPackPlugin({
crateDirectory: path.resolve(__dirname),
args: "--log-level warn",
extraArgs: "--no-typescript",
}),
new HtmlWebpackPlugin(),
]
};

4215
jirs-client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -171,3 +171,43 @@ pub struct UpdateIssuePayload {
pub users: Option<Vec<User>>,
pub user_ids: Option<Vec<i32>>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateCommentPayload {
pub user_id: Option<i32>,
pub issue_id: i32,
pub body: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCommentPayload {
pub body: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateIssuePayload {
pub title: String,
#[serde(rename = "type")]
pub issue_type: String,
pub status: String,
pub priority: String,
pub description: Option<String>,
pub description_text: Option<String>,
pub estimate: Option<i32>,
pub time_spent: Option<i32>,
pub time_remaining: Option<i32>,
pub project_id: i32,
pub user_ids: Vec<i32>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProjectPayload {
pub name: Option<String>,
pub url: Option<String>,
pub description: Option<String>,
pub category: Option<String>,
}

View File

@ -1,10 +1,11 @@
use crate::db::DbExecutor;
use crate::errors::ServiceErrors;
use crate::models::Comment;
use actix::{Handler, Message};
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use crate::db::DbExecutor;
use crate::errors::ServiceErrors;
use crate::models::Comment;
#[derive(Serialize, Deserialize)]
pub struct LoadIssueComments {
pub issue_id: i32,
@ -32,3 +33,103 @@ impl Handler<LoadIssueComments> for DbExecutor {
Ok(rows)
}
}
#[derive(Serialize, Deserialize)]
pub struct CreateComment {
pub user_id: i32,
pub issue_id: i32,
pub body: String,
}
impl Message for CreateComment {
type Result = Result<Comment, ServiceErrors>;
}
impl Handler<CreateComment> for DbExecutor {
type Result = Result<Comment, ServiceErrors>;
fn handle(&mut self, msg: CreateComment, _ctx: &mut Self::Context) -> Self::Result {
use crate::models::CommentForm;
use crate::schema::comments::dsl::*;
let form = CommentForm {
body: msg.body,
user_id: msg.user_id,
issue_id: msg.issue_id,
};
let conn = &self
.0
.get()
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
let row: Comment = diesel::insert_into(comments)
.values(form)
.get_result::<Comment>(conn)
.map_err(|_| ServiceErrors::RecordNotFound("issue comments".to_string()))?;
Ok(row)
}
}
#[derive(Serialize, Deserialize)]
pub struct UpdateComment {
pub comment_id: i32,
pub user_id: i32,
pub body: String,
}
impl Message for UpdateComment {
type Result = Result<Comment, ServiceErrors>;
}
impl Handler<UpdateComment> for DbExecutor {
type Result = Result<Comment, ServiceErrors>;
fn handle(&mut self, msg: UpdateComment, _ctx: &mut Self::Context) -> Self::Result {
use crate::schema::comments::dsl::*;
let conn = &self
.0
.get()
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
let row: Comment = diesel::update(
comments
.filter(user_id.eq(msg.user_id))
.find(msg.comment_id),
)
.set(body.eq(msg.body))
.get_result::<Comment>(conn)
.map_err(|_| ServiceErrors::RecordNotFound("issue comments".to_string()))?;
Ok(row)
}
}
#[derive(Serialize, Deserialize)]
pub struct DeleteComment {
pub comment_id: i32,
pub user_id: i32,
}
impl Message for DeleteComment {
type Result = Result<(), ServiceErrors>;
}
impl Handler<DeleteComment> for DbExecutor {
type Result = Result<(), ServiceErrors>;
fn handle(&mut self, msg: DeleteComment, _ctx: &mut Self::Context) -> Self::Result {
use crate::schema::comments::dsl::*;
let conn = &self
.0
.get()
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
diesel::delete(
comments
.filter(user_id.eq(msg.user_id))
.find(msg.comment_id),
)
.execute(conn)
.map_err(|_| ServiceErrors::RecordNotFound("issue comments".to_string()))?;
Ok(())
}
}

View File

@ -1,10 +1,12 @@
use actix::{Handler, Message};
use diesel::expression::dsl::not;
use diesel::expression::sql_literal::sql;
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use crate::db::DbExecutor;
use crate::errors::ServiceErrors;
use crate::models::Issue;
use actix::{Handler, Message};
use diesel::expression::dsl::not;
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct LoadIssue {
@ -61,7 +63,6 @@ impl Handler<LoadProjectIssues> for DbExecutor {
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateIssue {
pub issue_id: i32,
pub title: Option<String>,
@ -155,3 +156,116 @@ impl Handler<UpdateIssue> for DbExecutor {
Ok(row)
}
}
#[derive(Serialize, Deserialize)]
pub struct DeleteIssue {
pub issue_id: i32,
}
impl Message for DeleteIssue {
type Result = Result<(), ServiceErrors>;
}
impl Handler<DeleteIssue> for DbExecutor {
type Result = Result<(), ServiceErrors>;
fn handle(&mut self, msg: DeleteIssue, _ctx: &mut Self::Context) -> Self::Result {
use crate::schema::issue_assignees::dsl::{issue_assignees, issue_id};
use crate::schema::issues::dsl::issues;
let conn = &self
.0
.get()
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
diesel::delete(issue_assignees.filter(issue_id.eq(msg.issue_id)))
.execute(conn)
.map_err(|e| ServiceErrors::RecordNotFound(format!("issue {}. {}", msg.issue_id, e)))?;
diesel::delete(issues.find(msg.issue_id))
.execute(conn)
.map_err(|e| ServiceErrors::RecordNotFound(format!("issue {}. {}", msg.issue_id, e)))?;
Ok(())
}
}
#[derive(Serialize, Deserialize)]
pub struct CreateIssue {
pub title: String,
pub issue_type: String,
pub status: String,
pub priority: String,
pub description: Option<String>,
pub description_text: Option<String>,
pub estimate: Option<i32>,
pub time_spent: Option<i32>,
pub time_remaining: Option<i32>,
pub project_id: i32,
pub reporter_id: i32,
pub user_ids: Vec<i32>,
}
impl Message for CreateIssue {
type Result = Result<Issue, ServiceErrors>;
}
impl Handler<CreateIssue> for DbExecutor {
type Result = Result<Issue, ServiceErrors>;
fn handle(&mut self, msg: CreateIssue, _ctx: &mut Self::Context) -> Self::Result {
use crate::schema::issue_assignees::dsl;
use crate::schema::issues::dsl::{issues, status};
let conn = &self
.0
.get()
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
let list_position = issues
.filter(status.eq("backlog"))
.select(sql("max(list_position) + 1.0"))
.get_result::<f64>(conn)
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
let form = crate::models::CreateIssueForm {
title: msg.title,
issue_type: msg.issue_type,
status: msg.status,
priority: msg.priority,
list_position,
description: msg.description,
description_text: msg.description_text,
estimate: msg.estimate,
time_spent: msg.time_spent,
time_remaining: msg.time_remaining,
reporter_id: msg.reporter_id,
project_id: msg.project_id,
};
let issue = diesel::insert_into(issues)
.values(form)
.on_conflict_do_nothing()
.get_result::<Issue>(conn)
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
let mut values = vec![];
for user_id in msg.user_ids.iter() {
values.push(crate::models::CreateIssueAssigneeForm {
issue_id: issue.id,
user_id: *user_id,
});
}
if !msg.user_ids.contains(&msg.reporter_id) {
values.push(crate::models::CreateIssueAssigneeForm {
issue_id: issue.id,
user_id: msg.reporter_id,
});
}
diesel::insert_into(dsl::issue_assignees)
.values(values)
.execute(conn)
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
Ok(issue)
}
}

View File

@ -3,6 +3,8 @@ use actix::{Actor, SyncContext};
use diesel::pg::PgConnection;
use diesel::r2d2::{self, ConnectionManager};
use crate::db::dev::VerboseConnection;
pub mod authorize_user;
pub mod comments;
pub mod issues;
@ -27,14 +29,15 @@ impl DbExecutor {
}
pub fn build_pool() -> DbPool {
std::env::set_var("RUST_LOG", "actix_web=info,diesel=debug");
std::env::set_var("RUST_LOG", "actix_web=debug,diesel=debug");
dotenv::dotenv().ok();
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
#[cfg(debug_assertions)]
let manager = ConnectionManager::<dev::VerboseConnection>::new(database_url);
#[cfg(not(debug_assertions))]
let manager = ConnectionManager::<PgConnection>::new(database_url);
let manager = ConnectionManager::<PgConnection>::new(database_url.clone());
#[cfg(debug_assertions)]
let manager: ConnectionManager<VerboseConnection> =
ConnectionManager::<dev::VerboseConnection>::new(database_url.clone());
r2d2::Pool::builder()
.build(manager)
.unwrap_or_else(|e| panic!("Failed to create pool. {}", e))
@ -48,12 +51,13 @@ pub trait SyncQuery {
#[cfg(debug_assertions)]
pub mod dev {
use std::ops::Deref;
use diesel::connection::{AnsiTransactionManager, SimpleConnection};
use diesel::deserialize::QueryableByName;
use diesel::query_builder::{AsQuery, QueryFragment, QueryId};
use diesel::sql_types::HasSqlType;
use diesel::{Connection, ConnectionResult, PgConnection, QueryResult, Queryable};
use std::ops::Deref;
pub struct VerboseConnection {
inner: PgConnection,

View File

@ -1,10 +1,11 @@
use crate::db::DbExecutor;
use crate::errors::ServiceErrors;
use crate::models::Project;
use actix::{Handler, Message};
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use crate::db::DbExecutor;
use crate::errors::ServiceErrors;
use crate::models::Project;
#[derive(Serialize, Deserialize)]
pub struct LoadCurrentProject {
pub project_id: i32,
@ -23,10 +24,50 @@ impl Handler<LoadCurrentProject> for DbExecutor {
.0
.get()
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
let project = projects
projects
.filter(id.eq(msg.project_id))
.first::<Project>(conn)
.map_err(|_| ServiceErrors::RecordNotFound("Project".to_string()))?;
Ok(project)
.map_err(|_| ServiceErrors::RecordNotFound("Project".to_string()))
}
}
#[derive(Serialize, Deserialize)]
pub struct UpdateProject {
pub project_id: i32,
pub name: Option<String>,
pub url: Option<String>,
pub description: Option<String>,
pub category: Option<String>,
}
impl Message for UpdateProject {
type Result = Result<Project, ServiceErrors>;
}
impl Handler<UpdateProject> for DbExecutor {
type Result = Result<Project, ServiceErrors>;
fn handle(&mut self, msg: UpdateProject, _ctx: &mut Self::Context) -> Self::Result {
use crate::schema::projects::dsl::*;
let conn = &self
.0
.get()
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
diesel::update(projects.find(msg.project_id))
.set((
msg.name.map(|v| name.eq(v)),
msg.url.map(|v| url.eq(v)),
msg.description.map(|v| description.eq(v)),
msg.category.map(|v| category.eq(v)),
))
.execute(conn)
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
projects
.filter(id.eq(msg.project_id))
.first::<Project>(conn)
.map_err(|_| ServiceErrors::RecordNotFound("Project".to_string()))
}
}

View File

@ -13,7 +13,7 @@ pub mod schema;
#[actix_rt::main]
async fn main() -> Result<(), String> {
std::env::set_var("RUST_LOG", "actix_web=info,diesel=debug");
std::env::set_var("RUST_LOG", "actix_web=debug,diesel=debug");
env_logger::init();
dotenv::dotenv().ok();
@ -27,7 +27,6 @@ async fn main() -> Result<(), String> {
.data(crate::db::build_pool())
.service(
web::scope("/issues")
.wrap(crate::middleware::authorize::Authorize::default())
.service(crate::routes::issues::project_issues)
.service(crate::routes::issues::issue_with_users_and_comments)
.service(crate::routes::issues::create)
@ -36,19 +35,13 @@ async fn main() -> Result<(), String> {
)
.service(
web::scope("/comments")
.wrap(crate::middleware::authorize::Authorize::default())
.service(crate::routes::comments::create)
.service(crate::routes::comments::update)
.service(crate::routes::comments::delete),
)
.service(
web::scope("/currentUser")
.wrap(crate::middleware::authorize::Authorize::default())
.service(crate::routes::users::current_user),
)
.service(web::scope("/currentUser").service(crate::routes::users::current_user))
.service(
web::scope("/project")
.wrap(crate::middleware::authorize::Authorize::default())
.service(crate::routes::projects::project_with_users_and_issues)
.service(crate::routes::projects::update),
)

View File

@ -1,8 +1,9 @@
use crate::schema::*;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::schema::*;
#[derive(Debug, Serialize, Deserialize, Queryable)]
#[serde(rename_all = "camelCase")]
pub struct Comment {
@ -34,8 +35,8 @@ impl Into<jirs_data::Comment> for Comment {
#[table_name = "comments"]
pub struct CommentForm {
pub body: String,
pub user_id: Option<i32>,
pub issue_id: Option<i32>,
pub user_id: i32,
pub issue_id: i32,
}
#[derive(Debug, Serialize, Deserialize, Queryable)]
@ -173,11 +174,11 @@ impl Into<jirs_data::Project> for Project {
#[derive(Debug, Serialize, Deserialize, Insertable)]
#[serde(rename_all = "camelCase")]
#[table_name = "projects"]
pub struct ProjectForm {
pub name: String,
pub url: String,
pub description: String,
pub category: String,
pub struct UpdateProjectForm {
pub name: Option<String>,
pub url: Option<String>,
pub description: Option<String>,
pub category: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Queryable)]

View File

@ -1,18 +1,77 @@
use actix_web::{delete, post, put, HttpResponse};
use actix::Addr;
use actix_web::web::{Data, Json, Path};
use actix_web::{delete, post, put, HttpRequest, HttpResponse};
#[post("/")]
pub async fn create() -> HttpResponse {
HttpResponse::Ok()
.content_type("text/html")
.body("<!DOCTYPE html><html><head><title>Issues</title></head><body>Foo</body></html>")
use crate::db::comments::{CreateComment, DeleteComment, UpdateComment};
use crate::db::DbExecutor;
use crate::routes::user_from_request;
#[post("")]
pub async fn create(
req: HttpRequest,
payload: Json<jirs_data::CreateCommentPayload>,
db: Data<Addr<DbExecutor>>,
) -> HttpResponse {
let user = match user_from_request(req, &db).await {
Ok(user) => user,
Err(response) => return response,
};
let msg = CreateComment {
body: payload.body.clone(),
issue_id: payload.issue_id,
user_id: user.id,
};
let comment = match db.send(msg).await {
Ok(Ok(comment)) => comment,
_ => return crate::errors::ServiceErrors::Unauthorized.into_http_response(),
};
HttpResponse::Ok().json(comment)
}
#[put("/<id>")]
pub async fn update() -> HttpResponse {
HttpResponse::Ok().content_type("text/html").body("")
#[put("/{id}")]
pub async fn update(
req: HttpRequest,
path: Path<i32>,
db: Data<Addr<DbExecutor>>,
payload: Json<jirs_data::UpdateCommentPayload>,
) -> HttpResponse {
let user = match user_from_request(req, &db).await {
Ok(user) => user,
Err(response) => return response,
};
let comment_id = path.into_inner();
let body = payload.body.clone();
let msg = UpdateComment {
comment_id,
body,
user_id: user.id,
};
let comment = match db.send(msg).await {
Ok(Ok(comment)) => comment,
Ok(Err(e)) => return e.into_http_response(),
_ => return crate::errors::ServiceErrors::Unauthorized.into_http_response(),
};
HttpResponse::Ok().json(comment)
}
#[delete("/<id>")]
pub async fn delete() -> HttpResponse {
HttpResponse::Ok().content_type("text/html").body("")
#[delete("/{id}")]
pub async fn delete(req: HttpRequest, path: Path<i32>, db: Data<Addr<DbExecutor>>) -> HttpResponse {
let user = match user_from_request(req, &db).await {
Ok(user) => user,
Err(response) => return response,
};
let comment_id = path.into_inner();
let msg = DeleteComment {
user_id: user.id,
comment_id,
};
match db.send(msg).await {
Ok(Ok(_)) => (),
Ok(Err(_)) => (),
_ => return crate::errors::ServiceErrors::Unauthorized.into_http_response(),
};
HttpResponse::NoContent().body("")
}

View File

@ -1,15 +1,19 @@
use std::collections::HashMap;
use actix::Addr;
use actix_web::web::{Data, Json, Path};
use actix_web::{delete, get, post, put, HttpRequest, HttpResponse};
use jirs_data::ResponseData;
use crate::db::authorize_user::AuthorizeUser;
use crate::db::comments::LoadIssueComments;
use crate::db::issues::{LoadIssue, UpdateIssue};
use crate::db::issues::{CreateIssue, DeleteIssue, LoadIssue, UpdateIssue};
use crate::db::users::{LoadIssueAssignees, LoadProjectUsers};
use crate::db::DbExecutor;
use crate::errors::ServiceErrors;
use crate::middleware::authorize::token_from_headers;
use actix::Addr;
use actix_web::web::{Data, Json, Path};
use actix_web::{delete, get, post, put, HttpRequest, HttpResponse};
use jirs_data::ResponseData;
use std::collections::HashMap;
use crate::routes::user_from_request;
#[get("")]
pub async fn project_issues() -> HttpResponse {
@ -45,22 +49,35 @@ pub async fn issue_with_users_and_comments(
}
}
#[post("/")]
pub async fn create(req: HttpRequest, db: Data<Addr<DbExecutor>>) -> HttpResponse {
let token = match token_from_headers(req.headers()) {
Ok(uuid) => uuid,
_ => return crate::errors::ServiceErrors::Unauthorized.into_http_response(),
#[post("")]
pub async fn create(
req: HttpRequest,
payload: Json<jirs_data::CreateIssuePayload>,
db: Data<Addr<DbExecutor>>,
) -> HttpResponse {
let user = match user_from_request(req, &db).await {
Ok(user) => user,
Err(response) => return response,
};
let _user = match db
.send(AuthorizeUser {
access_token: token,
})
.await
{
Ok(Ok(user)) => user,
_ => return crate::errors::ServiceErrors::Unauthorized.into_http_response(),
let msg = CreateIssue {
title: payload.title.clone(),
issue_type: payload.issue_type.clone(),
status: payload.status.clone(),
priority: payload.priority.clone(),
description: payload.description.clone(),
description_text: payload.description_text.clone(),
estimate: payload.estimate.clone(),
time_spent: payload.time_spent.clone(),
time_remaining: payload.time_remaining.clone(),
project_id: payload.project_id,
reporter_id: user.id,
user_ids: payload.user_ids.clone(),
};
HttpResponse::Ok().content_type("text/html").body("")
match db.send(msg).await {
Ok(Ok(issue)) => HttpResponse::Ok().json(issue),
Ok(Err(e)) => e.into_http_response(),
_ => ServiceErrors::DatabaseConnectionLost.into_http_response(),
}
}
#[put("/{id}")]
@ -112,8 +129,18 @@ pub async fn update(
}
#[delete("/{id}")]
pub async fn delete() -> HttpResponse {
HttpResponse::Ok().content_type("text/html").body("")
pub async fn delete(req: HttpRequest, path: Path<i32>, db: Data<Addr<DbExecutor>>) -> HttpResponse {
let _user = match user_from_request(req, &db).await {
Ok(user) => user,
Err(response) => return response,
};
let issue_id = path.into_inner();
let msg = DeleteIssue { issue_id };
match db.send(msg).await {
Ok(Ok(_)) => HttpResponse::NoContent().body(""),
Ok(Err(e)) => e.into_http_response(),
_ => ServiceErrors::DatabaseConnectionLost.into_http_response(),
}
}
async fn load_issue(

View File

@ -1,4 +1,34 @@
use actix::Addr;
use actix_web::web::Data;
use actix_web::{HttpRequest, HttpResponse};
use crate::db::authorize_user::AuthorizeUser;
use crate::db::DbExecutor;
use crate::errors::ServiceErrors;
use crate::middleware::authorize::token_from_headers;
use crate::models::User;
pub mod comments;
pub mod issues;
pub mod projects;
pub mod users;
pub async fn user_from_request(
req: HttpRequest,
db: &Data<Addr<DbExecutor>>,
) -> Result<User, HttpResponse> {
let token = match token_from_headers(req.headers()) {
Ok(uuid) => uuid,
_ => return Err(ServiceErrors::Unauthorized.into_http_response()),
};
match db
.send(AuthorizeUser {
access_token: token,
})
.await
{
Ok(Ok(user)) => Ok(user),
Ok(Err(e)) => Err(e.into_http_response()),
_ => Err(ServiceErrors::Unauthorized.into_http_response()),
}
}

View File

@ -1,14 +1,17 @@
use actix::Addr;
use actix_web::web::{Data, Json, Path};
use actix_web::{get, put, HttpRequest, HttpResponse};
use jirs_data::ResponseData;
use crate::db::authorize_user::AuthorizeUser;
use crate::db::issues::LoadProjectIssues;
use crate::db::projects::LoadCurrentProject;
use crate::db::projects::{LoadCurrentProject, UpdateProject};
use crate::db::users::LoadProjectUsers;
use crate::db::DbExecutor;
use crate::errors::ServiceErrors;
use crate::middleware::authorize::token_from_headers;
use actix::Addr;
use actix_web::web::Data;
use actix_web::{get, put, HttpRequest, HttpResponse};
use jirs_data::ResponseData;
use crate::routes::user_from_request;
#[get("")]
pub async fn project_with_users_and_issues(
@ -73,7 +76,27 @@ pub async fn project_with_users_and_issues(
HttpResponse::Ok().json(res.into_response())
}
#[put("")]
pub async fn update(_req: HttpRequest, _db: Data<Addr<DbExecutor>>) -> HttpResponse {
HttpResponse::NotImplemented().body("")
#[put("/{id}")]
pub async fn update(
req: HttpRequest,
payload: Json<jirs_data::UpdateProjectPayload>,
path: Path<i32>,
db: Data<Addr<DbExecutor>>,
) -> HttpResponse {
let _user = match user_from_request(req, &db).await {
Ok(user) => user,
Err(response) => return response,
};
let msg = UpdateProject {
project_id: path.into_inner(),
name: payload.name.clone(),
url: payload.url.clone(),
description: payload.description.clone(),
category: payload.category.clone(),
};
match db.send(msg).await {
Ok(Ok(project)) => HttpResponse::Ok().json(project),
Ok(Err(e)) => e.into_http_response(),
_ => ServiceErrors::DatabaseConnectionLost.into_http_response(),
}
}

View File

@ -1,11 +1,37 @@
{
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": true,
"decorators": true,
"dynamicImport": true
[
{
"test": ".*.tsx?$",
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": true,
"decorators": true,
"dynamicImport": true
}
}
},
{
"test": ".jsx?$",
"jsc": {
"target": "es2018",
"parser": {
"syntax": "ecmascript",
"jsx": true,
"dynamicImport": true,
"numericSeparator": true,
"classPrivateProperty": true,
"privateMethod": true,
"classProperty": true,
"functionBind": true,
"exportDefaultFrom": true,
"exportNamespaceFrom": true,
"decorators": true,
"decoratorsBeforeExport": true,
"nullishCoalescing": true,
"topLevelAwait": true,
"importMeta": true,
"optionalChaining": true
}
}
}
}
]

View File

@ -47,7 +47,12 @@
},
"dependencies": {
"@4tw/cypress-drag-drop": "^1.3.0",
"axios": "^0.19.0",
"@types/axios": "^0.14.0",
"@types/react-redux": "^7.1.7",
"@types/redux": "^3.6.0",
"@types/redux-actions": "^2.6.1",
"@types/redux-saga": "^0.10.5",
"axios": "^0.19.2",
"color": "^3.1.2",
"compression": "^1.7.4",
"core-js": "^3.4.7",
@ -65,9 +70,13 @@
"react-beautiful-dnd": "^12.2.0",
"react-content-loader": "^4.3.3",
"react-dom": "^16.12.0",
"react-redux": "^7.2.0",
"react-router-dom": "^5.1.2",
"react-textarea-autosize": "^7.1.2",
"react-transition-group": "^4.3.0",
"redux": "^4.0.5",
"redux-actions": "^2.6.5",
"redux-saga": "^1.1.3",
"regenerator-runtime": "^0.13.3",
"styled-components": "^4.4.1",
"sweet-pubsub": "^1.1.2"

View File

@ -1,22 +1,22 @@
import React, { Fragment } from 'react';
import React from 'react';
import { Provider } from 'react-redux';
import store from '../store';
import NormalizeStyles from './NormalizeStyles';
import BaseStyles from './BaseStyles';
import Toast from './Toast';
import Routes from './Routes';
// We're importing .css because @font-face in styled-components causes font files
// to be constantly re-requested from the server (which causes screen flicker)
// https://github.com/styled-components/styled-components/issues/1593
import './fontStyles.css';
const App = () => (
<Fragment>
<NormalizeStyles />
<BaseStyles />
<Toast />
<Routes />
</Fragment>
<Provider store={ store }>
<NormalizeStyles/>
<BaseStyles/>
<Toast/>
<Routes/>
</Provider>
);
export default App;

View File

@ -1,31 +1,56 @@
import React, { useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import React from 'react';
import { connect } from "react-redux";
import { Redirect } from 'react-router-dom';
import api from 'shared/utils/api';
import toast from 'shared/utils/toast';
import { getStoredAuthToken, storeAuthToken } from 'shared/utils/authToken';
import { PageLoader } from 'shared/components';
import * as formActions from 'actions/forms';
import { getStoredAuthToken } from 'shared/utils/authToken';
const Authenticate = () => {
const history = useHistory();
import {
ActionButton,
Actions,
Divider,
FormElement,
Header,
SignIn,
SignInSection,
} from 'Project/IssueCreate/Styles';
useEffect(() => {
const createGuestAccount = async () => {
try {
const { authToken } = await api.post('/authentication/guest');
storeAuthToken(authToken);
history.push('/');
} catch (error) {
toast.error(error);
}
};
const Authenticate = ({
onEmailChanged,
onPasswordChanged,
onSubmit,
}) => {
if (getStoredAuthToken()) return <Redirect to='/project'/>;
if (!getStoredAuthToken()) {
createGuestAccount();
}
}, [history]);
return <PageLoader />;
return (
<SignIn>
<SignInSection>
<form onSubmit={ onSubmit }>
<FormElement>
<Header>Zaloguj się na swoje konto</Header>
<input
name='email'
onChange={ onEmailChanged }
/>
<input
name='password'
onChange={ onPasswordChanged }
/>
<Divider/>
<Actions>
<ActionButton type="submit" variant="primary" isWorking={ false }>
Zaloguj
</ActionButton>
</Actions>
</FormElement>
</form>
</SignInSection>
</SignIn>
);
};
export default Authenticate;
export default connect(null, {
onEmailChanged: formActions.emailChanged,
onPasswordChanged: formActions.passwordChanged,
onSubmit: formActions.signInSubmit,
})(Authenticate);

39
react-client/src/Auth/Styles.js vendored Normal file
View File

@ -0,0 +1,39 @@
import styled from 'styled-components/dist/styled-components.esm';
import { color, font } from 'shared/utils/styles';
import { Button, Form } from 'shared/components';
export const FormElement = styled(Form.Element)`;
padding: 25px 40px 35px;
`;
export const FormHeading = styled.div`
padding-bottom: 15px;
${ font.size(21) }
`;
export const SelectItem = styled.div`
display: flex;
align-items: center;
margin-right: 15px;
${ props => props.withBottomMargin && `margin-bottom: 5px;` }
`;
export const SelectItemLabel = styled.div`
padding: 0 3px 0 6px;
`;
export const Divider = styled.div`
margin-top: 22px;
border-top: 1px solid ${ color.borderLightest };
`;
export const Actions = styled.div`
display: flex;
justify-content: flex-end;
padding-top: 30px;
`;
export const ActionButton = styled(Button)`
margin-left: 10px;
`;

View File

@ -1,4 +1,4 @@
import React, { Fragment, useRef } from 'react';
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { Textarea } from 'shared/components';
@ -13,41 +13,45 @@ const propTypes = {
onCancel: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetailsCommentsBodyForm = ({
value,
onChange,
isWorking,
onSubmit,
onCancel,
}) => {
const $textareaRef = useRef();
class ProjectBoardIssueDetailsCommentsBodyForm extends React.Component {
state = { textArea: React.createRef() };
const handleSubmit = () => {
if ($textareaRef.current.value.trim()) {
onSubmit();
handleSubmit = () => {
if (this.state.textArea.current.value.trim()) {
this.props.onSubmit();
}
};
return (
<Fragment>
<Textarea
autoFocus
placeholder="Add a comment..."
value={value}
onChange={onChange}
ref={$textareaRef}
/>
<Actions>
<FormButton variant="primary" isWorking={isWorking} onClick={handleSubmit}>
Save
</FormButton>
<FormButton variant="empty" onClick={onCancel}>
Cancel
</FormButton>
</Actions>
</Fragment>
);
};
render() {
let {
value,
onChange,
isWorking,
onCancel,
} = this.props;
return (
<Fragment>
<Textarea
autoFocus
placeholder="Add a comment..."
value={ value }
onChange={ onChange }
ref={ this.state.textArea }
/>
<Actions>
<FormButton variant="primary" isWorking={ isWorking } onClick={ this.handleSubmit }>
Save
</FormButton>
<FormButton variant="empty" onClick={ onCancel }>
Cancel
</FormButton>
</Actions>
</Fragment>
);
}
}
ProjectBoardIssueDetailsCommentsBodyForm.propTypes = propTypes;

View File

@ -1,62 +1,74 @@
import React, { Fragment, useState } from 'react';
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import api from 'shared/utils/api';
import useCurrentUser from 'shared/hooks/currentUser';
import toast from 'shared/utils/toast';
import { fetchCurrentUser } from "actions/users";
import BodyForm from '../BodyForm';
import ProTip from './ProTip';
import { Create, UserAvatar, Right, FakeTextarea } from './Styles';
import { Create, FakeTextarea, Right, UserAvatar } from './Styles';
const propTypes = {
issueId: PropTypes.number.isRequired,
fetchIssue: PropTypes.func.isRequired,
};
class ProjectBoardIssueDetailsCommentsCreate extends React.Component {
state = { isFormOpen: false, isCreating: false, body: '', currentUser: null };
const ProjectBoardIssueDetailsCommentsCreate = ({ issueId, fetchIssue }) => {
const [isFormOpen, setFormOpen] = useState(false);
const [isCreating, setCreating] = useState(false);
const [body, setBody] = useState('');
setFormClosed = () => this.setState({ isFormOpen: false });
setFormOpened = () => this.setState({ isFormOpen: true });
setBody = body => this.setState({ body });
setFormOpen = isFormOpen => this.setState({ isFormOpen });
setCreatingTrue = () => this.setState({ isCreating: true });
const { currentUser } = useCurrentUser();
const handleCommentCreate = async () => {
try {
setCreating(true);
await api.post(`/comments`, { body, issueId, userId: currentUser.id });
await fetchIssue();
setFormOpen(false);
setCreating(false);
setBody('');
} catch (error) {
toast.error(error);
componentDidMount() {
this.props.fetchCurrentUser({});
}
};
return (
<Create>
{currentUser && <UserAvatar name={currentUser.name} avatarUrl={currentUser.avatarUrl} />}
<Right>
{isFormOpen ? (
<BodyForm
value={body}
onChange={setBody}
isWorking={isCreating}
onSubmit={handleCommentCreate}
onCancel={() => setFormOpen(false)}
/>
) : (
<Fragment>
<FakeTextarea onClick={() => setFormOpen(true)}>Add a comment...</FakeTextarea>
<ProTip setFormOpen={setFormOpen} />
</Fragment>
)}
</Right>
</Create>
);
handleCommentCreate = async () => {
try {
this.setCreatingTrue();
const response = await api.post(`/comments`, { body: this.state.body, issueId: this.props.issueId });
console.log(response);
await this.props.fetchIssue();
this.setState({ isCreating: false, isFormOpen: false, body: '' });
} catch (error) {
this.setState({ isCreating: false });
toast.error(error);
}
};
render() {
const { body, isFormOpen, isCreating } = this.state;
const { currentUser } = this.props;
return (
<Create>
{ currentUser && <UserAvatar name={ currentUser.name } avatarUrl={ currentUser.avatarUrl }/> }
<Right>
{ isFormOpen ? (
<BodyForm
value={ body }
onChange={ this.setBody }
isWorking={ isCreating }
onSubmit={ this.handleCommentCreate }
onCancel={ this.setFormClosed }
/>
) : (
<Fragment>
<FakeTextarea onClick={ this.setFormOpened }>Add a comment...</FakeTextarea>
<ProTip setFormOpen={ this.setFormOpen }/>
</Fragment>
) }
</Right>
</Create>
);
}
}
ProjectBoardIssueDetailsCommentsCreate.propTypes = {
issueId: PropTypes.number.isRequired,
fetchIssue: PropTypes.func.isRequired,
fetchCurrentUser: PropTypes.func,
};
ProjectBoardIssueDetailsCommentsCreate.propTypes = propTypes;
export default ProjectBoardIssueDetailsCommentsCreate;
export default connect(({ users: { currentUser } }) => ({ currentUser }), { fetchCurrentUser })(ProjectBoardIssueDetailsCommentsCreate);

View File

@ -1,15 +1,36 @@
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
import { Button, Form } from 'shared/components';
import { Button } from 'shared/components';
export const FormElement = styled(Form.Element)`
export const SignIn = styled.article`
margin: 24px auto;
display: flex;
justify-content: center;
`;
export const SignInSection = styled.section`
padding: 32px 40px;
width: 400px;
box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 10px;
`;
export const Header = styled.h5`
color: rgb(94, 108, 132);
font-size: 16px;
font-style: normal;
font-weight: 600;
letter-spacing: -0.048px;
line-height: 18.2833px;
`;
export const FormElement = styled.div`
padding: 25px 40px 35px;
`;
export const FormHeading = styled.div`
padding-bottom: 15px;
${font.size(21)}
${ font.size(21) }
`;
export const SelectItem = styled.div`

View File

@ -1,173 +1,177 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
IssueType,
IssueStatus,
IssuePriority,
IssueTypeCopy,
IssuePriorityCopy,
} from 'shared/constants/issues';
import { IssuePriority, IssuePriorityCopy, IssueStatus, IssueType, IssueTypeCopy, } from 'shared/constants/issues';
import toast from 'shared/utils/toast';
import useApi from 'shared/hooks/api';
import useCurrentUser from 'shared/hooks/currentUser';
import { Form, IssueTypeIcon, Icon, Avatar, IssuePriorityIcon } from 'shared/components';
import api from 'shared/utils/api';
import { Avatar, Form, Icon, IssuePriorityIcon, IssueTypeIcon } from 'shared/components';
import {
FormHeading,
FormElement,
SelectItem,
SelectItemLabel,
Divider,
Actions,
ActionButton,
} from './Styles';
import { ActionButton, Actions, Divider, FormElement, FormHeading, SelectItem, SelectItemLabel, } from './Styles';
const propTypes = {
project: PropTypes.object.isRequired,
fetchProject: PropTypes.func.isRequired,
onCreate: PropTypes.func.isRequired,
modalClose: PropTypes.func.isRequired,
};
const ProjectIssueCreate = ({ project, fetchProject, onCreate, modalClose }) => {
const [{ isCreating }, createIssue] = useApi.post('/issues');
const { currentUserId } = useCurrentUser();
return (
<Form
enableReinitialize
initialValues={{
type: IssueType.TASK,
title: '',
description: '',
reporterId: currentUserId,
userIds: [],
priority: IssuePriority.MEDIUM,
}}
validations={{
type: Form.is.required(),
title: [Form.is.required(), Form.is.maxLength(200)],
reporterId: Form.is.required(),
priority: Form.is.required(),
}}
onSubmit={async (values, form) => {
try {
await createIssue({
...values,
status: IssueStatus.BACKLOG,
projectId: project.id,
users: values.userIds.map(id => ({ id })),
});
await fetchProject();
toast.success('Issue has been successfully created.');
onCreate();
} catch (error) {
Form.handleAPIError(error, form);
class ProjectIssueCreate extends React.Component {
state = {
isCreating: false, form: {
type: IssueType.TASK,
title: '',
description: '',
reporterId: null,
userIds: [],
priority: IssuePriority.MEDIUM,
}
}}
>
<FormElement>
<FormHeading>Create issue</FormHeading>
<Form.Field.Select
name="type"
label="Issue Type"
tip="Start typing to get a list of possible matches."
options={typeOptions}
renderOption={renderType}
renderValue={renderType}
/>
<Divider />
<Form.Field.Input
name="title"
label="Short Summary"
tip="Concisely summarize the issue in one or two sentences."
/>
<Form.Field.TextEditor
name="description"
label="Description"
tip="Describe the issue in as much detail as you'd like."
/>
<Form.Field.Select
name="reporterId"
label="Reporter"
options={userOptions(project)}
renderOption={renderUser(project)}
renderValue={renderUser(project)}
/>
<Form.Field.Select
isMulti
name="userIds"
label="Assignees"
tio="People who are responsible for dealing with this issue."
options={userOptions(project)}
renderOption={renderUser(project)}
renderValue={renderUser(project)}
/>
<Form.Field.Select
name="priority"
label="Priority"
tip="Priority in relation to other issues."
options={priorityOptions}
renderOption={renderPriority}
renderValue={renderPriority}
/>
<Actions>
<ActionButton type="submit" variant="primary" isWorking={isCreating}>
Create Issue
</ActionButton>
<ActionButton type="button" variant="empty" onClick={modalClose}>
Cancel
</ActionButton>
</Actions>
</FormElement>
</Form>
);
};
};
onSubmit = async () => {
let { project, fetchProject, onCreate } = this.props;
this.setState({ isCreating: true });
try {
await api.post('/issues', {
...this.state.form,
status: IssueStatus.BACKLOG,
projectId: project.id,
// userIds: values.userIds,
});
await fetchProject();
toast.success('Issue has been successfully created.');
onCreate();
} catch (error) {
}
this.setState({ isCreating: false });
};
onInputChange = (field, value) => {
this.setState({
form: {
...this.state.form,
[field]: value,
}
});
};
render() {
let { project, modalClose } = this.props;
return (
<Form
enableReinitialize
initialValues={ this.state.form }
validations={ {} }
validate={ () => true }
onSubmit={ this.onSubmit }
>
<FormElement>
<FormHeading>
Create issue
</FormHeading>
<Form.Field.Select
name="type"
label="Issue Type"
tip="Start typing to get a list of possible matches."
options={ typeOptions }
renderOption={ renderType }
renderValue={ renderType }
/>
<Divider/>
<Form.Field.Input
name="title"
label="Short Summary"
tip="Concisely summarize the issue in one or two sentences."
onChange={ this.onInputChange }
/>
<Form.Field.TextEditor
name="description"
label="Description"
tip="Describe the issue in as much detail as you'd like."
onChange={ this.onInputChange }
/>
<Form.Field.Select
name="reporterId"
label="Reporter"
options={ userOptions(project) }
renderOption={ renderUser(project) }
renderValue={ renderUser(project) }
onChange={ this.onInputChange }
/>
<Form.Field.Select
isMulti
name="userIds"
label="Assignees"
tio="People who are responsible for dealing with this issue."
onChange={ this.onInputChange }
options={ userOptions(project) }
renderOption={ renderUser(project) }
renderValue={ renderUser(project) }
/>
<Form.Field.Select
name="priority"
label="Priority"
tip="Priority in relation to other issues."
options={ priorityOptions }
renderOption={ renderPriority }
renderValue={ renderPriority }
onChange={ this.onInputChange }
/>
<Actions>
<ActionButton type="submit" variant="primary" onClick={ this.onSubmit }>
Create Issue
</ActionButton>
<ActionButton type="button" variant="empty" onClick={ modalClose }>
Cancel
</ActionButton>
</Actions>
</FormElement>
</Form>
);
}
}
const typeOptions = Object.values(IssueType).map(type => ({
value: type,
label: IssueTypeCopy[type],
value: type,
label: IssueTypeCopy[type],
}));
const priorityOptions = Object.values(IssuePriority).map(priority => ({
value: priority,
label: IssuePriorityCopy[priority],
value: priority,
label: IssuePriorityCopy[priority],
}));
const userOptions = project => project.users.map(user => ({ value: user.id, label: user.name }));
const renderType = ({ value: type }) => (
<SelectItem>
<IssueTypeIcon type={type} top={1} />
<SelectItemLabel>{IssueTypeCopy[type]}</SelectItemLabel>
</SelectItem>
<SelectItem>
<IssueTypeIcon type={ type } top={ 1 }/>
<SelectItemLabel>{ IssueTypeCopy[type] }</SelectItemLabel>
</SelectItem>
);
const renderPriority = ({ value: priority }) => (
<SelectItem>
<IssuePriorityIcon priority={priority} top={1} />
<SelectItemLabel>{IssuePriorityCopy[priority]}</SelectItemLabel>
</SelectItem>
<SelectItem>
<IssuePriorityIcon priority={ priority } top={ 1 }/>
<SelectItemLabel>{ IssuePriorityCopy[priority] }</SelectItemLabel>
</SelectItem>
);
const renderUser = project => ({ value: userId, removeOptionValue }) => {
const user = project.users.find(({ id }) => id === userId);
const user = project.users.find(({ id }) => id === userId);
return (
<SelectItem
key={user.id}
withBottomMargin={!!removeOptionValue}
onClick={() => removeOptionValue && removeOptionValue()}
>
<Avatar size={20} avatarUrl={user.avatarUrl} name={user.name} />
<SelectItemLabel>{user.name}</SelectItemLabel>
{removeOptionValue && <Icon type="close" top={2} />}
</SelectItem>
);
return (
<SelectItem
key={ user.id }
withBottomMargin={ !!removeOptionValue }
onClick={ () => removeOptionValue && removeOptionValue() }
>
<Avatar size={ 20 } avatarUrl={ user.avatarUrl } name={ user.name }/>
<SelectItemLabel>{ user.name }</SelectItemLabel>
{ removeOptionValue && <Icon type="close" top={ 2 }/> }
</SelectItem>
);
};
ProjectIssueCreate.propTypes = propTypes;
ProjectIssueCreate.propTypes = {
project: PropTypes.object.isRequired,
fetchProject: PropTypes.func.isRequired,
onCreate: PropTypes.func.isRequired,
modalClose: PropTypes.func.isRequired,
};
export default ProjectIssueCreate;

View File

@ -4,17 +4,12 @@ import PropTypes from 'prop-types';
import { ProjectCategory, ProjectCategoryCopy } from 'shared/constants/projects';
import toast from 'shared/utils/toast';
import useApi from 'shared/hooks/api';
import { Form, Breadcrumbs } from 'shared/components';
import { Breadcrumbs, Form } from 'shared/components';
import { FormCont, FormHeading, FormElement, ActionButton } from './Styles';
const propTypes = {
project: PropTypes.object.isRequired,
fetchProject: PropTypes.func.isRequired,
};
import { ActionButton, FormCont, FormElement, FormHeading } from './Styles';
const ProjectSettings = ({ project, fetchProject }) => {
const [{ isUpdating }, updateProject] = useApi.put('/project');
const [ { isUpdating }, updateProject ] = useApi.put(`/project/${ project.id }`);
return (
<Form
@ -67,6 +62,9 @@ const categoryOptions = Object.values(ProjectCategory).map(category => ({
label: ProjectCategoryCopy[category],
}));
ProjectSettings.propTypes = propTypes;
ProjectSettings.propTypes = {
project: PropTypes.object.isRequired,
fetchProject: PropTypes.func.isRequired,
};
export default ProjectSettings;

View File

@ -0,0 +1,9 @@
import { createAction } from "redux-actions";
import { ActionType } from 'reducers/types';
export const emailChanged = createAction(ActionType.SignInEmailChanged, event => event.target.value);
export const passwordChanged = createAction(ActionType.SignInPasswordChanged, event => event.target.value);
export const signInSubmit = createAction(ActionType.SignInRequest, event => {
event.preventDefault();
});

View File

@ -0,0 +1,5 @@
import { createAction } from "redux-actions";
import { ActionType, JirsAction } from "../reducers/types";
export const fetchCurrentUser = createAction<JirsAction>(ActionType.FetchCurrentUser);

View File

@ -0,0 +1,37 @@
import { getStoredAuthToken } from 'shared/utils/authToken';
import axios from 'axios';
export interface RequestBody {
path: string,
query?: string,
body?: object,
form?: FormData,
method?: string,
}
export const endpoint = (): string => `http://localhost:3000`;
export const getContentType = (method, form) =>
method === 'GET' || form instanceof FormData
? ({})
: ({ 'Content-Type': 'application/json' });
export const buildHeaders = (method, form) => ({
'Access-Control-Allow-Origin': '*',
Authorization: `Bearer ${ getStoredAuthToken() }`,
...getContentType(method, form),
});
export const client = (method, ...argv) => axios.create(...argv);
export const request = ({ path, query = '', body, form, method = 'string' }: RequestBody) =>
method === 'GET'
? client(method, `${ endpoint() }${ path }${ query }`, {
method,
headers: buildHeaders(method, form),
})
: client(method, `${ endpoint() }${ path }${ query }`, {
method,
body: body ? JSON.stringify(body) : form,
headers: buildHeaders(method, form),
});

View File

@ -0,0 +1,4 @@
import { request } from './index';
export const currentUser = () =>
request({ path: '/currentUser' });

View File

@ -0,0 +1,25 @@
import { combineReducers } from 'redux';
import { ActionType, JirsAction } from './types';
interface SignInFormState {
email: string,
password: string,
}
const initialSignIn = (): SignInFormState => ({ email: '', password: '' });
const signInForm = (state: SignInFormState = initialSignIn(), { type, payload }: JirsAction) => {
switch (type) {
case ActionType.SignInPasswordChanged:
return { ...state, password: payload };
case ActionType.SignInEmailChanged:
return { ...state, email: payload };
case ActionType.SignInSuccess:
return initialSignIn();
default:
return state
}
};
export default combineReducers({ signInForm })

View File

@ -0,0 +1,9 @@
import { combineReducers } from 'redux';
import users from './users'
import forms from './forms'
export default combineReducers({
users,
forms,
});

View File

@ -0,0 +1,25 @@
import { Action } from 'redux';
export interface User {
avatarUrl: string,
createdAt: string,
email: string,
id: number,
name: string,
projectId: number,
updatedAt: string,
}
export enum ActionType {
CurrentUser = 'CurrentUser',
FetchCurrentUser = 'FetchCurrentUser',
SignInEmailChanged = 'SignInEmailChanged',
SignInPasswordChanged = 'SignInPasswordChanged',
SignInRequest = 'SignInRequest',
SignInSuccess = 'SignInSuccess',
}
export interface JirsAction extends Action<ActionType> {
payload?: any,
errors?: any,
}

View File

@ -0,0 +1,20 @@
import { combineReducers } from 'redux';
import { ActionType, JirsAction, User } from './types';
interface CurrentUserState extends User {
}
const initialCurrentUser = (): User => null;
export const currentUser = (state: CurrentUserState = initialCurrentUser(), { type, payload, }: JirsAction) => {
switch (type) {
case ActionType.CurrentUser: {
return payload;
}
default:
return state;
}
};
export default combineReducers({ currentUser })

View File

@ -0,0 +1,10 @@
import { takeEvery } from 'redux-saga/effects';
import { ActionType } from "../reducers/types";
import * as usersSaga from './users';
const main = function * () {
yield takeEvery(ActionType.FetchCurrentUser, usersSaga.fetchCurrentUser);
};
export default main

View File

@ -0,0 +1,9 @@
import { call } from 'redux-saga/effects';
import * as usersApi from "api/users";
export const fetchCurrentUser = function * () {
// @ts-ignore
const res = yield call(usersApi.currentUser, {});
console.log('awa', res);
};

View File

@ -2,58 +2,64 @@ import React from 'react';
import PropTypes from 'prop-types';
import { uniqueId } from 'lodash';
import Input from 'shared/components/Input';
import Select from 'shared/components/Select';
import Textarea from 'shared/components/Textarea';
import TextEditor from 'shared/components/TextEditor';
import DatePicker from 'shared/components/DatePicker';
import InputComponent from 'shared/components/Input';
import SelectComponent from 'shared/components/Select';
import TextareaComponent from 'shared/components/Textarea';
import TextEditorComponent from 'shared/components/TextEditor';
import DatePickerComponent from 'shared/components/DatePicker';
import { StyledField, FieldLabel, FieldTip, FieldError } from './Styles';
import { FieldError, FieldLabel, FieldTip, StyledField } from './Styles';
const propTypes = {
className: PropTypes.string,
label: PropTypes.string,
tip: PropTypes.string,
error: PropTypes.string,
name: PropTypes.string,
className: PropTypes.string,
label: PropTypes.string,
tip: PropTypes.string,
error: PropTypes.string,
name: PropTypes.string,
};
const defaultProps = {
className: undefined,
label: undefined,
tip: undefined,
error: undefined,
name: undefined,
className: undefined,
label: undefined,
tip: undefined,
error: undefined,
name: undefined,
};
const generateField = FormComponent => {
const FieldComponent = ({ className, label, tip, error, name, ...otherProps }) => {
const fieldId = uniqueId('form-field-');
const FieldComponent = ({ className, label, tip, error, name, ...otherProps }) => {
const fieldId = uniqueId('form-field-');
return (
<StyledField
className={className}
hasLabel={!!label}
data-testid={name ? `form-field:${name}` : 'form-field'}
>
{label && <FieldLabel htmlFor={fieldId}>{label}</FieldLabel>}
<FormComponent id={fieldId} invalid={!!error} name={name} {...otherProps} />
{tip && <FieldTip>{tip}</FieldTip>}
{error && <FieldError>{error}</FieldError>}
</StyledField>
);
};
return (
<StyledField
className={ className }
hasLabel={ !!label }
data-testid={ name ? `form-field:${ name }` : 'form-field' }
>
{ label && <FieldLabel htmlFor={ fieldId }>{ label }</FieldLabel> }
<FormComponent id={ fieldId } invalid={ !!error } name={ name } { ...otherProps } />
{ tip && <FieldTip>{ tip }</FieldTip> }
{ error && <FieldError>{ error }</FieldError> }
</StyledField>
);
};
FieldComponent.propTypes = propTypes;
FieldComponent.defaultProps = defaultProps;
FieldComponent.propTypes = propTypes;
FieldComponent.defaultProps = defaultProps;
return FieldComponent;
return FieldComponent;
};
export const Input = generateField(InputComponent);
export const Select = generateField(SelectComponent);
export const Textarea = generateField(TextareaComponent);
export const TextEditor = generateField(TextEditorComponent);
export const DatePicker = generateField(DatePickerComponent);
export default {
Input: generateField(Input),
Select: generateField(Select),
Textarea: generateField(Textarea),
TextEditor: generateField(TextEditor),
DatePicker: generateField(DatePicker),
Input,
Select,
Textarea,
TextEditor,
DatePicker,
};

View File

@ -1,10 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Formik, Form as FormikForm, Field as FormikField } from 'formik';
import { Field as FormikField, Form as FormikForm, Formik } from 'formik';
import { get, mapValues } from 'lodash';
import toast from 'shared/utils/toast';
import { is, generateErrors } from 'shared/utils/validation';
import { generateErrors, is } from 'shared/utils/validation';
import Field from './Field';
@ -37,19 +37,30 @@ const Form = ({ validate, validations, ...otherProps }) => (
Form.Element = props => <FormikForm noValidate {...props} />;
Form.Field = mapValues(Field, FieldComponent => ({ name, validate, ...props }) => (
<FormikField name={name} validate={validate}>
{({ field, form: { touched, errors, setFieldValue } }) => (
<FieldComponent
{...field}
{...props}
name={name}
error={get(touched, name) && get(errors, name)}
onChange={value => setFieldValue(name, value)}
/>
)}
</FormikField>
));
Form.Field = mapValues(Field, FieldComponent => ({ name, validate, ...props }) => {
return (
<FormikField name={ name } validate={ validate }>
{ ({ field, form: { touched, errors, setFieldValue } }) => {
const onChange = value => {
if (props.onChange) {
props.onChange(name, value)
}
setFieldValue(name, value)
};
return (
<FieldComponent
{ ...field }
{ ...props }
name={ name }
error={ get(touched, name) && get(errors, name) }
onChange={ onChange }
/>
)
} }
</FormikField>
)
});
Form.initialValues = (data, getFieldValues) =>
getFieldValues((key, defaultValue = '') => {

View File

@ -0,0 +1,5 @@
import styled from 'styled-components';
export const Input = styled.input``;
export const Select = styled.select``;

View File

@ -0,0 +1,5 @@
import styled from 'styled-components/dist/styled-components.esm';
export const Form = styled.form`
padding: 25px 40px 35px;
`;

View File

@ -0,0 +1,11 @@
import React from 'react';
// @ts-ignore
import { Form } from './Styles';
const FormComponent = ({ onSubmit, children }) => (
<Form onSubmit={ onSubmit }>
{ children }
</Form>
);
export default FormComponent

View File

@ -1,13 +1,12 @@
import { get } from 'lodash';
import useApi from 'shared/hooks/api';
const useCurrentUser = ({ cachePolicy = 'cache-only' } = {}) => {
const [{ data }] = useApi.get('/currentUser', {}, { cachePolicy });
const res = useApi.get('/currentUser', {}, { cachePolicy });
const [ { data } ] = res;
return {
currentUser: get(data, 'currentUser'),
currentUserId: get(data, 'currentUser.id'),
currentUser: data && data.currentUser,
currentUserId: data && data.currentUser ? data.currentUser.id : null,
};
};

View File

@ -6,62 +6,59 @@ import { objectToQueryString } from 'shared/utils/url';
import { getStoredAuthToken, removeStoredAuthToken } from 'shared/utils/authToken';
const defaults = {
baseURL: process.env.API_URL || 'http://localhost:3000',
headers: () => ({
'Content-Type': 'application/json',
Authorization: getStoredAuthToken() ? `Bearer ${getStoredAuthToken()}` : undefined,
}),
error: {
code: 'INTERNAL_ERROR',
message: 'Something went wrong. Please check your internet connection or contact our support.',
status: 503,
data: {},
},
baseURL: process.env.API_URL || 'http://localhost:3000',
headers: () => ({
'Content-Type': 'application/json',
Authorization: getStoredAuthToken() ? `Bearer ${ getStoredAuthToken() }` : undefined,
}),
error: {
code: 'INTERNAL_ERROR',
message: 'Something went wrong. Please check your internet connection or contact our support.',
status: 503,
data: {},
},
};
const api = (method, url, variables) =>
new Promise((resolve, reject) => {
axios({
url: `${defaults.baseURL}${url}`,
method,
headers: defaults.headers(),
params: method === 'get' ? variables : undefined,
data: method !== 'get' ? variables : undefined,
paramsSerializer: objectToQueryString,
}).then(
response => {
resolve(response.data);
},
error => {
const api = async (method, url, variables) => {
try {
const response = await axios({
url: `${ defaults.baseURL }${ url }`,
method,
headers: defaults.headers(),
params: method === 'get' ? variables : undefined,
data: method !== 'get' ? variables : undefined,
paramsSerializer: objectToQueryString,
});
return response.data;
} catch (error) {
if (error.response) {
if (error.response.data.error.code === 'INVALID_TOKEN') {
removeStoredAuthToken();
history.push('/authenticate');
} else {
reject(error.response.data.error);
}
if (error.response.status === 401) {
removeStoredAuthToken();
history.push('/authenticate');
} else {
throw error.response.data.error;
}
} else {
reject(defaults.error);
throw defaults.error;
}
},
);
});
}
};
const optimisticUpdate = async (url, { updatedFields, currentFields, setLocalData }) => {
try {
setLocalData(updatedFields);
await api('put', url, updatedFields);
} catch (error) {
setLocalData(currentFields);
toast.error(error);
}
try {
setLocalData(updatedFields);
await api('put', url, updatedFields);
} catch (error) {
setLocalData(currentFields);
toast.error(error);
}
};
export default {
get: (...args) => api('get', ...args),
post: (...args) => api('post', ...args),
put: (...args) => api('put', ...args),
patch: (...args) => api('patch', ...args),
delete: (...args) => api('delete', ...args),
optimisticUpdate,
get: (...args) => api('get', ...args),
post: (...args) => api('post', ...args),
put: (...args) => api('put', ...args),
patch: (...args) => api('patch', ...args),
delete: (...args) => api('delete', ...args),
optimisticUpdate,
};

14
react-client/src/store.ts Normal file
View File

@ -0,0 +1,14 @@
import { applyMiddleware, compose, createStore } from 'redux';
import createSagaMiddleware from 'redux-saga';
import reducers from './reducers';
import saga from './sagas';
// @ts-ignore
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const sagaMiddleware = createSagaMiddleware();
export default createStore(reducers, composeEnhancers(applyMiddleware(sagaMiddleware)));
sagaMiddleware.run(saga);

View File

@ -14,9 +14,9 @@ module.exports = {
module: {
rules: [
{
test: /\.(t|j)sx?$/,
test: /\.([tj])sx?$/,
exclude: /node_modules/,
use: ['swc-loader'],
use: [ 'swc-loader' ],
},
{
test: /\.css$/,
@ -34,17 +34,23 @@ module.exports = {
],
},
resolve: {
// allows us to do absolute imports from "src"
modules: [path.join(__dirname, 'src'), 'node_modules'],
extensions: ['*', '.js', '.jsx', '.ts', '.tsx'],
modules: [ path.join(__dirname, 'src'), 'node_modules' ],
extensions: [ '*', '.js', '.jsx', '.ts', '.tsx' ],
},
devtool: 'eval-source-map',
devtool: 'source-map',
devServer: {
contentBase: path.join(__dirname, 'dev'),
historyApiFallback: true,
hot: true,
port: 8000,
host: '0.0.0.0',
allowedHosts: [
'localhost:3000',
'localhost:8000',
],
headers: {
'Access-Control-Allow-Origin': '*',
}
},
plugins: [
new webpack.HotModuleReplacementPlugin(),

View File

@ -1123,6 +1123,50 @@
"@nodelib/fs.scandir" "2.1.3"
fastq "^1.6.0"
"@redux-saga/core@^1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@redux-saga/core/-/core-1.1.3.tgz#3085097b57a4ea8db5528d58673f20ce0950f6a4"
integrity sha512-8tInBftak8TPzE6X13ABmEtRJGjtK17w7VUs7qV17S8hCO5S3+aUTWZ/DBsBJPdE8Z5jOPwYALyvofgq1Ws+kg==
dependencies:
"@babel/runtime" "^7.6.3"
"@redux-saga/deferred" "^1.1.2"
"@redux-saga/delay-p" "^1.1.2"
"@redux-saga/is" "^1.1.2"
"@redux-saga/symbols" "^1.1.2"
"@redux-saga/types" "^1.1.0"
redux "^4.0.4"
typescript-tuple "^2.2.1"
"@redux-saga/deferred@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@redux-saga/deferred/-/deferred-1.1.2.tgz#59937a0eba71fff289f1310233bc518117a71888"
integrity sha512-908rDLHFN2UUzt2jb4uOzj6afpjgJe3MjICaUNO3bvkV/kN/cNeI9PMr8BsFXB/MR8WTAZQq/PlTq8Kww3TBSQ==
"@redux-saga/delay-p@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@redux-saga/delay-p/-/delay-p-1.1.2.tgz#8f515f4b009b05b02a37a7c3d0ca9ddc157bb355"
integrity sha512-ojc+1IoC6OP65Ts5+ZHbEYdrohmIw1j9P7HS9MOJezqMYtCDgpkoqB5enAAZrNtnbSL6gVCWPHaoaTY5KeO0/g==
dependencies:
"@redux-saga/symbols" "^1.1.2"
"@redux-saga/is@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@redux-saga/is/-/is-1.1.2.tgz#ae6c8421f58fcba80faf7cadb7d65b303b97e58e"
integrity sha512-OLbunKVsCVNTKEf2cH4TYyNbbPgvmZ52iaxBD4I1fTif4+MTXMa4/Z07L83zW/hTCXwpSZvXogqMqLfex2Tg6w==
dependencies:
"@redux-saga/symbols" "^1.1.2"
"@redux-saga/types" "^1.1.0"
"@redux-saga/symbols@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@redux-saga/symbols/-/symbols-1.1.2.tgz#216a672a487fc256872b8034835afc22a2d0595d"
integrity sha512-EfdGnF423glv3uMwLsGAtE6bg+R9MdqlHEzExnfagXPrIiuxwr3bdiAwz3gi+PsrQ3yBlaBpfGLtDG8rf3LgQQ==
"@redux-saga/types@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@redux-saga/types/-/types-1.1.0.tgz#0e81ce56b4883b4b2a3001ebe1ab298b84237204"
integrity sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg==
"@samverschueren/stream-to-observable@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f"
@ -1140,6 +1184,13 @@
progress "^2.0.3"
"true-case-path" "^1.0.3"
"@types/axios@^0.14.0":
version "0.14.0"
resolved "https://registry.yarnpkg.com/@types/axios/-/axios-0.14.0.tgz#ec2300fbe7d7dddd7eb9d3abf87999964cafce46"
integrity sha1-7CMA++fX3d1+udOr+HmZlkyvzkY=
dependencies:
axios "*"
"@types/babel__core@^7.1.0":
version "7.1.6"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.6.tgz#16ff42a5ae203c9af1c6e190ed1f30f83207b610"
@ -1192,6 +1243,14 @@
"@types/minimatch" "*"
"@types/node" "*"
"@types/hoist-non-react-statics@^3.3.0":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==
dependencies:
"@types/react" "*"
hoist-non-react-statics "^3.3.0"
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff"
@ -1222,6 +1281,48 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-13.9.5.tgz#59738bf30b31aea1faa2df7f4a5f55613750cf00"
integrity sha512-hkzMMD3xu6BrJpGVLeQ3htQQNAcOrJjX7WFmtK8zWQpz2UJf13LCFF2ALA7c9OVdvc2vQJeDdjfR35M0sBCxvw==
"@types/prop-types@*":
version "15.7.3"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7"
integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==
"@types/react-redux@^7.1.7":
version "7.1.7"
resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.7.tgz#12a0c529aba660696947384a059c5c6e08185c7a"
integrity sha512-U+WrzeFfI83+evZE2dkZ/oF/1vjIYgqrb5dGgedkqVV8HEfDFujNgWCwHL89TDuWKb47U0nTBT6PLGq4IIogWg==
dependencies:
"@types/hoist-non-react-statics" "^3.3.0"
"@types/react" "*"
hoist-non-react-statics "^3.3.0"
redux "^4.0.0"
"@types/react@*":
version "16.9.27"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.27.tgz#7fc5db99e3ec3f21735b44d3560cff684856814a"
integrity sha512-j+RvQb9w7a2kZFBOgTh+s/elCwtqWUMN6RJNdmz0ntmwpeoMHKnyhUcmYBu7Yw94Rtj9938D+TJSn6WGcq2+OA==
dependencies:
"@types/prop-types" "*"
csstype "^2.2.0"
"@types/redux-actions@^2.6.1":
version "2.6.1"
resolved "https://registry.yarnpkg.com/@types/redux-actions/-/redux-actions-2.6.1.tgz#0940e97fa35ad3004316bddb391d8e01d2efa605"
integrity sha512-zKgK+ATp3sswXs6sOYo1tk8xdXTy4CTaeeYrVQlClCjeOpag5vzPo0ASWiiBJ7vsiQRAdb3VkuFLnDoBimF67g==
"@types/redux-saga@^0.10.5":
version "0.10.5"
resolved "https://registry.yarnpkg.com/@types/redux-saga/-/redux-saga-0.10.5.tgz#80bf21078379ebc97387dbe56e44467b5677fa85"
integrity sha1-gL8hB4N568lzh9vlbkRGe1Z3+oU=
dependencies:
redux-saga "*"
"@types/redux@^3.6.0":
version "3.6.0"
resolved "https://registry.yarnpkg.com/@types/redux/-/redux-3.6.0.tgz#f1ebe1e5411518072e4fdfca5c76e16e74c1399a"
integrity sha1-8evh5UEVGAcuT9/KXHbhbnTBOZo=
dependencies:
redux "*"
"@types/sizzle@2.3.2":
version "2.3.2"
resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.2.tgz#a811b8c18e2babab7d542b3365887ae2e4d9de47"
@ -1770,7 +1871,7 @@ aws4@^1.8.0:
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==
axios@^0.19.0:
axios@*, axios@^0.19.2:
version "0.19.2"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27"
integrity sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==
@ -2804,7 +2905,7 @@ cssstyle@^1.0.0:
dependencies:
cssom "0.3.x"
csstype@^2.6.7:
csstype@^2.2.0, csstype@^2.6.7:
version "2.6.9"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.9.tgz#05141d0cd557a56b8891394c1911c40c8a98d098"
integrity sha512-xz39Sb4+OaTsULgUERcCk+TJj8ylkL4aSVDQiX/ksxbELSqwkgt4d4RD7fovIdgJGSuNYqwZEiVjYY5l0ask+Q==
@ -5734,6 +5835,11 @@ jsx-ast-utils@^2.2.1, jsx-ast-utils@^2.2.3:
array-includes "^3.0.3"
object.assign "^4.1.0"
just-curry-it@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/just-curry-it/-/just-curry-it-3.1.0.tgz#ab59daed308a58b847ada166edd0a2d40766fbc5"
integrity sha512-mjzgSOFzlrurlURaHVjnQodyPNvrHrf1TbQP2XU9NSqBtHQPuHZ+Eb6TAJP7ASeJN9h9K0KXoRTs8u6ouHBKvg==
jwt-decode@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79"
@ -7557,7 +7663,7 @@ react-is@^16.8.4, react-is@^16.9.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-redux@^7.1.1:
react-redux@^7.1.1, react-redux@^7.2.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.0.tgz#f970f62192b3981642fec46fd0db18a074fe879d"
integrity sha512-EvCAZYGfOLqwV7gh849xy9/pt55rJXPwmYvI4lilPM5rUT/1NxuuN59ipdBksRVSvz0KInbPnp4IfoXJXCqiDA==
@ -7696,7 +7802,30 @@ realpath-native@^1.1.0:
dependencies:
util.promisify "^1.0.0"
redux@^4.0.4:
reduce-reducers@^0.4.3:
version "0.4.3"
resolved "https://registry.yarnpkg.com/reduce-reducers/-/reduce-reducers-0.4.3.tgz#8e052618801cd8fc2714b4915adaa8937eb6d66c"
integrity sha512-+CNMnI8QhgVMtAt54uQs3kUxC3Sybpa7Y63HR14uGLgI9/QR5ggHvpxwhGGe3wmx5V91YwqQIblN9k5lspAmGw==
redux-actions@^2.6.5:
version "2.6.5"
resolved "https://registry.yarnpkg.com/redux-actions/-/redux-actions-2.6.5.tgz#bdca548768ee99832a63910c276def85e821a27e"
integrity sha512-pFhEcWFTYNk7DhQgxMGnbsB1H2glqhQJRQrtPb96kD3hWiZRzXHwwmFPswg6V2MjraXRXWNmuP9P84tvdLAJmw==
dependencies:
invariant "^2.2.4"
just-curry-it "^3.1.0"
loose-envify "^1.4.0"
reduce-reducers "^0.4.3"
to-camel-case "^1.0.0"
redux-saga@*, redux-saga@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-1.1.3.tgz#9f3e6aebd3c994bbc0f6901a625f9a42b51d1112"
integrity sha512-RkSn/z0mwaSa5/xH/hQLo8gNf4tlvT18qXDNvedihLcfzh+jMchDgaariQoehCpgRltEm4zHKJyINEz6aqswTw==
dependencies:
"@redux-saga/core" "^1.1.3"
redux@*, redux@^4.0.0, redux@^4.0.4, redux@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f"
integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==
@ -8966,11 +9095,23 @@ to-arraybuffer@^1.0.0:
resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43"
integrity sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=
to-camel-case@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/to-camel-case/-/to-camel-case-1.0.0.tgz#1a56054b2f9d696298ce66a60897322b6f423e46"
integrity sha1-GlYFSy+daWKYzmamCJcyK29CPkY=
dependencies:
to-space-case "^1.0.0"
to-fast-properties@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=
to-no-case@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/to-no-case/-/to-no-case-1.0.2.tgz#c722907164ef6b178132c8e69930212d1b4aa16a"
integrity sha1-xyKQcWTvaxeBMsjmmTAhLRtKoWo=
to-object-path@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af"
@ -9003,6 +9144,13 @@ to-regex@^3.0.1, to-regex@^3.0.2:
regex-not "^1.0.2"
safe-regex "^1.1.0"
to-space-case@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/to-space-case/-/to-space-case-1.0.0.tgz#b052daafb1b2b29dc770cea0163e5ec0ebc9fc17"
integrity sha1-sFLar7Gysp3HcM6gFj5ewOvJ/Bc=
dependencies:
to-no-case "^1.0.0"
toidentifier@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
@ -9100,6 +9248,25 @@ typedarray@^0.0.6:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
typescript-compare@^0.0.2:
version "0.0.2"
resolved "https://registry.yarnpkg.com/typescript-compare/-/typescript-compare-0.0.2.tgz#7ee40a400a406c2ea0a7e551efd3309021d5f425"
integrity sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA==
dependencies:
typescript-logic "^0.0.0"
typescript-logic@^0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/typescript-logic/-/typescript-logic-0.0.0.tgz#66ebd82a2548f2b444a43667bec120b496890196"
integrity sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==
typescript-tuple@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/typescript-tuple/-/typescript-tuple-2.2.1.tgz#7d9813fb4b355f69ac55032e0363e8bb0f04dad2"
integrity sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==
dependencies:
typescript-compare "^0.0.2"
uglify-js@3.4.x:
version "3.4.10"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.10.tgz#9ad9563d8eb3acdfb8d38597d2af1d815f6a755f"