Display project, show and edit issue

This commit is contained in:
Adrian Wozniak 2020-03-28 21:41:16 +01:00
parent f802d0c4e5
commit e388d89494
214 changed files with 68663 additions and 36 deletions

11
Cargo.lock generated
View File

@ -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",

View File

@ -2,6 +2,7 @@
members = [
"./jirs-cli",
"./jirs-server",
"./jirs-client"
"./jirs-client",
"./jirs-data",
]

17
jirs-data/Cargo.toml Normal file
View 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
View 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>>,
}

View File

@ -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 = "*" }

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS issue_assignees CASCADE;

View File

@ -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()
);

View File

@ -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;

View File

@ -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)
}
}

View 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)
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}
}

View File

@ -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);

View File

@ -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";

View File

@ -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

View File

@ -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,

View File

@ -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)
}

View File

@ -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("")
}

View File

@ -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
View 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 }]
]
}

View 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
View File

@ -0,0 +1 @@
node_modules

5
react-client/.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all"
}

11
react-client/.swcrc Normal file
View File

@ -0,0 +1,11 @@
{
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": true,
"decorators": true,
"dynamicImport": true
}
}
}

18
react-client/README.md Normal file
View 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. |

View File

@ -0,0 +1,8 @@
{
"baseUrl": "http://localhost:8080",
"viewportHeight": 800,
"viewportWidth": 1440,
"env": {
"apiBaseUrl": "http://localhost:3000"
}
}

View File

@ -0,0 +1,6 @@
{
"extends": ["plugin:cypress/recommended"],
"rules": {
"no-unused-expressions": 0 // chai assertions trigger this rule
}
}

View 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);
});
});

View 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`);
});
});

View 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);
});

View 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);
});

View 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/"]');
});

View 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 });
});
};
});

View 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
View 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 }));
};

View 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
View 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
View File

@ -0,0 +1,4 @@
export const testid = (strings, ...values) => {
const id = strings.map((str, index) => str + (values[index] || '')).join('');
return `[data-testid="${id}"]`;
};

View 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
View File

@ -0,0 +1 @@
module.exports = 'test-file-stub';

1
react-client/jest/styleMock.js vendored Normal file
View File

@ -0,0 +1 @@
module.exports = {};

View 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

File diff suppressed because it is too large Load Diff

82
react-client/package.json Normal file
View 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
View 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
View 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
View 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;
}
`;

View 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
View 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}
`;

View 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;

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Binary file not shown.

View 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;
}

View 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;

View 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;

View 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};
}
`;

View 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;

View 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}
`;

View 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;

View 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)}
`;

View File

@ -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;

View 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;
`;

View File

@ -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;

View 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;
}
`;

View File

@ -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;

View 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)}
`;

View File

@ -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;

View 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};
}
`;

View File

@ -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;

View 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)}
`;

View File

@ -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;

View 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)}
`;

View 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;

View 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;

View 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