Display project, show and edit issue
This commit is contained in:
parent
f802d0c4e5
commit
e388d89494
11
Cargo.lock
generated
11
Cargo.lock
generated
@ -960,6 +960,16 @@ version = "0.1.0"
|
||||
name = "jirs-client"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "jirs-data"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"uuid 0.8.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jirs-server"
|
||||
version = "0.1.0"
|
||||
@ -978,6 +988,7 @@ dependencies = [
|
||||
"env_logger 0.7.1",
|
||||
"futures",
|
||||
"ipnetwork",
|
||||
"jirs-data",
|
||||
"libc",
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
|
@ -2,6 +2,7 @@
|
||||
members = [
|
||||
"./jirs-cli",
|
||||
"./jirs-server",
|
||||
"./jirs-client"
|
||||
"./jirs-client",
|
||||
"./jirs-data",
|
||||
]
|
||||
|
||||
|
17
jirs-data/Cargo.toml
Normal file
17
jirs-data/Cargo.toml
Normal file
@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "jirs-data"
|
||||
version = "0.1.0"
|
||||
authors = ["Adrian Wozniak <adrian.wozniak@ita-prog.pl>"]
|
||||
edition = "2018"
|
||||
|
||||
[lib]
|
||||
name = "jirs_data"
|
||||
path = "./src/lib.rs"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
serde = "*"
|
||||
serde_json = "*"
|
||||
chrono = { version = "*", features = [ "serde" ] }
|
||||
uuid = { version = ">=0.7.0, <0.9.0", features = ["serde"] }
|
173
jirs-data/src/lib.rs
Normal file
173
jirs-data/src/lib.rs
Normal file
@ -0,0 +1,173 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub trait ResponseData {
|
||||
type Response: Serialize;
|
||||
|
||||
fn into_response(self) -> Self::Response;
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ErrorResponse {
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FullProject {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
|
||||
pub issues: Vec<Issue>,
|
||||
pub users: Vec<User>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FullProjectResponse {
|
||||
pub project: FullProject,
|
||||
}
|
||||
|
||||
impl ResponseData for FullProject {
|
||||
type Response = FullProjectResponse;
|
||||
|
||||
fn into_response(self) -> Self::Response {
|
||||
FullProjectResponse { project: self }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FullIssue {
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
#[serde(rename = "type")]
|
||||
pub issue_type: String,
|
||||
pub status: String,
|
||||
pub priority: String,
|
||||
pub list_position: f64,
|
||||
pub description: Option<String>,
|
||||
pub description_text: Option<String>,
|
||||
pub estimate: Option<i32>,
|
||||
pub time_spent: Option<i32>,
|
||||
pub time_remaining: Option<i32>,
|
||||
pub reporter_id: i32,
|
||||
pub project_id: i32,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
|
||||
pub user_ids: Vec<i32>,
|
||||
pub comments: Vec<Comment>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FullIssueResponse {
|
||||
pub issue: FullIssue,
|
||||
}
|
||||
|
||||
impl ResponseData for FullIssue {
|
||||
type Response = FullIssueResponse;
|
||||
|
||||
fn into_response(self) -> Self::Response {
|
||||
FullIssueResponse { issue: self }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Project {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Issue {
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
#[serde(rename = "type")]
|
||||
pub issue_type: String,
|
||||
pub status: String,
|
||||
pub priority: String,
|
||||
pub list_position: f64,
|
||||
pub description: Option<String>,
|
||||
pub description_text: Option<String>,
|
||||
pub estimate: Option<i32>,
|
||||
pub time_spent: Option<i32>,
|
||||
pub time_remaining: Option<i32>,
|
||||
pub reporter_id: i32,
|
||||
pub project_id: i32,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
|
||||
pub user_ids: Vec<i32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Comment {
|
||||
pub id: i32,
|
||||
pub body: String,
|
||||
pub user_id: i32,
|
||||
pub issue_id: i32,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
|
||||
pub user: Option<User>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct User {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub project_id: i32,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Token {
|
||||
pub id: i32,
|
||||
pub user_id: i32,
|
||||
pub access_token: Uuid,
|
||||
pub refresh_token: Uuid,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateIssuePayload {
|
||||
pub title: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub issue_type: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
pub list_position: Option<f64>,
|
||||
pub description: Option<Option<String>>,
|
||||
pub description_text: Option<Option<String>>,
|
||||
pub estimate: Option<Option<i32>>,
|
||||
pub time_spent: Option<Option<i32>>,
|
||||
pub time_remaining: Option<Option<i32>>,
|
||||
pub project_id: Option<i32>,
|
||||
|
||||
pub users: Option<Vec<User>>,
|
||||
pub user_ids: Option<Vec<i32>>,
|
||||
}
|
@ -9,6 +9,7 @@ name = "jirs_server"
|
||||
path = "./src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
jirs-data = { path = "../jirs-data" }
|
||||
serde = { version = "*", features = ["derive"] }
|
||||
actix = { version = "*" }
|
||||
actix-web = { version = "*" }
|
||||
|
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS issue_assignees CASCADE;
|
@ -0,0 +1,7 @@
|
||||
CREATE TABLE issue_assignees (
|
||||
id serial primary key not null,
|
||||
issue_id integer not null references issues (id),
|
||||
user_id integer not null references users (id),
|
||||
created_at timestamp not null default now(),
|
||||
updated_at timestamp not null default now()
|
||||
);
|
@ -1,4 +1,45 @@
|
||||
INSERT INTO projects (name) values ('initial');
|
||||
INSERT INTO users (project_id, email, name) values (1, 'foo', 'bar');
|
||||
INSERT INTO tokens (user_id, access_token, refresh_token) values (1, uuid_generate_v4(), uuid_generate_v4() );
|
||||
SELECT * FROM tokens;
|
||||
insert into projects (name) values ('initial'), ('second'), ('third');
|
||||
insert into users (project_id, email, name, avatar_url) values (1, 'john@example.com', 'John Doe', 'http://cdn.onlinewebfonts.com/svg/img_553934.png'), (1, 'kate@exampe.com', 'Kate Snow', 'http://www.asthmamd.org/images/icon_user_6.png');
|
||||
insert into tokens (user_id, access_token, refresh_token) values (1, uuid_generate_v4(), uuid_generate_v4() );
|
||||
insert into issues(
|
||||
title,
|
||||
issue_type,
|
||||
status,
|
||||
priority,
|
||||
list_position,
|
||||
description,
|
||||
description_text,
|
||||
reporter_id,
|
||||
project_id
|
||||
) values (
|
||||
'Foo',
|
||||
'task',
|
||||
'backlog',
|
||||
'low',
|
||||
1,
|
||||
'hello world',
|
||||
'foz baz',
|
||||
1,
|
||||
1
|
||||
), (
|
||||
'Foo2',
|
||||
'story',
|
||||
'selected',
|
||||
'low',
|
||||
2,
|
||||
'hello world 2',
|
||||
'foz baz 2',
|
||||
1,
|
||||
1
|
||||
), (
|
||||
'Foo3',
|
||||
'bug',
|
||||
'inprogress',
|
||||
'low',
|
||||
3,
|
||||
'hello world 3',
|
||||
'foz baz 3',
|
||||
2,
|
||||
1
|
||||
);
|
||||
select * from tokens;
|
||||
|
@ -28,11 +28,11 @@ impl Handler<AuthorizeUser> for DbExecutor {
|
||||
let token = tokens
|
||||
.filter(access_token.eq(msg.access_token))
|
||||
.first::<Token>(conn)
|
||||
.map_err(|e| ServiceErrors::RecordNotFound("Token".to_string()))?;
|
||||
.map_err(|_e| ServiceErrors::RecordNotFound("Token".to_string()))?;
|
||||
let user = users
|
||||
.filter(id.eq(token.user_id))
|
||||
.first::<User>(conn)
|
||||
.map_err(|e| ServiceErrors::RecordNotFound("User".to_string()))?;
|
||||
.map_err(|_e| ServiceErrors::RecordNotFound("User".to_string()))?;
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
34
jirs-server/src/db/comments.rs
Normal file
34
jirs-server/src/db/comments.rs
Normal file
@ -0,0 +1,34 @@
|
||||
use crate::db::DbExecutor;
|
||||
use crate::errors::ServiceErrors;
|
||||
use crate::models::Comment;
|
||||
use actix::{Handler, Message};
|
||||
use diesel::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct LoadIssueComments {
|
||||
pub issue_id: i32,
|
||||
}
|
||||
|
||||
impl Message for LoadIssueComments {
|
||||
type Result = Result<Vec<Comment>, ServiceErrors>;
|
||||
}
|
||||
|
||||
impl Handler<LoadIssueComments> for DbExecutor {
|
||||
type Result = Result<Vec<Comment>, ServiceErrors>;
|
||||
|
||||
fn handle(&mut self, msg: LoadIssueComments, _ctx: &mut Self::Context) -> Self::Result {
|
||||
use crate::schema::comments::dsl::*;
|
||||
|
||||
let conn = &self
|
||||
.0
|
||||
.get()
|
||||
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||
let rows: Vec<Comment> = comments
|
||||
.distinct_on(id)
|
||||
.filter(issue_id.eq(msg.issue_id))
|
||||
.load(conn)
|
||||
.map_err(|_| ServiceErrors::RecordNotFound("issue comments".to_string()))?;
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
@ -2,9 +2,37 @@ 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 {
|
||||
pub issue_id: i32,
|
||||
}
|
||||
|
||||
impl Message for LoadIssue {
|
||||
type Result = Result<Issue, ServiceErrors>;
|
||||
}
|
||||
|
||||
impl Handler<LoadIssue> for DbExecutor {
|
||||
type Result = Result<Issue, ServiceErrors>;
|
||||
|
||||
fn handle(&mut self, msg: LoadIssue, _ctx: &mut Self::Context) -> Self::Result {
|
||||
use crate::schema::issues::dsl::{id, issues};
|
||||
let conn = &self
|
||||
.0
|
||||
.get()
|
||||
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||
let record = issues
|
||||
.filter(id.eq(msg.issue_id))
|
||||
.distinct()
|
||||
.first::<Issue>(conn)
|
||||
.map_err(|_| ServiceErrors::RecordNotFound("project issues".to_string()))?;
|
||||
Ok(record)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct LoadProjectIssues {
|
||||
pub project_id: i32,
|
||||
@ -31,3 +59,99 @@ impl Handler<LoadProjectIssues> for DbExecutor {
|
||||
Ok(vec)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateIssue {
|
||||
pub issue_id: i32,
|
||||
pub title: Option<String>,
|
||||
pub issue_type: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
pub list_position: Option<f64>,
|
||||
pub description: Option<Option<String>>,
|
||||
pub description_text: Option<Option<String>>,
|
||||
pub estimate: Option<Option<i32>>,
|
||||
pub time_spent: Option<Option<i32>>,
|
||||
pub time_remaining: Option<Option<i32>>,
|
||||
pub project_id: Option<i32>,
|
||||
|
||||
pub users: Option<Vec<jirs_data::User>>,
|
||||
pub user_ids: Option<Vec<i32>>,
|
||||
}
|
||||
|
||||
impl Message for UpdateIssue {
|
||||
type Result = Result<Issue, ServiceErrors>;
|
||||
}
|
||||
|
||||
impl Handler<UpdateIssue> for DbExecutor {
|
||||
type Result = Result<Issue, ServiceErrors>;
|
||||
|
||||
fn handle(&mut self, msg: UpdateIssue, _ctx: &mut Self::Context) -> Self::Result {
|
||||
use crate::schema::issues::dsl::{self, issues};
|
||||
let conn = &self
|
||||
.0
|
||||
.get()
|
||||
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||
|
||||
let current_issue_id = msg.issue_id;
|
||||
|
||||
let chain = diesel::update(issues.find(current_issue_id)).set((
|
||||
msg.title.map(|title| dsl::title.eq(title)),
|
||||
msg.issue_type
|
||||
.map(|issue_type| dsl::issue_type.eq(issue_type)),
|
||||
msg.status.map(|status| dsl::status.eq(status)),
|
||||
msg.priority.map(|priority| dsl::priority.eq(priority)),
|
||||
msg.list_position
|
||||
.map(|list_position| dsl::list_position.eq(list_position)),
|
||||
msg.description
|
||||
.map(|description| dsl::description.eq(description)),
|
||||
msg.description_text
|
||||
.map(|description_text| dsl::description_text.eq(description_text)),
|
||||
msg.estimate.map(|estimate| dsl::estimate.eq(estimate)),
|
||||
msg.time_spent
|
||||
.map(|time_spent| dsl::time_spent.eq(time_spent)),
|
||||
msg.time_remaining
|
||||
.map(|time_remaining| dsl::time_remaining.eq(time_remaining)),
|
||||
msg.project_id
|
||||
.map(|project_id| dsl::project_id.eq(project_id)),
|
||||
dsl::updated_at.eq(chrono::Utc::now().naive_utc()),
|
||||
));
|
||||
diesel::debug_query::<diesel::pg::Pg, _>(&chain);
|
||||
chain
|
||||
.get_result::<Issue>(conn)
|
||||
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||
|
||||
if let Some(user_ids) = msg.user_ids.as_ref() {
|
||||
use crate::schema::issue_assignees::dsl;
|
||||
diesel::delete(dsl::issue_assignees)
|
||||
.filter(not(dsl::user_id.eq_any(user_ids)).and(dsl::issue_id.eq(current_issue_id)))
|
||||
.execute(conn)
|
||||
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||
let existing: Vec<i32> = dsl::issue_assignees
|
||||
.select(dsl::user_id)
|
||||
.filter(dsl::issue_id.eq(current_issue_id))
|
||||
.get_results::<i32>(conn)
|
||||
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||
let mut values = vec![];
|
||||
for user_id in user_ids.iter() {
|
||||
if !existing.contains(user_id) {
|
||||
values.push(crate::models::CreateIssueAssigneeForm {
|
||||
issue_id: current_issue_id,
|
||||
user_id: *user_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
diesel::insert_into(dsl::issue_assignees)
|
||||
.values(values)
|
||||
.execute(conn)
|
||||
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||
}
|
||||
|
||||
let row = issues
|
||||
.find(msg.issue_id)
|
||||
.first::<Issue>(conn)
|
||||
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||
Ok(row)
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,17 @@
|
||||
use actix::{Actor, SyncContext};
|
||||
#[cfg(not(debug_assertions))]
|
||||
use diesel::pg::PgConnection;
|
||||
use diesel::r2d2::{self, ConnectionManager};
|
||||
|
||||
pub mod authorize_user;
|
||||
pub mod comments;
|
||||
pub mod issues;
|
||||
pub mod projects;
|
||||
pub mod users;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub type DbPool = r2d2::Pool<ConnectionManager<dev::VerboseConnection>>;
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub type DbPool = r2d2::Pool<ConnectionManager<PgConnection>>;
|
||||
|
||||
pub struct DbExecutor(pub DbPool);
|
||||
@ -26,6 +31,9 @@ pub fn build_pool() -> DbPool {
|
||||
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);
|
||||
r2d2::Pool::builder()
|
||||
.build(manager)
|
||||
@ -37,3 +45,83 @@ pub trait SyncQuery {
|
||||
|
||||
fn handle(&self, pool: &DbPool) -> Self::Result;
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub mod dev {
|
||||
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,
|
||||
}
|
||||
|
||||
impl Deref for VerboseConnection {
|
||||
type Target = PgConnection;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl SimpleConnection for VerboseConnection {
|
||||
fn batch_execute(&self, query: &str) -> QueryResult<()> {
|
||||
use diesel::debug_query;
|
||||
debug_query::<diesel::pg::Pg, _>(&query);
|
||||
self.inner.batch_execute(query)
|
||||
}
|
||||
}
|
||||
|
||||
impl Connection for VerboseConnection {
|
||||
type Backend = diesel::pg::Pg;
|
||||
type TransactionManager = AnsiTransactionManager;
|
||||
|
||||
fn establish(database_url: &str) -> ConnectionResult<Self> {
|
||||
PgConnection::establish(database_url).map(|inner| Self { inner })
|
||||
}
|
||||
|
||||
fn execute(&self, query: &str) -> QueryResult<usize> {
|
||||
use diesel::debug_query;
|
||||
debug_query::<diesel::pg::Pg, _>(&query);
|
||||
self.inner.execute(query)
|
||||
}
|
||||
|
||||
fn query_by_index<T, U>(&self, source: T) -> QueryResult<Vec<U>>
|
||||
where
|
||||
T: AsQuery,
|
||||
T::Query: QueryFragment<Self::Backend> + QueryId,
|
||||
Self::Backend: HasSqlType<T::SqlType>,
|
||||
U: Queryable<T::SqlType, Self::Backend>,
|
||||
{
|
||||
use diesel::debug_query;
|
||||
debug_query::<diesel::pg::Pg, _>(&source);
|
||||
self.inner.query_by_index(source)
|
||||
}
|
||||
|
||||
fn query_by_name<T, U>(&self, source: &T) -> QueryResult<Vec<U>>
|
||||
where
|
||||
T: QueryFragment<Self::Backend> + QueryId,
|
||||
U: QueryableByName<Self::Backend>,
|
||||
{
|
||||
use diesel::debug_query;
|
||||
debug_query::<diesel::pg::Pg, _>(&source);
|
||||
self.inner.query_by_name(source)
|
||||
}
|
||||
|
||||
fn execute_returning_count<T>(&self, source: &T) -> QueryResult<usize>
|
||||
where
|
||||
T: QueryFragment<Self::Backend> + QueryId,
|
||||
{
|
||||
use diesel::debug_query;
|
||||
debug_query::<diesel::pg::Pg, _>(&source);
|
||||
self.inner.execute_returning_count(source)
|
||||
}
|
||||
|
||||
fn transaction_manager(&self) -> &Self::TransactionManager {
|
||||
self.inner.transaction_manager()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,7 @@
|
||||
use crate::db::DbExecutor;
|
||||
use crate::errors::ServiceErrors;
|
||||
use crate::models::{Issue, User};
|
||||
use crate::models::{IssueAssignee, User};
|
||||
use actix::{Handler, Message};
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@ -19,19 +18,47 @@ impl Handler<LoadProjectUsers> for DbExecutor {
|
||||
type Result = Result<Vec<User>, ServiceErrors>;
|
||||
|
||||
fn handle(&mut self, msg: LoadProjectUsers, _ctx: &mut Self::Context) -> Self::Result {
|
||||
use crate::schema::issues::dsl::{issues, project_id, reporter_id};
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
let conn = &self
|
||||
.0
|
||||
.get()
|
||||
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||
let rows: Vec<(User, Option<Issue>)> = users
|
||||
.distinct()
|
||||
.left_outer_join(issues.on(reporter_id.eq(id)))
|
||||
let rows: Vec<User> = users
|
||||
.distinct_on(id)
|
||||
.filter(project_id.eq(msg.project_id))
|
||||
.load(conn)
|
||||
.map_err(|_| ServiceErrors::RecordNotFound("project issues".to_string()))?;
|
||||
.map_err(|_| ServiceErrors::RecordNotFound("project users".to_string()))?;
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct LoadIssueAssignees {
|
||||
pub issue_id: i32,
|
||||
}
|
||||
|
||||
impl Message for LoadIssueAssignees {
|
||||
type Result = Result<Vec<User>, ServiceErrors>;
|
||||
}
|
||||
|
||||
impl Handler<LoadIssueAssignees> for DbExecutor {
|
||||
type Result = Result<Vec<User>, ServiceErrors>;
|
||||
|
||||
fn handle(&mut self, msg: LoadIssueAssignees, _ctx: &mut Self::Context) -> Self::Result {
|
||||
use crate::schema::issue_assignees::dsl::{issue_assignees, issue_id, user_id};
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
let conn = &self
|
||||
.0
|
||||
.get()
|
||||
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||
let rows: Vec<(User, IssueAssignee)> = users
|
||||
.distinct_on(id)
|
||||
.inner_join(issue_assignees.on(user_id.eq(id)))
|
||||
.filter(issue_id.eq(msg.issue_id))
|
||||
.load(conn)
|
||||
.map_err(|_| ServiceErrors::RecordNotFound("issue users".to_string()))?;
|
||||
let mut vec: Vec<User> = vec![];
|
||||
for row in rows {
|
||||
vec.push(row.0);
|
||||
|
@ -1,5 +1,5 @@
|
||||
use crate::models::ErrorResponse;
|
||||
use actix_web::HttpResponse;
|
||||
use jirs_data::ErrorResponse;
|
||||
|
||||
const TOKEN_NOT_FOUND: &str = "Token not found";
|
||||
const DATABASE_CONNECTION_FAILED: &str = "Database connection failed";
|
||||
|
@ -29,7 +29,7 @@ async fn main() -> Result<(), String> {
|
||||
web::scope("/issues")
|
||||
.wrap(crate::middleware::authorize::Authorize::default())
|
||||
.service(crate::routes::issues::project_issues)
|
||||
.service(crate::routes::issues::issue_with_users_and_omments)
|
||||
.service(crate::routes::issues::issue_with_users_and_comments)
|
||||
.service(crate::routes::issues::create)
|
||||
.service(crate::routes::issues::update)
|
||||
.service(crate::routes::issues::delete),
|
||||
@ -53,7 +53,7 @@ async fn main() -> Result<(), String> {
|
||||
.service(crate::routes::projects::update),
|
||||
)
|
||||
})
|
||||
.bind("127.0.0.1:8080")
|
||||
.bind("127.0.0.1:3000")
|
||||
.map_err(|e| format!("{}", e))?
|
||||
.run()
|
||||
.await
|
||||
|
@ -3,22 +3,34 @@ use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Queryable)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Comment {
|
||||
pub id: i32,
|
||||
pub body: String,
|
||||
pub user_id: Option<i32>,
|
||||
pub issue_id: Option<i32>,
|
||||
pub user_id: i32,
|
||||
pub issue_id: i32,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl Into<jirs_data::Comment> for Comment {
|
||||
fn into(self) -> jirs_data::Comment {
|
||||
jirs_data::Comment {
|
||||
id: self.id,
|
||||
body: self.body,
|
||||
user_id: self.user_id,
|
||||
issue_id: self.issue_id,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
|
||||
user: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Insertable)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[table_name = "comments"]
|
||||
pub struct CommentForm {
|
||||
pub body: String,
|
||||
@ -27,9 +39,11 @@ pub struct CommentForm {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Queryable)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Issue {
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
#[serde(rename = "type")]
|
||||
pub issue_type: String,
|
||||
pub status: String,
|
||||
pub priority: String,
|
||||
@ -45,10 +59,61 @@ pub struct Issue {
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl Into<jirs_data::Issue> for Issue {
|
||||
fn into(self) -> jirs_data::Issue {
|
||||
jirs_data::Issue {
|
||||
id: self.id,
|
||||
title: self.title,
|
||||
issue_type: self.issue_type,
|
||||
status: self.status,
|
||||
priority: self.priority,
|
||||
list_position: self.list_position,
|
||||
description: self.description,
|
||||
description_text: self.description_text,
|
||||
estimate: self.estimate,
|
||||
time_spent: self.time_spent,
|
||||
time_remaining: self.time_remaining,
|
||||
reporter_id: self.reporter_id,
|
||||
project_id: self.project_id,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
|
||||
user_ids: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<jirs_data::FullIssue> for Issue {
|
||||
fn into(self) -> jirs_data::FullIssue {
|
||||
jirs_data::FullIssue {
|
||||
id: self.id,
|
||||
title: self.title,
|
||||
issue_type: self.issue_type,
|
||||
status: self.status,
|
||||
priority: self.priority,
|
||||
list_position: self.list_position,
|
||||
description: self.description,
|
||||
description_text: self.description_text,
|
||||
estimate: self.estimate,
|
||||
time_spent: self.time_spent,
|
||||
time_remaining: self.time_remaining,
|
||||
reporter_id: self.reporter_id,
|
||||
project_id: self.project_id,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
|
||||
user_ids: vec![],
|
||||
comments: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Insertable)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[table_name = "issues"]
|
||||
pub struct IssueForm {
|
||||
pub struct CreateIssueForm {
|
||||
pub title: String,
|
||||
#[serde(rename = "type")]
|
||||
pub issue_type: String,
|
||||
pub status: String,
|
||||
pub priority: String,
|
||||
@ -63,6 +128,24 @@ pub struct IssueForm {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Queryable)]
|
||||
pub struct IssueAssignee {
|
||||
pub id: i32,
|
||||
pub issue_id: i32,
|
||||
pub user_id: i32,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Insertable)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[table_name = "issue_assignees"]
|
||||
pub struct CreateIssueAssigneeForm {
|
||||
pub issue_id: i32,
|
||||
pub user_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Queryable)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Project {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
@ -73,7 +156,22 @@ pub struct Project {
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl Into<jirs_data::Project> for Project {
|
||||
fn into(self) -> jirs_data::Project {
|
||||
jirs_data::Project {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
url: self.url,
|
||||
description: self.description,
|
||||
category: self.category,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Insertable)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[table_name = "projects"]
|
||||
pub struct ProjectForm {
|
||||
pub name: String,
|
||||
@ -83,6 +181,7 @@ pub struct ProjectForm {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Queryable)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct User {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
@ -93,7 +192,36 @@ pub struct User {
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl Into<jirs_data::User> for User {
|
||||
fn into(self) -> jirs_data::User {
|
||||
jirs_data::User {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
email: self.email,
|
||||
avatar_url: self.avatar_url,
|
||||
project_id: self.project_id,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<jirs_data::User> for &User {
|
||||
fn into(self) -> jirs_data::User {
|
||||
jirs_data::User {
|
||||
id: self.id,
|
||||
name: self.name.clone(),
|
||||
email: self.email.clone(),
|
||||
avatar_url: self.avatar_url.clone(),
|
||||
project_id: self.project_id,
|
||||
created_at: self.created_at.clone(),
|
||||
updated_at: self.updated_at.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Insertable)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[table_name = "users"]
|
||||
pub struct UserForm {
|
||||
pub name: String,
|
||||
@ -103,6 +231,7 @@ pub struct UserForm {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Queryable)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Token {
|
||||
pub id: i32,
|
||||
pub user_id: i32,
|
||||
@ -112,7 +241,21 @@ pub struct Token {
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl Into<jirs_data::Token> for Token {
|
||||
fn into(self) -> jirs_data::Token {
|
||||
jirs_data::Token {
|
||||
id: self.id,
|
||||
user_id: self.user_id,
|
||||
access_token: self.access_token,
|
||||
refresh_token: self.refresh_token,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Insertable)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[table_name = "tokens"]
|
||||
pub struct TokenForm {
|
||||
pub user_id: i32,
|
||||
|
@ -1,4 +1,15 @@
|
||||
use actix_web::{delete, get, post, put, HttpResponse};
|
||||
use crate::db::authorize_user::AuthorizeUser;
|
||||
use crate::db::comments::LoadIssueComments;
|
||||
use crate::db::issues::{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;
|
||||
|
||||
#[get("")]
|
||||
pub async fn project_issues() -> HttpResponse {
|
||||
@ -7,22 +18,148 @@ pub async fn project_issues() -> HttpResponse {
|
||||
.body("<!DOCTYPE html><html><head><title>Issues</title></head><body>Foo</body></html>")
|
||||
}
|
||||
|
||||
#[get("/<id>")]
|
||||
pub async fn issue_with_users_and_omments() -> HttpResponse {
|
||||
HttpResponse::Ok().content_type("text/html").body("")
|
||||
#[get("/{id}")]
|
||||
pub async fn issue_with_users_and_comments(
|
||||
req: HttpRequest,
|
||||
path: Path<i32>,
|
||||
db: Data<Addr<DbExecutor>>,
|
||||
) -> HttpResponse {
|
||||
let issue_id = path.into_inner();
|
||||
let token = match token_from_headers(req.headers()) {
|
||||
Ok(uuid) => uuid,
|
||||
_ => return crate::errors::ServiceErrors::Unauthorized.into_http_response(),
|
||||
};
|
||||
let _user = match db
|
||||
.send(AuthorizeUser {
|
||||
access_token: token,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(user)) => user,
|
||||
_ => return crate::errors::ServiceErrors::Unauthorized.into_http_response(),
|
||||
};
|
||||
|
||||
match load_issue(issue_id, db).await {
|
||||
Ok(full_issue) => HttpResponse::Ok().json(full_issue.into_response()),
|
||||
Err(e) => e.into_http_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/")]
|
||||
pub async fn create() -> HttpResponse {
|
||||
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(),
|
||||
};
|
||||
let _user = match db
|
||||
.send(AuthorizeUser {
|
||||
access_token: token,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(user)) => user,
|
||||
_ => return crate::errors::ServiceErrors::Unauthorized.into_http_response(),
|
||||
};
|
||||
HttpResponse::Ok().content_type("text/html").body("")
|
||||
}
|
||||
|
||||
#[put("/<id>")]
|
||||
pub async fn update() -> HttpResponse {
|
||||
HttpResponse::Ok().content_type("text/html").body("")
|
||||
#[put("/{id}")]
|
||||
pub async fn update(
|
||||
req: HttpRequest,
|
||||
payload: Json<jirs_data::UpdateIssuePayload>,
|
||||
path: Path<i32>,
|
||||
db: Data<Addr<DbExecutor>>,
|
||||
) -> HttpResponse {
|
||||
let issue_id = path.into_inner();
|
||||
let token = match token_from_headers(req.headers()) {
|
||||
Ok(uuid) => uuid,
|
||||
_ => return crate::errors::ServiceErrors::Unauthorized.into_http_response(),
|
||||
};
|
||||
let _user = match db
|
||||
.send(AuthorizeUser {
|
||||
access_token: token,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(user)) => user,
|
||||
_ => return crate::errors::ServiceErrors::Unauthorized.into_http_response(),
|
||||
};
|
||||
let signal = UpdateIssue {
|
||||
issue_id,
|
||||
title: payload.title.clone(),
|
||||
issue_type: payload.issue_type.clone(),
|
||||
status: payload.status.clone(),
|
||||
priority: payload.priority.clone(),
|
||||
list_position: payload.list_position.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.clone(),
|
||||
user_ids: payload.user_ids.clone(),
|
||||
users: payload.users.clone(),
|
||||
};
|
||||
match db.send(signal).await {
|
||||
Ok(Ok(_)) => (),
|
||||
Ok(Err(e)) => return e.into_http_response(),
|
||||
_ => return ServiceErrors::DatabaseConnectionLost.into_http_response(),
|
||||
};
|
||||
match load_issue(issue_id, db).await {
|
||||
Ok(full_issue) => HttpResponse::Ok().json(full_issue.into_response()),
|
||||
Err(e) => e.into_http_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("/<id>")]
|
||||
#[delete("/{id}")]
|
||||
pub async fn delete() -> HttpResponse {
|
||||
HttpResponse::Ok().content_type("text/html").body("")
|
||||
}
|
||||
|
||||
async fn load_issue(
|
||||
issue_id: i32,
|
||||
db: Data<Addr<DbExecutor>>,
|
||||
) -> Result<jirs_data::FullIssue, ServiceErrors> {
|
||||
let issue_future = db.send(LoadIssue { issue_id });
|
||||
let assignees_future = db.send(LoadIssueAssignees { issue_id });
|
||||
let comments_future = db.send(LoadIssueComments { issue_id });
|
||||
let issue_result = issue_future.await;
|
||||
let issue = match issue_result {
|
||||
Ok(Ok(issue)) => issue,
|
||||
_ => return Err(ServiceErrors::DatabaseConnectionLost),
|
||||
};
|
||||
let users = match db
|
||||
.send(LoadProjectUsers {
|
||||
project_id: issue.project_id,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(users)) => users,
|
||||
_ => return Err(ServiceErrors::DatabaseConnectionLost),
|
||||
};
|
||||
let mut full_issue: jirs_data::FullIssue = issue.into();
|
||||
let assignees_result = assignees_future.await;
|
||||
let assignees = match assignees_result {
|
||||
Ok(Ok(assignees)) => assignees,
|
||||
_ => return Err(ServiceErrors::DatabaseConnectionLost),
|
||||
};
|
||||
let mut user_map = HashMap::new();
|
||||
for user in users.into_iter() {
|
||||
user_map.insert(user.id, user);
|
||||
}
|
||||
let comments_result = comments_future.await;
|
||||
let comments = match comments_result {
|
||||
Ok(Ok(comments)) => comments,
|
||||
_ => return Err(ServiceErrors::DatabaseConnectionLost),
|
||||
};
|
||||
full_issue.user_ids = assignees.iter().map(|u| u.id).collect();
|
||||
full_issue.comments = comments
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let mut comment: jirs_data::Comment = c.into();
|
||||
comment.user = user_map.get(&comment.user_id).map(|user| user.into());
|
||||
comment
|
||||
})
|
||||
.collect();
|
||||
Ok(full_issue)
|
||||
}
|
||||
|
@ -1,17 +1,79 @@
|
||||
use crate::db::authorize_user::AuthorizeUser;
|
||||
use crate::db::issues::LoadProjectIssues;
|
||||
use crate::db::projects::LoadCurrentProject;
|
||||
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;
|
||||
|
||||
#[get("")]
|
||||
pub async fn project_with_users_and_issues(
|
||||
req: HttpRequest,
|
||||
db: Data<Addr<DbExecutor>>,
|
||||
) -> HttpResponse {
|
||||
HttpResponse::NotImplemented().body("")
|
||||
let token = match token_from_headers(req.headers()) {
|
||||
Ok(uuid) => uuid,
|
||||
_ => return crate::errors::ServiceErrors::Unauthorized.into_http_response(),
|
||||
};
|
||||
let user = match db
|
||||
.send(AuthorizeUser {
|
||||
access_token: token,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(user)) => user,
|
||||
_ => return crate::errors::ServiceErrors::Unauthorized.into_http_response(),
|
||||
};
|
||||
let issues_future = db.send(LoadProjectIssues {
|
||||
project_id: user.project_id,
|
||||
});
|
||||
let project_future = db.send(LoadCurrentProject {
|
||||
project_id: user.project_id,
|
||||
});
|
||||
let users_future = db.send(LoadProjectUsers {
|
||||
project_id: user.project_id,
|
||||
});
|
||||
let issues_result = issues_future.await;
|
||||
let issues = match issues_result {
|
||||
Ok(Ok(issues)) => issues,
|
||||
_ => return ServiceErrors::DatabaseConnectionLost.into_http_response(),
|
||||
};
|
||||
let project_result = project_future.await;
|
||||
let project = match project_result {
|
||||
Ok(Ok(project)) => project,
|
||||
_ => return ServiceErrors::DatabaseConnectionLost.into_http_response(),
|
||||
};
|
||||
let users_result = users_future.await;
|
||||
let users = match users_result {
|
||||
Ok(Ok(users)) => users,
|
||||
_ => return ServiceErrors::DatabaseConnectionLost.into_http_response(),
|
||||
};
|
||||
let res = jirs_data::FullProject {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
url: project.url,
|
||||
description: project.description,
|
||||
category: project.category,
|
||||
created_at: project.created_at,
|
||||
updated_at: project.updated_at,
|
||||
issues: issues
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let mut issue: jirs_data::Issue = i.into();
|
||||
issue.user_ids = users.iter().map(|u| u.id).collect();
|
||||
issue
|
||||
})
|
||||
.collect(),
|
||||
users: users.into_iter().map(|u| u.into()).collect(),
|
||||
};
|
||||
HttpResponse::Ok().json(res.into_response())
|
||||
}
|
||||
|
||||
#[put("")]
|
||||
pub async fn update(req: HttpRequest, db: Data<Addr<DbExecutor>>) -> HttpResponse {
|
||||
pub async fn update(_req: HttpRequest, _db: Data<Addr<DbExecutor>>) -> HttpResponse {
|
||||
HttpResponse::NotImplemented().body("")
|
||||
}
|
||||
|
@ -9,6 +9,16 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
issue_assignees (id) {
|
||||
id -> Int4,
|
||||
issue_id -> Int4,
|
||||
user_id -> Int4,
|
||||
created_at -> Timestamp,
|
||||
updated_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
issues (id) {
|
||||
id -> Int4,
|
||||
@ -66,6 +76,8 @@ table! {
|
||||
|
||||
joinable!(comments -> issues (issue_id));
|
||||
joinable!(comments -> users (user_id));
|
||||
joinable!(issue_assignees -> issues (issue_id));
|
||||
joinable!(issue_assignees -> users (user_id));
|
||||
joinable!(issues -> projects (project_id));
|
||||
joinable!(issues -> users (reporter_id));
|
||||
joinable!(tokens -> users (user_id));
|
||||
@ -73,6 +85,7 @@ joinable!(users -> projects (project_id));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(
|
||||
comments,
|
||||
issue_assignees,
|
||||
issues,
|
||||
projects,
|
||||
tokens,
|
||||
|
18
react-client/.babelrc
Normal file
18
react-client/.babelrc
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"useBuiltIns": "entry",
|
||||
"corejs": 3
|
||||
}
|
||||
],
|
||||
"@babel/react"
|
||||
],
|
||||
"plugins": [
|
||||
["@babel/plugin-proposal-decorators", { "legacy": true }],
|
||||
"@babel/plugin-proposal-export-namespace-from",
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
["@babel/plugin-proposal-class-properties", { "loose": true }]
|
||||
]
|
||||
}
|
45
react-client/.eslintrc.json
Normal file
45
react-client/.eslintrc.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"parser": "babel-eslint",
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaVersion": 8,
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"jest": true
|
||||
},
|
||||
"extends": ["airbnb", "prettier", "prettier/react"],
|
||||
"plugins": ["react-hooks"],
|
||||
"rules": {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"radix": 0,
|
||||
"no-restricted-syntax": 0,
|
||||
"no-await-in-loop": 0,
|
||||
"no-console": 0,
|
||||
"consistent-return": 0,
|
||||
"no-param-reassign": [2, { "props": false }],
|
||||
"no-return-assign": [2, "except-parens"],
|
||||
"no-use-before-define": 0,
|
||||
"import/prefer-default-export": 0,
|
||||
"import/no-cycle": 0,
|
||||
"react/no-array-index-key": 0,
|
||||
"react/forbid-prop-types": 0,
|
||||
"react/prop-types": [2, { "skipUndeclared": true }],
|
||||
"react/jsx-fragments": [2, "element"],
|
||||
"react/state-in-constructor": 0,
|
||||
"react/jsx-props-no-spreading": 0,
|
||||
"jsx-a11y/click-events-have-key-events": 0
|
||||
},
|
||||
"settings": {
|
||||
// Allows us to lint absolute imports within codebase
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"moduleDirectory": ["node_modules", "src/"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
1
react-client/.gitignore
vendored
Normal file
1
react-client/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
5
react-client/.prettierrc
Normal file
5
react-client/.prettierrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
11
react-client/.swcrc
Normal file
11
react-client/.swcrc
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"decorators": true,
|
||||
"dynamicImport": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
18
react-client/README.md
Normal file
18
react-client/README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Project structure 🏗
|
||||
|
||||
I've used this architecture on multiple larger projects in the past and it performed really well.
|
||||
|
||||
There are two special root folders in `src`: `App` and `shared` (described below). All other root folders in `src` (in our case only two: `Auth` and `Project`) should follow the structure of the routes. We can call these folders modules.
|
||||
|
||||
The main rule to follow: **Files from one module can only import from ancestor folders within the same module or from `src/shared`.** This makes the codebase easier to understand, and if you're fiddling with code in one module, you will never introduce a bug in another module.
|
||||
|
||||
<br>
|
||||
|
||||
| File or folder | Description |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/index.jsx` | The entry file. This is where we import babel polyfills and render the App into the root DOM node. |
|
||||
| `src/index.html` | The only HTML file in our App. All scripts and styles will be injected here by Webpack. |
|
||||
| `src/App` | Main application routes, components that need to be mounted at all times regardless of current route, global css styles, fonts, etc. Basically anything considered global / ancestor of all modules. |
|
||||
| `src/Auth` | Authentication module |
|
||||
| `src/Project` | Project module |
|
||||
| `src/shared` | Components, constants, utils, hooks, styles etc. that can be used anywhere in the codebase. Any module is allowed to import from shared. |
|
8
react-client/cypress.json
Normal file
8
react-client/cypress.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"baseUrl": "http://localhost:8080",
|
||||
"viewportHeight": 800,
|
||||
"viewportWidth": 1440,
|
||||
"env": {
|
||||
"apiBaseUrl": "http://localhost:3000"
|
||||
}
|
||||
}
|
6
react-client/cypress/.eslintrc.json
Normal file
6
react-client/cypress/.eslintrc.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": ["plugin:cypress/recommended"],
|
||||
"rules": {
|
||||
"no-unused-expressions": 0 // chai assertions trigger this rule
|
||||
}
|
||||
}
|
21
react-client/cypress/integration/authentication.spec.js
Normal file
21
react-client/cypress/integration/authentication.spec.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Authentication', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.visit('/');
|
||||
});
|
||||
|
||||
it('creates guest account if user has no auth token', () => {
|
||||
cy.window()
|
||||
.its('localStorage.authToken')
|
||||
.should('be.undefined');
|
||||
|
||||
cy.window()
|
||||
.its('localStorage.authToken')
|
||||
.should('be.a', 'string')
|
||||
.and('not.be.empty');
|
||||
|
||||
cy.get(testid`list-issue`).should('have.length', 8);
|
||||
});
|
||||
});
|
35
react-client/cypress/integration/issueCreate.spec.js
Normal file
35
react-client/cypress/integration/issueCreate.spec.js
Normal file
@ -0,0 +1,35 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Issue create', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/settings?modal-issue-create=true');
|
||||
});
|
||||
|
||||
it('validates form and creates issue successfully', () => {
|
||||
cy.get(testid`modal:issue-create`).within(() => {
|
||||
cy.get('button[type="submit"]').click();
|
||||
cy.get(testid`form-field:title`).should('contain', 'This field is required');
|
||||
|
||||
cy.selectOption('type', 'Story');
|
||||
cy.get('input[name="title"]').type('TEST_TITLE');
|
||||
cy.get('.ql-editor').type('TEST_DESCRIPTION');
|
||||
cy.selectOption('reporterId', 'Yoda');
|
||||
cy.selectOption('userIds', 'Gaben', 'Yoda');
|
||||
cy.selectOption('priority', 'High');
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
});
|
||||
|
||||
cy.get(testid`modal:issue-create`).should('not.exist');
|
||||
cy.contains('Issue has been successfully created.').should('exist');
|
||||
cy.location('pathname').should('equal', '/project/board');
|
||||
cy.location('search').should('be.empty');
|
||||
|
||||
cy.contains(testid`list-issue`, 'TEST_TITLE')
|
||||
.should('have.descendants', testid`avatar:Gaben`)
|
||||
.and('have.descendants', testid`avatar:Yoda`)
|
||||
.and('have.descendants', testid`icon:story`);
|
||||
});
|
||||
});
|
166
react-client/cypress/integration/issueDetails.spec.js
Normal file
166
react-client/cypress/integration/issueDetails.spec.js
Normal file
@ -0,0 +1,166 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Issue details', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/board');
|
||||
getListIssue().click(); // open issue details modal
|
||||
});
|
||||
|
||||
it('updates type, status, assignees, reporter, priority successfully', () => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.selectOption('type', 'Story');
|
||||
cy.selectOption('status', 'Done');
|
||||
cy.selectOption('assignees', 'Gaben', 'Yoda');
|
||||
cy.selectOption('reporter', 'Yoda');
|
||||
cy.selectOption('priority', 'Medium');
|
||||
});
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.selectShouldContain('type', 'Story');
|
||||
cy.selectShouldContain('status', 'Done');
|
||||
cy.selectShouldContain('assignees', 'Gaben', 'Yoda');
|
||||
cy.selectShouldContain('reporter', 'Yoda');
|
||||
cy.selectShouldContain('priority', 'Medium');
|
||||
});
|
||||
|
||||
getListIssue()
|
||||
.should('have.descendants', testid`avatar:Gaben`)
|
||||
.and('have.descendants', testid`avatar:Yoda`)
|
||||
.and('have.descendants', testid`icon:story`);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates title, description successfully', () => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.get('textarea[placeholder="Short summary"]')
|
||||
.clear()
|
||||
.type('TEST_TITLE')
|
||||
.blur();
|
||||
|
||||
cy.contains('Add a description...')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
cy.get('.ql-editor').type('TEST_DESCRIPTION');
|
||||
|
||||
cy.contains('button', 'Save')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
});
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.get('textarea[placeholder="Short summary"]').should('have.value', 'TEST_TITLE');
|
||||
cy.get('.ql-editor').should('contain', 'TEST_DESCRIPTION');
|
||||
});
|
||||
|
||||
cy.get(testid`list-issue`).should('contain', 'TEST_TITLE');
|
||||
});
|
||||
});
|
||||
|
||||
it('updates estimate, time tracking successfully', () => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
getNumberInputAtIndex(0).debounced('type', '10');
|
||||
cy.contains('10h estimated').click(); // open tracking modal
|
||||
});
|
||||
|
||||
cy.get(testid`modal:tracking`).within(() => {
|
||||
cy.contains('No time logged').should('exist');
|
||||
|
||||
getNumberInputAtIndex(0).debounced('type', 1);
|
||||
cy.get('div[width="10"]').should('exist'); // tracking bar
|
||||
|
||||
getNumberInputAtIndex(1).debounced('type', 2);
|
||||
|
||||
cy.contains('button', 'Done')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
});
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
getNumberInputAtIndex(0).should('have.value', '10');
|
||||
cy.contains('1h logged').should('exist');
|
||||
cy.contains('2h remaining').should('exist');
|
||||
cy.get('div[width*="33.3333"]').should('exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes an issue successfully', () => {
|
||||
getIssueDetailsModal()
|
||||
.find(`button ${testid`icon:trash`}`)
|
||||
.click();
|
||||
|
||||
cy.get(testid`modal:confirm`)
|
||||
.contains('button', 'Delete issue')
|
||||
.click();
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
getIssueDetailsModal().should('not.exist');
|
||||
getListIssue().should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a comment successfully', () => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.contains('Add a comment...')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
cy.get('textarea[placeholder="Add a comment..."]').type('TEST_COMMENT');
|
||||
|
||||
cy.contains('button', 'Save')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
cy.contains('Add a comment...').should('exist');
|
||||
cy.get(testid`issue-comment`).should('contain', 'TEST_COMMENT');
|
||||
});
|
||||
});
|
||||
|
||||
it('edits a comment successfully', () => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.get(testid`issue-comment`)
|
||||
.contains('Edit')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
cy.get('textarea[placeholder="Add a comment..."]')
|
||||
.should('have.value', 'Comment body')
|
||||
.clear()
|
||||
.type('TEST_COMMENT_EDITED');
|
||||
|
||||
cy.contains('button', 'Save')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
cy.get(testid`issue-comment`)
|
||||
.should('contain', 'Edit')
|
||||
.and('contain', 'TEST_COMMENT_EDITED');
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a comment successfully', () => {
|
||||
getIssueDetailsModal()
|
||||
.find(testid`issue-comment`)
|
||||
.contains('Delete')
|
||||
.click();
|
||||
|
||||
cy.get(testid`modal:confirm`)
|
||||
.contains('button', 'Delete comment')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
getIssueDetailsModal()
|
||||
.find(testid`issue-comment`)
|
||||
.should('not.exist');
|
||||
});
|
||||
|
||||
const getIssueDetailsModal = () => cy.get(testid`modal:issue-details`);
|
||||
const getListIssue = () => cy.contains(testid`list-issue`, 'Issue title 1');
|
||||
const getNumberInputAtIndex = index => cy.get('input[placeholder="Number"]').eq(index);
|
||||
});
|
35
react-client/cypress/integration/issueFilters.spec.js
Normal file
35
react-client/cypress/integration/issueFilters.spec.js
Normal file
@ -0,0 +1,35 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Issue filters', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/board');
|
||||
});
|
||||
|
||||
it('filters issues', () => {
|
||||
getSearchInput().debounced('type', 'Issue title 1');
|
||||
assertIssuesCount(1);
|
||||
getSearchInput().debounced('clear');
|
||||
assertIssuesCount(3);
|
||||
|
||||
getUserAvatar().click();
|
||||
assertIssuesCount(2);
|
||||
getUserAvatar().click();
|
||||
assertIssuesCount(3);
|
||||
|
||||
getMyOnlyButton().click();
|
||||
assertIssuesCount(2);
|
||||
getMyOnlyButton().click();
|
||||
assertIssuesCount(3);
|
||||
|
||||
getRecentButton().click();
|
||||
assertIssuesCount(3);
|
||||
});
|
||||
|
||||
const getSearchInput = () => cy.get(testid`board-filters`).find('input');
|
||||
const getUserAvatar = () => cy.get(testid`board-filters`).find(testid`avatar:Gaben`);
|
||||
const getMyOnlyButton = () => cy.get(testid`board-filters`).contains('Only My Issues');
|
||||
const getRecentButton = () => cy.get(testid`board-filters`).contains('Recently Updated');
|
||||
const assertIssuesCount = count => cy.get(testid`list-issue`).should('have.length', count);
|
||||
});
|
50
react-client/cypress/integration/issueSearch.spec.js
Normal file
50
react-client/cypress/integration/issueSearch.spec.js
Normal file
@ -0,0 +1,50 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Issue search', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/board?modal-issue-search=true');
|
||||
});
|
||||
|
||||
it('displays recent issues if search input is empty', () => {
|
||||
getIssueSearchModal().within(() => {
|
||||
cy.contains('Recent Issues').should('exist');
|
||||
getIssues().should('have.length', 3);
|
||||
|
||||
cy.get('input').debounced('type', 'anything');
|
||||
cy.contains('Recent Issues').should('not.exist');
|
||||
|
||||
cy.get('input').debounced('clear');
|
||||
cy.contains('Recent Issues').should('exist');
|
||||
getIssues().should('have.length', 3);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays matching issues successfully', () => {
|
||||
getIssueSearchModal().within(() => {
|
||||
cy.get('input').debounced('type', 'Issue');
|
||||
getIssues().should('have.length', 3);
|
||||
|
||||
cy.get('input').debounced('type', ' description');
|
||||
getIssues().should('have.length', 2);
|
||||
|
||||
cy.get('input').debounced('type', ' 3');
|
||||
getIssues().should('have.length', 1);
|
||||
|
||||
cy.contains('Matching Issues').should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
it('displays message if no results were found', () => {
|
||||
getIssueSearchModal().within(() => {
|
||||
cy.get('input').debounced('type', 'gibberish');
|
||||
|
||||
getIssues().should('not.exist');
|
||||
cy.contains("We couldn't find anything matching your search").should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
const getIssueSearchModal = () => cy.get(testid`modal:issue-search`);
|
||||
const getIssues = () => cy.get('a[href*="/project/board/issues/"]');
|
||||
});
|
48
react-client/cypress/integration/issuesDragDrop.spec.js
Normal file
48
react-client/cypress/integration/issuesDragDrop.spec.js
Normal file
@ -0,0 +1,48 @@
|
||||
import { KeyCodes } from 'shared/constants/keyCodes';
|
||||
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Issues drag & drop', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/board');
|
||||
});
|
||||
|
||||
it('moves issue between different lists', () => {
|
||||
cy.get(testid`board-list:backlog`).should('contain', firstIssueTitle);
|
||||
cy.get(testid`board-list:selected`).should('not.contain', firstIssueTitle);
|
||||
moveFirstIssue(KeyCodes.ARROW_RIGHT);
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
cy.get(testid`board-list:backlog`).should('not.contain', firstIssueTitle);
|
||||
cy.get(testid`board-list:selected`).should('contain', firstIssueTitle);
|
||||
});
|
||||
});
|
||||
|
||||
it('moves issue within a single list', () => {
|
||||
getIssueAtIndex(0).should('contain', firstIssueTitle);
|
||||
getIssueAtIndex(1).should('contain', secondIssueTitle);
|
||||
moveFirstIssue(KeyCodes.ARROW_DOWN);
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
getIssueAtIndex(0).should('contain', secondIssueTitle);
|
||||
getIssueAtIndex(1).should('contain', firstIssueTitle);
|
||||
});
|
||||
});
|
||||
|
||||
const firstIssueTitle = 'Issue title 1';
|
||||
const secondIssueTitle = 'Issue title 2';
|
||||
|
||||
const getIssueAtIndex = index => cy.get(testid`list-issue`).eq(index);
|
||||
|
||||
const moveFirstIssue = directionKeyCode => {
|
||||
cy.waitForXHR('PUT', '/issues/**', () => {
|
||||
getIssueAtIndex(0)
|
||||
.focus()
|
||||
.trigger('keydown', { keyCode: KeyCodes.SPACE })
|
||||
.trigger('keydown', { keyCode: directionKeyCode, force: true })
|
||||
.trigger('keydown', { keyCode: KeyCodes.SPACE, force: true });
|
||||
});
|
||||
};
|
||||
});
|
34
react-client/cypress/integration/projectSettings.spec.js
Normal file
34
react-client/cypress/integration/projectSettings.spec.js
Normal file
@ -0,0 +1,34 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Project settings', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/settings');
|
||||
});
|
||||
|
||||
it('should display current values in form', () => {
|
||||
cy.get('input[name="name"]').should('have.value', 'Project name');
|
||||
cy.get('input[name="url"]').should('have.value', 'https://www.testurl.com');
|
||||
cy.get('.ql-editor').should('contain', 'Project description');
|
||||
cy.selectShouldContain('category', 'Software');
|
||||
});
|
||||
|
||||
it('validates form and updates project successfully', () => {
|
||||
cy.get('input[name="name"]').clear();
|
||||
cy.get('button[type="submit"]').click();
|
||||
cy.get(testid`form-field:name`).should('contain', 'This field is required');
|
||||
|
||||
cy.get('input[name="name"]').type('TEST_NAME');
|
||||
cy.get(testid`form-field:name`).should('not.contain', 'This field is required');
|
||||
|
||||
cy.selectOption('category', 'Business');
|
||||
cy.get('button[type="submit"]').click();
|
||||
cy.contains('Changes have been saved successfully.').should('exist');
|
||||
|
||||
cy.reload();
|
||||
|
||||
cy.get('input[name="name"]').should('have.value', 'TEST_NAME');
|
||||
cy.selectShouldContain('category', 'Business');
|
||||
});
|
||||
});
|
22
react-client/cypress/plugins/index.js
vendored
Normal file
22
react-client/cypress/plugins/index.js
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
/* eslint-disable global-require */
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
|
||||
// ***********************************************************
|
||||
// This example plugins/index.js can be used to load plugins
|
||||
//
|
||||
// You can change the location of this file or turn off loading
|
||||
// the plugins file with the 'pluginsFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/plugins-guide
|
||||
// ***********************************************************
|
||||
|
||||
// This function is called when a project is opened or re-opened (e.g. due to
|
||||
// the project's config changing)
|
||||
|
||||
const webpack = require('@cypress/webpack-preprocessor');
|
||||
const webpackOptions = require('../../webpack.config.js');
|
||||
|
||||
module.exports = on => {
|
||||
on('file:preprocessor', webpack({ webpackOptions }));
|
||||
};
|
75
react-client/cypress/support/commands.js
vendored
Normal file
75
react-client/cypress/support/commands.js
vendored
Normal file
@ -0,0 +1,75 @@
|
||||
import 'core-js/stable';
|
||||
import 'regenerator-runtime/runtime';
|
||||
|
||||
import '@4tw/cypress-drag-drop';
|
||||
|
||||
import { objectToQueryString } from 'shared/utils/url';
|
||||
import { getStoredAuthToken, storeAuthToken } from 'shared/utils/authToken';
|
||||
|
||||
import { testid } from './utils';
|
||||
|
||||
Cypress.Commands.add('selectOption', (selectName, ...optionLabels) => {
|
||||
optionLabels.forEach(optionLabel => {
|
||||
cy.get(testid`select:${selectName}`).click('bottomRight');
|
||||
cy.get(testid`select-option:${optionLabel}`).click();
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('selectShouldContain', (selectName, ...optionLabels) => {
|
||||
optionLabels.forEach(optionLabel => {
|
||||
cy.get(testid`select:${selectName}`).should('contain', optionLabel);
|
||||
});
|
||||
});
|
||||
|
||||
// We don't want to waste time when running tests on cypress waiting for debounced
|
||||
// inputs. We can use tick() to speed up time and trigger onChange immediately.
|
||||
Cypress.Commands.add('debounced', { prevSubject: true }, (input, action, value) => {
|
||||
cy.clock();
|
||||
cy.wrap(input)[action](value);
|
||||
cy.tick(1000);
|
||||
});
|
||||
|
||||
// Sometimes cypress fails to properly wait for api requests to finish which results
|
||||
// in flaky tests, and in those cases we need to explicitly tell it to wait
|
||||
// https://docs.cypress.io/guides/guides/network-requests.html#Flake
|
||||
Cypress.Commands.add('waitForXHR', (method, url, funcThatTriggersXHR) => {
|
||||
const alias = method + url;
|
||||
cy.server();
|
||||
cy.route(method, url).as(alias);
|
||||
funcThatTriggersXHR();
|
||||
cy.wait(`@${alias}`);
|
||||
});
|
||||
|
||||
// We're using optimistic updates (not waiting for API response before updating
|
||||
// the local data and UI) in a lot of places in the app. That's why we want to assert
|
||||
// both the immediate local UI change in the first assert, and if the change was
|
||||
// successfully persisted by the API in the second assert after page reload
|
||||
Cypress.Commands.add('assertReloadAssert', assertFunc => {
|
||||
assertFunc();
|
||||
cy.reload();
|
||||
assertFunc();
|
||||
});
|
||||
|
||||
Cypress.Commands.add('apiRequest', (method, url, variables = {}, options = {}) => {
|
||||
cy.request({
|
||||
method,
|
||||
url: `${Cypress.env('apiBaseUrl')}${url}`,
|
||||
qs: method === 'GET' ? objectToQueryString(variables) : undefined,
|
||||
body: method !== 'GET' ? variables : undefined,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: getStoredAuthToken() ? `Bearer ${getStoredAuthToken()}` : undefined,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('resetDatabase', () => {
|
||||
cy.apiRequest('DELETE', '/test/reset-database');
|
||||
});
|
||||
|
||||
Cypress.Commands.add('createTestAccount', () => {
|
||||
cy.apiRequest('POST', '/test/create-account').then(response => {
|
||||
storeAuthToken(response.body.authToken);
|
||||
});
|
||||
});
|
16
react-client/cypress/support/index.js
vendored
Normal file
16
react-client/cypress/support/index.js
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
// ***********************************************************
|
||||
// This example support/index.js is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
import './commands';
|
4
react-client/cypress/support/utils.js
vendored
Normal file
4
react-client/cypress/support/utils.js
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
export const testid = (strings, ...values) => {
|
||||
const id = strings.map((str, index) => str + (values[index] || '')).join('');
|
||||
return `[data-testid="${id}"]`;
|
||||
};
|
9
react-client/jest.config.js
Normal file
9
react-client/jest.config.js
Normal file
@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
moduleFileExtensions: ['*', 'js', 'jsx'],
|
||||
moduleDirectories: ['src', 'node_modules'],
|
||||
moduleNameMapper: {
|
||||
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
|
||||
'<rootDir>/jest/fileMock.js',
|
||||
'\\.(css|scss|less)$': '<rootDir>/jest/styleMock.js',
|
||||
},
|
||||
};
|
1
react-client/jest/fileMock.js
vendored
Normal file
1
react-client/jest/fileMock.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
module.exports = 'test-file-stub';
|
1
react-client/jest/styleMock.js
vendored
Normal file
1
react-client/jest/styleMock.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
module.exports = {};
|
10
react-client/jsconfig.json
Normal file
10
react-client/jsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
// This config allows VSCode intellisense to work with absolute "src" imports and jsx files
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./src",
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"cypress/**/*.js", "./node_modules/cypress"]
|
||||
}
|
13088
react-client/package-lock.json
generated
Normal file
13088
react-client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
82
react-client/package.json
Normal file
82
react-client/package.json
Normal file
@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "jira_client",
|
||||
"version": "1.0.0",
|
||||
"author": "Ivor Reic",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "webpack-dev-server",
|
||||
"start:production": "pm2 start --name 'jira_client' server.js",
|
||||
"test:jest": "jest",
|
||||
"test:cypress": "node_modules/.bin/cypress open",
|
||||
"build": "rm -rf build && webpack --config webpack.config.production.js --progress",
|
||||
"pre-commit": "lint-staged"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.7.4",
|
||||
"@babel/plugin-proposal-class-properties": "^7.7.4",
|
||||
"@babel/plugin-proposal-decorators": "^7.7.4",
|
||||
"@babel/plugin-proposal-export-namespace-from": "^7.7.4",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.7.4",
|
||||
"@babel/preset-env": "^7.7.4",
|
||||
"@babel/preset-react": "^7.7.4",
|
||||
"@cypress/webpack-preprocessor": "^4.1.1",
|
||||
"@swc/core": "^1.1.36",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"babel-loader": "^8.0.6",
|
||||
"css-loader": "^3.3.2",
|
||||
"cypress": "^3.8.1",
|
||||
"eslint": "^6.1.0",
|
||||
"eslint-config-airbnb": "^18.0.1",
|
||||
"eslint-config-prettier": "^6.7.0",
|
||||
"eslint-plugin-cypress": "^2.8.1",
|
||||
"eslint-plugin-import": "^2.18.2",
|
||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||
"eslint-plugin-react": "^7.17.0",
|
||||
"eslint-plugin-react-hooks": "^1.7.0",
|
||||
"file-loader": "^5.0.2",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"jest": "^24.9.0",
|
||||
"lint-staged": "^9.5.0",
|
||||
"prettier": "^1.19.1",
|
||||
"style-loader": "^1.0.1",
|
||||
"swc-loader": "^0.1.8",
|
||||
"url-loader": "^3.0.0",
|
||||
"webpack": "^4.41.2",
|
||||
"webpack-cli": "^3.3.10",
|
||||
"webpack-dev-server": "^3.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@4tw/cypress-drag-drop": "^1.3.0",
|
||||
"axios": "^0.19.0",
|
||||
"color": "^3.1.2",
|
||||
"compression": "^1.7.4",
|
||||
"core-js": "^3.4.7",
|
||||
"express": "^4.17.1",
|
||||
"express-history-api-fallback": "^2.2.1",
|
||||
"formik": "^2.1.1",
|
||||
"history": "^4.10.1",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"lodash": "^4.17.15",
|
||||
"moment": "^2.24.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"query-string": "^6.9.0",
|
||||
"quill": "^1.3.7",
|
||||
"react": "^16.12.0",
|
||||
"react-beautiful-dnd": "^12.2.0",
|
||||
"react-content-loader": "^4.3.3",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-textarea-autosize": "^7.1.2",
|
||||
"react-transition-group": "^4.3.0",
|
||||
"regenerator-runtime": "^0.13.3",
|
||||
"styled-components": "^4.4.1",
|
||||
"sweet-pubsub": "^1.1.2"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write",
|
||||
"git add"
|
||||
]
|
||||
}
|
||||
}
|
13
react-client/server.js
vendored
Normal file
13
react-client/server.js
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
const express = require('express');
|
||||
const fallback = require('express-history-api-fallback');
|
||||
const compression = require('compression');
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(compression());
|
||||
|
||||
app.use(express.static(`${__dirname}/build`));
|
||||
|
||||
app.use(fallback(`${__dirname}/build/index.html`));
|
||||
|
||||
app.listen(process.env.PORT || 8081);
|
110
react-client/src/App/BaseStyles.js
vendored
Normal file
110
react-client/src/App/BaseStyles.js
vendored
Normal file
@ -0,0 +1,110 @@
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { color, font, mixin } from 'shared/utils/styles';
|
||||
|
||||
export default createGlobalStyle`
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
min-width: 768px;
|
||||
}
|
||||
|
||||
body {
|
||||
color: ${color.textDarkest};
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
line-height: 1.2;
|
||||
${font.size(16)}
|
||||
${font.regular}
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
${font.regular}
|
||||
}
|
||||
|
||||
*, *:after, *:before, input[type="search"] {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
ul, li, ol, dd, h1, h2, h3, h4, h5, h6, p {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6, strong {
|
||||
${font.bold}
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Workaround for IE11 focus highlighting for select elements */
|
||||
select::-ms-value {
|
||||
background: none;
|
||||
color: #42413d;
|
||||
}
|
||||
|
||||
[role="button"], button, input, select, textarea {
|
||||
outline: none;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
[role="button"], button, input, textarea {
|
||||
appearance: none;
|
||||
}
|
||||
select:-moz-focusring {
|
||||
color: transparent;
|
||||
text-shadow: 0 0 0 #000;
|
||||
}
|
||||
select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
select option {
|
||||
color: ${color.textDarkest};
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.4285;
|
||||
a {
|
||||
${mixin.link()}
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
line-height: 1.4285;
|
||||
}
|
||||
|
||||
body, select {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
${mixin.placeholderColor(color.textLight)}
|
||||
`;
|
152
react-client/src/App/NormalizeStyles.js
vendored
Normal file
152
react-client/src/App/NormalizeStyles.js
vendored
Normal file
@ -0,0 +1,152 @@
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
/** DO NOT ALTER THIS FILE. It is a copy of https://necolas.github.io/normalize.css/ */
|
||||
|
||||
export default createGlobalStyle`
|
||||
html {
|
||||
line-height: 1.15;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
main {
|
||||
display: block;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
hr {
|
||||
box-sizing: content-box;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
pre {
|
||||
font-family: monospace, monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
a {
|
||||
background-color: transparent;
|
||||
}
|
||||
abbr[title] {
|
||||
border-bottom: none;
|
||||
text-decoration: underline;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: monospace, monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
button,
|
||||
input {
|
||||
overflow: visible;
|
||||
}
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
button,
|
||||
[type="button"],
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button::-moz-focus-inner,
|
||||
[type="button"]::-moz-focus-inner,
|
||||
[type="reset"]::-moz-focus-inner,
|
||||
[type="submit"]::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
button:-moz-focusring,
|
||||
[type="button"]:-moz-focusring,
|
||||
[type="reset"]:-moz-focusring,
|
||||
[type="submit"]:-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
fieldset {
|
||||
padding: 0.35em 0.75em 0.625em;
|
||||
}
|
||||
legend {
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
display: table;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button;
|
||||
font: inherit;
|
||||
}
|
||||
details {
|
||||
display: block;
|
||||
}
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
`;
|
20
react-client/src/App/Routes.jsx
Normal file
20
react-client/src/App/Routes.jsx
Normal file
@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Router, Switch, Route, Redirect } from 'react-router-dom';
|
||||
|
||||
import history from 'browserHistory';
|
||||
import Project from 'Project';
|
||||
import Authenticate from 'Auth/Authenticate';
|
||||
import PageError from 'shared/components/PageError';
|
||||
|
||||
const Routes = () => (
|
||||
<Router history={history}>
|
||||
<Switch>
|
||||
<Redirect exact from="/" to="/project" />
|
||||
<Route path="/authenticate" component={Authenticate} />
|
||||
<Route path="/project" component={Project} />
|
||||
<Route component={PageError} />
|
||||
</Switch>
|
||||
</Router>
|
||||
);
|
||||
|
||||
export default Routes;
|
59
react-client/src/App/Toast/Styles.js
vendored
Normal file
59
react-client/src/App/Toast/Styles.js
vendored
Normal file
@ -0,0 +1,59 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font, mixin, zIndexValues } from 'shared/utils/styles';
|
||||
import { Icon } from 'shared/components';
|
||||
|
||||
export const Container = styled.div`
|
||||
z-index: ${zIndexValues.modal + 1};
|
||||
position: fixed;
|
||||
right: 30px;
|
||||
top: 50px;
|
||||
`;
|
||||
|
||||
export const StyledToast = styled.div`
|
||||
position: relative;
|
||||
margin-bottom: 5px;
|
||||
width: 300px;
|
||||
padding: 15px 20px;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
background: ${props => color[props.type]};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
${mixin.clearfix}
|
||||
${mixin.hardwareAccelerate}
|
||||
|
||||
&.jira-toast-enter,
|
||||
&.jira-toast-exit.jira-toast-exit-active {
|
||||
opacity: 0;
|
||||
right: -10px;
|
||||
}
|
||||
|
||||
&.jira-toast-exit,
|
||||
&.jira-toast-enter.jira-toast-enter-active {
|
||||
opacity: 1;
|
||||
right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const CloseIcon = styled(Icon)`
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
right: 14px;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
`;
|
||||
|
||||
export const Title = styled.div`
|
||||
padding-right: 22px;
|
||||
${font.size(15)}
|
||||
${font.medium}
|
||||
`;
|
||||
|
||||
export const Message = styled.div`
|
||||
padding: 8px 10px 0 0;
|
||||
white-space: pre-wrap;
|
||||
${font.size(14)}
|
||||
${font.medium}
|
||||
`;
|
50
react-client/src/App/Toast/index.jsx
Normal file
50
react-client/src/App/Toast/index.jsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group';
|
||||
import pubsub from 'sweet-pubsub';
|
||||
import { uniqueId } from 'lodash';
|
||||
|
||||
import { Container, StyledToast, CloseIcon, Title, Message } from './Styles';
|
||||
|
||||
const Toast = () => {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const addToast = ({ type = 'success', title, message, duration = 5 }) => {
|
||||
const id = uniqueId('toast-');
|
||||
|
||||
setToasts(currentToasts => [...currentToasts, { id, type, title, message }]);
|
||||
|
||||
if (duration) {
|
||||
setTimeout(() => removeToast(id), duration * 1000);
|
||||
}
|
||||
};
|
||||
|
||||
pubsub.on('toast', addToast);
|
||||
|
||||
return () => {
|
||||
pubsub.off('toast', addToast);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const removeToast = id => {
|
||||
setToasts(currentToasts => currentToasts.filter(toast => toast.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TransitionGroup>
|
||||
{toasts.map(toast => (
|
||||
<CSSTransition key={toast.id} classNames="jira-toast" timeout={200}>
|
||||
<StyledToast key={toast.id} type={toast.type} onClick={() => removeToast(toast.id)}>
|
||||
<CloseIcon type="close" />
|
||||
{toast.title && <Title>{toast.title}</Title>}
|
||||
{toast.message && <Message>{toast.message}</Message>}
|
||||
</StyledToast>
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
BIN
react-client/src/App/assets/fonts/CircularStd-Black.eot
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Black.eot
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/CircularStd-Black.otf
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Black.otf
Executable file
Binary file not shown.
1
react-client/src/App/assets/fonts/CircularStd-Black.svg
Executable file
1
react-client/src/App/assets/fonts/CircularStd-Black.svg
Executable file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 384 KiB |
BIN
react-client/src/App/assets/fonts/CircularStd-Black.ttf
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Black.ttf
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/CircularStd-Black.woff
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Black.woff
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/CircularStd-Black.woff2
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Black.woff2
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/CircularStd-Bold.eot
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Bold.eot
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/CircularStd-Bold.otf
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Bold.otf
Executable file
Binary file not shown.
13533
react-client/src/App/assets/fonts/CircularStd-Bold.svg
Executable file
13533
react-client/src/App/assets/fonts/CircularStd-Bold.svg
Executable file
File diff suppressed because it is too large
Load Diff
After Width: | Height: | Size: 433 KiB |
BIN
react-client/src/App/assets/fonts/CircularStd-Bold.ttf
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Bold.ttf
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/CircularStd-Bold.woff
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Bold.woff
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/CircularStd-Bold.woff2
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Bold.woff2
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/CircularStd-Book.eot
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Book.eot
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/CircularStd-Book.otf
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Book.otf
Executable file
Binary file not shown.
9962
react-client/src/App/assets/fonts/CircularStd-Book.svg
Executable file
9962
react-client/src/App/assets/fonts/CircularStd-Book.svg
Executable file
File diff suppressed because it is too large
Load Diff
After Width: | Height: | Size: 341 KiB |
BIN
react-client/src/App/assets/fonts/CircularStd-Book.ttf
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Book.ttf
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/CircularStd-Book.woff
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Book.woff
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/CircularStd-Book.woff2
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Book.woff2
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/CircularStd-Medium.eot
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Medium.eot
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/CircularStd-Medium.otf
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Medium.otf
Executable file
Binary file not shown.
13507
react-client/src/App/assets/fonts/CircularStd-Medium.svg
Executable file
13507
react-client/src/App/assets/fonts/CircularStd-Medium.svg
Executable file
File diff suppressed because it is too large
Load Diff
After Width: | Height: | Size: 432 KiB |
BIN
react-client/src/App/assets/fonts/CircularStd-Medium.ttf
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Medium.ttf
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/CircularStd-Medium.woff
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Medium.woff
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/CircularStd-Medium.woff2
Executable file
BIN
react-client/src/App/assets/fonts/CircularStd-Medium.woff2
Executable file
Binary file not shown.
42
react-client/src/App/assets/fonts/jira.svg
Executable file
42
react-client/src/App/assets/fonts/jira.svg
Executable file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 29 KiB |
BIN
react-client/src/App/assets/fonts/jira.ttf
Executable file
BIN
react-client/src/App/assets/fonts/jira.ttf
Executable file
Binary file not shown.
BIN
react-client/src/App/assets/fonts/jira.woff
Executable file
BIN
react-client/src/App/assets/fonts/jira.woff
Executable file
Binary file not shown.
35
react-client/src/App/fontStyles.css
Normal file
35
react-client/src/App/fontStyles.css
Normal file
@ -0,0 +1,35 @@
|
||||
@font-face {
|
||||
font-family: 'CircularStdBlack';
|
||||
src: url('./assets/fonts/CircularStd-Black.woff2') format('woff2'),
|
||||
url('./assets/fonts/CircularStd-Black.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularStdBold';
|
||||
src: url('./assets/fonts/CircularStd-Bold.woff2') format('woff2'),
|
||||
url('./assets/fonts/CircularStd-Bold.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularStdMedium';
|
||||
src: url('./assets/fonts/CircularStd-Medium.woff2') format('woff2'),
|
||||
url('./assets/fonts/CircularStd-Medium.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularStdBook';
|
||||
src: url('./assets/fonts/CircularStd-Book.woff2') format('woff2'),
|
||||
url('./assets/fonts/CircularStd-Book.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'jira';
|
||||
src: url('./assets/fonts/jira.woff') format('truetype'),
|
||||
url('./assets/fonts/jira.ttf') format('woff'), url('./assets/fonts/jira.svg#jira') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
22
react-client/src/App/index.jsx
Normal file
22
react-client/src/App/index.jsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
export default App;
|
31
react-client/src/Auth/Authenticate.jsx
Normal file
31
react-client/src/Auth/Authenticate.jsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useHistory } 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';
|
||||
|
||||
const Authenticate = () => {
|
||||
const history = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
const createGuestAccount = async () => {
|
||||
try {
|
||||
const { authToken } = await api.post('/authentication/guest');
|
||||
storeAuthToken(authToken);
|
||||
history.push('/');
|
||||
} catch (error) {
|
||||
toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!getStoredAuthToken()) {
|
||||
createGuestAccount();
|
||||
}
|
||||
}, [history]);
|
||||
|
||||
return <PageLoader />;
|
||||
};
|
||||
|
||||
export default Authenticate;
|
55
react-client/src/Project/Board/Filters/Styles.js
vendored
Normal file
55
react-client/src/Project/Board/Filters/Styles.js
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font, mixin } from 'shared/utils/styles';
|
||||
import { InputDebounced, Avatar, Button } from 'shared/components';
|
||||
|
||||
export const Filters = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 24px;
|
||||
`;
|
||||
|
||||
export const SearchInput = styled(InputDebounced)`
|
||||
margin-right: 18px;
|
||||
width: 160px;
|
||||
`;
|
||||
|
||||
export const Avatars = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
margin: 0 12px 0 2px;
|
||||
`;
|
||||
|
||||
export const AvatarIsActiveBorder = styled.div`
|
||||
display: inline-flex;
|
||||
margin-left: -2px;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.1s;
|
||||
${mixin.clickable};
|
||||
${props => props.isActive && `box-shadow: 0 0 0 4px ${color.primary}`}
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledAvatar = styled(Avatar)`
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
`;
|
||||
|
||||
export const StyledButton = styled(Button)`
|
||||
margin-left: 6px;
|
||||
`;
|
||||
|
||||
export const ClearAll = styled.div`
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
margin-left: 15px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid ${color.borderLightest};
|
||||
color: ${color.textDark};
|
||||
${font.size(14.5)}
|
||||
${mixin.clickable}
|
||||
&:hover {
|
||||
color: ${color.textMedium};
|
||||
}
|
||||
`;
|
68
react-client/src/Project/Board/Filters/index.jsx
Normal file
68
react-client/src/Project/Board/Filters/index.jsx
Normal file
@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { xor } from 'lodash';
|
||||
|
||||
import {
|
||||
Filters,
|
||||
SearchInput,
|
||||
Avatars,
|
||||
AvatarIsActiveBorder,
|
||||
StyledAvatar,
|
||||
StyledButton,
|
||||
ClearAll,
|
||||
} from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
projectUsers: PropTypes.array.isRequired,
|
||||
defaultFilters: PropTypes.object.isRequired,
|
||||
filters: PropTypes.object.isRequired,
|
||||
mergeFilters: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardFilters = ({ projectUsers, defaultFilters, filters, mergeFilters }) => {
|
||||
const { searchTerm, userIds, myOnly, recent } = filters;
|
||||
|
||||
const areFiltersCleared = !searchTerm && userIds.length === 0 && !myOnly && !recent;
|
||||
|
||||
return (
|
||||
<Filters data-testid="board-filters">
|
||||
<SearchInput
|
||||
icon="search"
|
||||
value={searchTerm}
|
||||
onChange={value => mergeFilters({ searchTerm: value })}
|
||||
/>
|
||||
<Avatars>
|
||||
{projectUsers.map(user => (
|
||||
<AvatarIsActiveBorder key={user.id} isActive={userIds.includes(user.id)}>
|
||||
<StyledAvatar
|
||||
avatarUrl={user.avatarUrl}
|
||||
name={user.name}
|
||||
onClick={() => mergeFilters({ userIds: xor(userIds, [user.id]) })}
|
||||
/>
|
||||
</AvatarIsActiveBorder>
|
||||
))}
|
||||
</Avatars>
|
||||
<StyledButton
|
||||
variant="empty"
|
||||
isActive={myOnly}
|
||||
onClick={() => mergeFilters({ myOnly: !myOnly })}
|
||||
>
|
||||
Only My Issues
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
variant="empty"
|
||||
isActive={recent}
|
||||
onClick={() => mergeFilters({ recent: !recent })}
|
||||
>
|
||||
Recently Updated
|
||||
</StyledButton>
|
||||
{!areFiltersCleared && (
|
||||
<ClearAll onClick={() => mergeFilters(defaultFilters)}>Clear all</ClearAll>
|
||||
)}
|
||||
</Filters>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardFilters.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardFilters;
|
14
react-client/src/Project/Board/Header/Styles.js
vendored
Normal file
14
react-client/src/Project/Board/Header/Styles.js
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { font } from 'shared/utils/styles';
|
||||
|
||||
export const Header = styled.div`
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
export const BoardName = styled.div`
|
||||
${font.size(24)}
|
||||
${font.medium}
|
||||
`;
|
16
react-client/src/Project/Board/Header/index.jsx
Normal file
16
react-client/src/Project/Board/Header/index.jsx
Normal file
@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from 'shared/components';
|
||||
|
||||
import { Header, BoardName } from './Styles';
|
||||
|
||||
const ProjectBoardHeader = () => (
|
||||
<Header>
|
||||
<BoardName>Kanban board</BoardName>
|
||||
<a href="https://github.com/oldboyxx/jira_clone" target="_blank" rel="noreferrer noopener">
|
||||
<Button icon="github">Github Repo</Button>
|
||||
</a>
|
||||
</Header>
|
||||
);
|
||||
|
||||
export default ProjectBoardHeader;
|
26
react-client/src/Project/Board/IssueDetails/AssigneesReporter/Styles.js
vendored
Normal file
26
react-client/src/Project/Board/IssueDetails/AssigneesReporter/Styles.js
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
import { color, font, mixin } from 'shared/utils/styles';
|
||||
|
||||
export const User = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
${mixin.clickable}
|
||||
${props =>
|
||||
props.isSelectValue &&
|
||||
css`
|
||||
margin: 0 10px ${props.withBottomMargin ? 5 : 0}px 0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: ${color.backgroundLight};
|
||||
transition: background 0.1s;
|
||||
&:hover {
|
||||
background: ${color.backgroundMedium};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const Username = styled.div`
|
||||
padding: 0 3px 0 8px;
|
||||
${font.size(14.5)}
|
||||
`;
|
@ -0,0 +1,71 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Avatar, Select, Icon } from 'shared/components';
|
||||
|
||||
import { SectionTitle } from '../Styles';
|
||||
import { User, Username } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
issue: PropTypes.object.isRequired,
|
||||
updateIssue: PropTypes.func.isRequired,
|
||||
projectUsers: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsAssigneesReporter = ({ issue, updateIssue, projectUsers }) => {
|
||||
const getUserById = userId => projectUsers.find(user => user.id === userId);
|
||||
|
||||
const userOptions = projectUsers.map(user => ({ value: user.id, label: user.name }));
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<SectionTitle>Assignees</SectionTitle>
|
||||
<Select
|
||||
isMulti
|
||||
variant="empty"
|
||||
dropdownWidth={343}
|
||||
placeholder="Unassigned"
|
||||
name="assignees"
|
||||
value={issue.userIds}
|
||||
options={userOptions}
|
||||
onChange={userIds => {
|
||||
updateIssue({ userIds, users: userIds.map(getUserById) });
|
||||
}}
|
||||
renderValue={({ value: userId, removeOptionValue }) =>
|
||||
renderUser(getUserById(userId), true, removeOptionValue)
|
||||
}
|
||||
renderOption={({ value: userId }) => renderUser(getUserById(userId), false)}
|
||||
/>
|
||||
|
||||
<SectionTitle>Reporter</SectionTitle>
|
||||
<Select
|
||||
variant="empty"
|
||||
dropdownWidth={343}
|
||||
withClearValue={false}
|
||||
name="reporter"
|
||||
value={issue.reporterId}
|
||||
options={userOptions}
|
||||
onChange={userId => updateIssue({ reporterId: userId })}
|
||||
renderValue={({ value: userId }) => renderUser(getUserById(userId), true)}
|
||||
renderOption={({ value: userId }) => renderUser(getUserById(userId))}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const renderUser = (user, isSelectValue, removeOptionValue) => (
|
||||
<User
|
||||
key={user.id}
|
||||
isSelectValue={isSelectValue}
|
||||
withBottomMargin={!!removeOptionValue}
|
||||
onClick={() => removeOptionValue && removeOptionValue()}
|
||||
>
|
||||
<Avatar avatarUrl={user.avatarUrl} name={user.name} size={24} />
|
||||
<Username>{user.name}</Username>
|
||||
{removeOptionValue && <Icon type="close" top={1} />}
|
||||
</User>
|
||||
);
|
||||
|
||||
ProjectBoardIssueDetailsAssigneesReporter.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsAssigneesReporter;
|
12
react-client/src/Project/Board/IssueDetails/Comments/BodyForm/Styles.js
vendored
Normal file
12
react-client/src/Project/Board/IssueDetails/Comments/BodyForm/Styles.js
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Button } from 'shared/components';
|
||||
|
||||
export const Actions = styled.div`
|
||||
display: flex;
|
||||
padding-top: 10px;
|
||||
`;
|
||||
|
||||
export const FormButton = styled(Button)`
|
||||
margin-right: 6px;
|
||||
`;
|
@ -0,0 +1,54 @@
|
||||
import React, { Fragment, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Textarea } from 'shared/components';
|
||||
|
||||
import { Actions, FormButton } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isWorking: PropTypes.bool.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsCommentsBodyForm = ({
|
||||
value,
|
||||
onChange,
|
||||
isWorking,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}) => {
|
||||
const $textareaRef = useRef();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if ($textareaRef.current.value.trim()) {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardIssueDetailsCommentsBodyForm.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsCommentsBodyForm;
|
66
react-client/src/Project/Board/IssueDetails/Comments/Comment/Styles.js
vendored
Normal file
66
react-client/src/Project/Board/IssueDetails/Comments/Comment/Styles.js
vendored
Normal file
@ -0,0 +1,66 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
import { color, font, mixin } from 'shared/utils/styles';
|
||||
import { Avatar } from 'shared/components';
|
||||
|
||||
export const Comment = styled.div`
|
||||
position: relative;
|
||||
margin-top: 25px;
|
||||
${font.size(15)}
|
||||
`;
|
||||
|
||||
export const UserAvatar = styled(Avatar)`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
`;
|
||||
|
||||
export const Content = styled.div`
|
||||
padding-left: 44px;
|
||||
`;
|
||||
|
||||
export const Username = styled.div`
|
||||
display: inline-block;
|
||||
padding-right: 12px;
|
||||
padding-bottom: 10px;
|
||||
color: ${color.textDark};
|
||||
${font.medium}
|
||||
`;
|
||||
|
||||
export const CreatedAt = styled.div`
|
||||
display: inline-block;
|
||||
padding-bottom: 10px;
|
||||
color: ${color.textDark};
|
||||
${font.size(14.5)}
|
||||
`;
|
||||
|
||||
export const Body = styled.p`
|
||||
padding-bottom: 10px;
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
const actionLinkStyles = css`
|
||||
display: inline-block;
|
||||
padding: 2px 0;
|
||||
color: ${color.textMedium};
|
||||
${font.size(14.5)}
|
||||
${mixin.clickable}
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
export const EditLink = styled.div`
|
||||
margin-right: 12px;
|
||||
${actionLinkStyles}
|
||||
`;
|
||||
|
||||
export const DeleteLink = styled.div`
|
||||
${actionLinkStyles}
|
||||
&:before {
|
||||
position: relative;
|
||||
right: 6px;
|
||||
content: '·';
|
||||
display: inline-block;
|
||||
}
|
||||
`;
|
@ -0,0 +1,87 @@
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import api from 'shared/utils/api';
|
||||
import toast from 'shared/utils/toast';
|
||||
import { formatDateTimeConversational } from 'shared/utils/dateTime';
|
||||
import { ConfirmModal } from 'shared/components';
|
||||
|
||||
import BodyForm from '../BodyForm';
|
||||
import {
|
||||
Comment,
|
||||
UserAvatar,
|
||||
Content,
|
||||
Username,
|
||||
CreatedAt,
|
||||
Body,
|
||||
EditLink,
|
||||
DeleteLink,
|
||||
} from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
comment: PropTypes.object.isRequired,
|
||||
fetchIssue: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsComment = ({ comment, fetchIssue }) => {
|
||||
const [isFormOpen, setFormOpen] = useState(false);
|
||||
const [isUpdating, setUpdating] = useState(false);
|
||||
const [body, setBody] = useState(comment.body);
|
||||
|
||||
const handleCommentDelete = async () => {
|
||||
try {
|
||||
await api.delete(`/comments/${comment.id}`);
|
||||
await fetchIssue();
|
||||
} catch (error) {
|
||||
toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommentUpdate = async () => {
|
||||
try {
|
||||
setUpdating(true);
|
||||
await api.put(`/comments/${comment.id}`, { body });
|
||||
await fetchIssue();
|
||||
setUpdating(false);
|
||||
setFormOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Comment data-testid="issue-comment">
|
||||
<UserAvatar name={comment.user.name} avatarUrl={comment.user.avatarUrl} />
|
||||
<Content>
|
||||
<Username>{comment.user.name}</Username>
|
||||
<CreatedAt>{formatDateTimeConversational(comment.createdAt)}</CreatedAt>
|
||||
|
||||
{isFormOpen ? (
|
||||
<BodyForm
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
isWorking={isUpdating}
|
||||
onSubmit={handleCommentUpdate}
|
||||
onCancel={() => setFormOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<Fragment>
|
||||
<Body>{comment.body}</Body>
|
||||
<EditLink onClick={() => setFormOpen(true)}>Edit</EditLink>
|
||||
<ConfirmModal
|
||||
title="Are you sure you want to delete this comment?"
|
||||
message="Once you delete, it's gone for good."
|
||||
confirmText="Delete comment"
|
||||
onConfirm={handleCommentDelete}
|
||||
renderLink={modal => <DeleteLink onClick={modal.open}>Delete</DeleteLink>}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
</Content>
|
||||
</Comment>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardIssueDetailsComment.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsComment;
|
27
react-client/src/Project/Board/IssueDetails/Comments/Create/ProTip/Styles.js
vendored
Normal file
27
react-client/src/Project/Board/IssueDetails/Comments/Create/ProTip/Styles.js
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font } from 'shared/utils/styles';
|
||||
|
||||
export const Tip = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
color: ${color.textMedium};
|
||||
${font.size(13)}
|
||||
strong {
|
||||
padding-right: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TipLetter = styled.span`
|
||||
position: relative;
|
||||
top: 1px;
|
||||
display: inline-block;
|
||||
margin: 0 4px;
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
color: ${color.textDarkest};
|
||||
background: ${color.backgroundMedium};
|
||||
${font.bold}
|
||||
${font.size(12)}
|
||||
`;
|
@ -0,0 +1,38 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { KeyCodes } from 'shared/constants/keyCodes';
|
||||
import { isFocusedElementEditable } from 'shared/utils/browser';
|
||||
|
||||
import { Tip, TipLetter } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
setFormOpen: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsCommentsCreateProTip = ({ setFormOpen }) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
if (!isFocusedElementEditable() && event.keyCode === KeyCodes.M) {
|
||||
event.preventDefault();
|
||||
setFormOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [setFormOpen]);
|
||||
|
||||
return (
|
||||
<Tip>
|
||||
<strong>Pro tip:</strong>press<TipLetter>M</TipLetter>to comment
|
||||
</Tip>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardIssueDetailsCommentsCreateProTip.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsCommentsCreateProTip;
|
31
react-client/src/Project/Board/IssueDetails/Comments/Create/Styles.js
vendored
Normal file
31
react-client/src/Project/Board/IssueDetails/Comments/Create/Styles.js
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font, mixin } from 'shared/utils/styles';
|
||||
import { Avatar } from 'shared/components';
|
||||
|
||||
export const Create = styled.div`
|
||||
position: relative;
|
||||
margin-top: 25px;
|
||||
${font.size(15)}
|
||||
`;
|
||||
|
||||
export const UserAvatar = styled(Avatar)`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
`;
|
||||
|
||||
export const Right = styled.div`
|
||||
padding-left: 44px;
|
||||
`;
|
||||
|
||||
export const FakeTextarea = styled.div`
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${color.borderLightest};
|
||||
color: ${color.textLight};
|
||||
${mixin.clickable}
|
||||
&:hover {
|
||||
border: 1px solid ${color.borderLight};
|
||||
}
|
||||
`;
|
@ -0,0 +1,62 @@
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import api from 'shared/utils/api';
|
||||
import useCurrentUser from 'shared/hooks/currentUser';
|
||||
import toast from 'shared/utils/toast';
|
||||
|
||||
import BodyForm from '../BodyForm';
|
||||
import ProTip from './ProTip';
|
||||
import { Create, UserAvatar, Right, FakeTextarea } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
issueId: PropTypes.number.isRequired,
|
||||
fetchIssue: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsCommentsCreate = ({ issueId, fetchIssue }) => {
|
||||
const [isFormOpen, setFormOpen] = useState(false);
|
||||
const [isCreating, setCreating] = useState(false);
|
||||
const [body, setBody] = useState('');
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardIssueDetailsCommentsCreate.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsCommentsCreate;
|
12
react-client/src/Project/Board/IssueDetails/Comments/Styles.js
vendored
Normal file
12
react-client/src/Project/Board/IssueDetails/Comments/Styles.js
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { font } from 'shared/utils/styles';
|
||||
|
||||
export const Comments = styled.div`
|
||||
padding-top: 40px;
|
||||
`;
|
||||
|
||||
export const Title = styled.div`
|
||||
${font.medium}
|
||||
${font.size(15)}
|
||||
`;
|
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { sortByNewest } from 'shared/utils/javascript';
|
||||
|
||||
import Create from './Create';
|
||||
import Comment from './Comment';
|
||||
import { Comments, Title } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
issue: PropTypes.object.isRequired,
|
||||
fetchIssue: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsComments = ({ issue, fetchIssue }) => (
|
||||
<Comments>
|
||||
<Title>Comments</Title>
|
||||
<Create issueId={issue.id} fetchIssue={fetchIssue} />
|
||||
|
||||
{sortByNewest(issue.comments, 'createdAt').map(comment => (
|
||||
<Comment key={comment.id} comment={comment} fetchIssue={fetchIssue} />
|
||||
))}
|
||||
</Comments>
|
||||
);
|
||||
|
||||
ProjectBoardIssueDetailsComments.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsComments;
|
12
react-client/src/Project/Board/IssueDetails/Dates/Styles.js
vendored
Normal file
12
react-client/src/Project/Board/IssueDetails/Dates/Styles.js
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font } from 'shared/utils/styles';
|
||||
|
||||
export const Dates = styled.div`
|
||||
margin-top: 11px;
|
||||
padding-top: 13px;
|
||||
line-height: 22px;
|
||||
border-top: 1px solid ${color.borderLightest};
|
||||
color: ${color.textMedium};
|
||||
${font.size(13)}
|
||||
`;
|
21
react-client/src/Project/Board/IssueDetails/Dates/index.jsx
Normal file
21
react-client/src/Project/Board/IssueDetails/Dates/index.jsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { formatDateTimeConversational } from 'shared/utils/dateTime';
|
||||
|
||||
import { Dates } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
issue: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsDates = ({ issue }) => (
|
||||
<Dates>
|
||||
<div>Created at {formatDateTimeConversational(issue.createdAt)}</div>
|
||||
<div>Updated at {formatDateTimeConversational(issue.updatedAt)}</div>
|
||||
</Dates>
|
||||
);
|
||||
|
||||
ProjectBoardIssueDetailsDates.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsDates;
|
40
react-client/src/Project/Board/IssueDetails/Delete.jsx
Normal file
40
react-client/src/Project/Board/IssueDetails/Delete.jsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import api from 'shared/utils/api';
|
||||
import toast from 'shared/utils/toast';
|
||||
import { Button, ConfirmModal } from 'shared/components';
|
||||
|
||||
const propTypes = {
|
||||
issue: PropTypes.object.isRequired,
|
||||
fetchProject: PropTypes.func.isRequired,
|
||||
modalClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsDelete = ({ issue, fetchProject, modalClose }) => {
|
||||
const handleIssueDelete = async () => {
|
||||
try {
|
||||
await api.delete(`/issues/${issue.id}`);
|
||||
await fetchProject();
|
||||
modalClose();
|
||||
} catch (error) {
|
||||
toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title="Are you sure you want to delete this issue?"
|
||||
message="Once you delete, it's gone for good."
|
||||
confirmText="Delete issue"
|
||||
onConfirm={handleIssueDelete}
|
||||
renderLink={modal => (
|
||||
<Button icon="trash" iconSize={19} variant="empty" onClick={modal.open} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardIssueDetailsDelete.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsDelete;
|
30
react-client/src/Project/Board/IssueDetails/Description/Styles.js
vendored
Normal file
30
react-client/src/Project/Board/IssueDetails/Description/Styles.js
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font, mixin } from 'shared/utils/styles';
|
||||
|
||||
export const Title = styled.div`
|
||||
padding: 20px 0 6px;
|
||||
${font.size(15)}
|
||||
${font.medium}
|
||||
`;
|
||||
|
||||
export const EmptyLabel = styled.div`
|
||||
margin-left: -7px;
|
||||
padding: 7px;
|
||||
border-radius: 3px;
|
||||
color: ${color.textMedium}
|
||||
transition: background 0.1s;
|
||||
${font.size(15)}
|
||||
${mixin.clickable}
|
||||
&:hover {
|
||||
background: ${color.backgroundLight};
|
||||
}
|
||||
`;
|
||||
|
||||
export const Actions = styled.div`
|
||||
display: flex;
|
||||
padding-top: 12px;
|
||||
& > button {
|
||||
margin-right: 6px;
|
||||
}
|
||||
`;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user