Add drag&drop

This commit is contained in:
Adrian Wozniak 2020-03-31 22:05:18 +02:00
parent 8ee9cc8957
commit d272b6a1dc
12 changed files with 444 additions and 210 deletions

View File

@ -27,5 +27,9 @@ futures = "^0.1.26"
[dependencies.web-sys] [dependencies.web-sys]
version = "*" version = "*"
features = [ features = [
"Window" "Window",
"DataTransfer",
"DragEvent",
"HtmlDivElement",
"DomRect"
] ]

View File

@ -87,6 +87,7 @@
#projectPage > #projectBoardLists { #projectPage > #projectBoardLists {
display: flex; display: flex;
margin: 26px -5px 0; margin: 26px -5px 0;
position: relative;
} }
#projectPage > #projectBoardLists > .list { #projectPage > #projectBoardLists > .list {
@ -134,9 +135,18 @@
user-select: none; user-select: none;
} }
#projectPage > #projectBoardLists > .list > .issues > .issueLink > .issue.hidden {
display: none;
}
#projectPage > #projectBoardLists > .list > .issues > .issueLink > .issue.isBeingDragged { #projectPage > #projectBoardLists > .list > .issues > .issueLink > .issue.isBeingDragged {
transform: rotate(3deg); transform: rotate(3deg);
box-shadow: 5px 10px 30px 0 rgba(9, 30, 66, 0.15); /*box-shadow: 5px 10px 30px 0 rgba(9, 30, 66, 0.15);*/
position: absolute;
top: 0;
left: 0;
z-index: 2;
width: 90px;
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {

View File

@ -1,3 +1,5 @@
use jirs_data::UpdateIssuePayload;
use crate::shared::host_client; use crate::shared::host_client;
use crate::Msg; use crate::Msg;
@ -14,3 +16,19 @@ pub async fn fetch_current_user(host_url: String) -> Result<Msg, Msg> {
Err(e) => Err(Msg::InternalFailure(e)), Err(e) => Err(Msg::InternalFailure(e)),
} }
} }
pub async fn update_issue(
host_url: String,
id: i32,
payload: UpdateIssuePayload,
) -> Result<Msg, Msg> {
match host_client(host_url, format!("/issue/{id}", id = id).as_str()) {
Ok(client) => {
client
.body_json(&payload)
.fetch_json(Msg::IssueUpdateResult)
.await
}
Err(e) => return Ok(Msg::InternalFailure(e)),
}
}

View File

@ -0,0 +1,84 @@
use seed::fetch::{FetchObject, ResponseWithDataResult};
use jirs_data::{FullProjectResponse, Issue};
use crate::model::Model;
pub fn current_user_response(fetched: &FetchObject<String>, model: &mut Model) {
if let FetchObject {
result:
Ok(ResponseWithDataResult {
data: Ok(body),
status,
..
}),
..
} = fetched
{
if status.is_error() {
return;
}
match serde_json::from_str::<'_, jirs_data::User>(body.as_str()) {
Ok(user) => {
model.user = Some(user);
}
_ => (),
}
}
}
pub fn current_project_response(fetched: &FetchObject<String>, model: &mut Model) {
if let FetchObject {
result:
Ok(ResponseWithDataResult {
data: Ok(body),
status,
..
}),
..
} = fetched
{
if status.is_error() {
return;
}
match serde_json::from_str::<'_, FullProjectResponse>(body.as_str()) {
Ok(project_response) => {
model.project = Some(project_response.project);
}
_ => (),
}
}
}
pub fn update_issue_response(fetched: &FetchObject<String>, model: &mut Model) {
if let FetchObject {
result:
Ok(ResponseWithDataResult {
data: Ok(body),
status,
..
}),
..
} = fetched
{
if status.is_error() {
return;
}
match (
serde_json::from_str::<'_, Issue>(body.as_str()),
model.project.as_mut(),
) {
(Ok(issue), Some(project)) => {
let mut issues: Vec<Issue> = vec![];
for i in project.issues.iter() {
if i.id != issue.id {
issues.push(i.clone());
}
}
issues.push(issue);
project.issues = issues;
}
_ => (),
}
}
}

View File

@ -1,9 +1,12 @@
use seed::fetch::FetchObject; use seed::fetch::FetchObject;
use seed::{prelude::*, *}; use seed::{prelude::*, *};
use jirs_data::IssueStatus;
use crate::model::Page; use crate::model::Page;
mod api; mod api;
mod api_handlers;
mod login; mod login;
mod model; mod model;
mod project; mod project;
@ -12,10 +15,12 @@ mod register;
mod shared; mod shared;
pub type UserId = i32; pub type UserId = i32;
pub type IssueId = i32;
pub type AvatarFilterActive = bool; pub type AvatarFilterActive = bool;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum Msg { pub enum Msg {
NoOp,
ChangePage(model::Page), ChangePage(model::Page),
CurrentProjectResult(FetchObject<String>), CurrentProjectResult(FetchObject<String>),
CurrentUserResult(FetchObject<String>), CurrentUserResult(FetchObject<String>),
@ -28,6 +33,15 @@ pub enum Msg {
ProjectToggleOnlyMy, ProjectToggleOnlyMy,
ProjectToggleRecentlyUpdated, ProjectToggleRecentlyUpdated,
ProjectClearFilters, ProjectClearFilters,
// dragging
IssueDragStarted(IssueId),
IssueDragStopped(IssueId),
IssueDragOver(f64, f64),
IssueDropZone(IssueStatus),
// issues
IssueUpdateResult(FetchObject<String>),
} }
fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) { fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
@ -48,7 +62,7 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
Page::Register => register::update(msg, model, orders), Page::Register => register::update(msg, model, orders),
} }
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
log!(model); // debug!(model);
} }
} }

View File

@ -5,7 +5,7 @@ use uuid::Uuid;
use jirs_data::*; use jirs_data::*;
use crate::HOST_URL; use crate::{IssueId, UserId, HOST_URL};
pub type ProjectId = i32; pub type ProjectId = i32;
pub type StatusCode = u32; pub type StatusCode = u32;
@ -47,13 +47,21 @@ pub struct UpdateProjectForm {
pub fields: UpdateProjectPayload, pub fields: UpdateProjectPayload,
} }
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct ProjectPage { pub struct ProjectPage {
pub about_tooltip_visible: bool, pub about_tooltip_visible: bool,
pub text_filter: String, pub text_filter: String,
pub active_avatar_filters: Vec<i32>, pub active_avatar_filters: Vec<UserId>,
pub only_my_filter: bool, pub only_my_filter: bool,
pub recenlty_updated_filter: bool, pub recently_updated_filter: bool,
pub dragged_issue_id: Option<IssueId>,
pub drag_point: Point,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
@ -90,7 +98,9 @@ impl Default for Model {
text_filter: "".to_string(), text_filter: "".to_string(),
active_avatar_filters: vec![], active_avatar_filters: vec![],
only_my_filter: false, only_my_filter: false,
recenlty_updated_filter: false, recently_updated_filter: false,
dragged_issue_id: None,
drag_point: Point::default(),
}, },
} }
} }

View File

@ -1,12 +1,13 @@
use seed::{prelude::*, *}; use seed::{prelude::*, *};
use jirs_data::{FullProject, Issue, IssuePriority, IssueType, UpdateIssuePayload};
use crate::model::{Icon, Model, Page}; use crate::model::{Icon, Model, Page};
use crate::shared::styled_avatar::StyledAvatar; use crate::shared::styled_avatar::StyledAvatar;
use crate::shared::styled_button::{StyledButton, Variant}; use crate::shared::styled_button::{StyledButton, Variant};
use crate::shared::styled_input::StyledInput; use crate::shared::styled_input::StyledInput;
use crate::shared::{host_client, inner_layout, ToNode}; use crate::shared::{drag_ev, host_client, inner_layout, ToNode};
use crate::Msg; use crate::Msg;
use jirs_data::{FullProject, Issue, IssuePriority, IssueStatus, IssueType};
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) { pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
match msg { match msg {
@ -42,15 +43,73 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order
model.project_page.only_my_filter = !model.project_page.only_my_filter; model.project_page.only_my_filter = !model.project_page.only_my_filter;
} }
Msg::ProjectToggleRecentlyUpdated => { Msg::ProjectToggleRecentlyUpdated => {
model.project_page.recenlty_updated_filter = model.project_page.recently_updated_filter =
!model.project_page.recenlty_updated_filter; !model.project_page.recently_updated_filter;
} }
Msg::ProjectClearFilters => { Msg::ProjectClearFilters => {
let pp = &mut model.project_page; let pp = &mut model.project_page;
pp.active_avatar_filters = vec![]; pp.active_avatar_filters = vec![];
pp.recenlty_updated_filter = false; pp.recently_updated_filter = false;
pp.only_my_filter = false; pp.only_my_filter = false;
} }
Msg::IssueDragStarted(issue_id) => {
model.project_page.dragged_issue_id = Some(issue_id);
}
Msg::IssueDragStopped(_) => {
model.project_page.dragged_issue_id = None;
}
Msg::IssueDragOver(x, y) => {
model.project_page.drag_point.x = x;
model.project_page.drag_point.y = y;
}
Msg::IssueDropZone(status) => {
match (
model.project_page.dragged_issue_id.as_ref().cloned(),
model.project.as_mut(),
) {
(Some(issue_id), Some(project)) => {
let mut position = 0f64;
let mut found: Option<&mut Issue> = None;
for issue in project.issues.iter_mut() {
if issue.status == status.to_payload() {
position += 1f64;
}
if issue.id == issue_id {
found = Some(issue);
break;
}
}
if let Some(issue) = found {
issue.status = status.to_payload().to_string();
issue.list_position = position + 1f64;
let payload = UpdateIssuePayload {
title: None,
issue_type: None,
status: Some(status.to_payload().to_string()),
priority: None,
list_position: Some(position + 1f64),
description: None,
description_text: None,
estimate: None,
time_spent: None,
time_remaining: None,
project_id: None,
users: None,
user_ids: None,
};
orders.skip().perform_cmd(crate::api::update_issue(
model.host_url.clone(),
issue.id,
payload,
));
}
}
_ => error!("Drag stopped before drop :("),
}
}
Msg::IssueUpdateResult(fetched) => {
crate::api_handlers::update_issue_response(&fetched, model);
}
_ => (), _ => (),
} }
} }
@ -129,7 +188,7 @@ fn project_board_filters(model: &Model) -> Node<Msg> {
variant: Variant::Empty, variant: Variant::Empty,
icon_only: false, icon_only: false,
disabled: false, disabled: false,
active: model.project_page.recenlty_updated_filter, active: model.project_page.recently_updated_filter,
text: Some("Recently Updated".to_string()), text: Some("Recently Updated".to_string()),
icon: None, icon: None,
on_click: Some(mouse_ev(Ev::Click, |_| Msg::ProjectToggleRecentlyUpdated)), on_click: Some(mouse_ev(Ev::Click, |_| Msg::ProjectToggleRecentlyUpdated)),
@ -137,7 +196,7 @@ fn project_board_filters(model: &Model) -> Node<Msg> {
.into_node(); .into_node();
let clear_all = match project_page.only_my_filter let clear_all = match project_page.only_my_filter
|| project_page.recenlty_updated_filter || project_page.recently_updated_filter
|| !project_page.active_avatar_filters.is_empty() || !project_page.active_avatar_filters.is_empty()
{ {
true => button![ true => button![
@ -214,18 +273,34 @@ fn project_issue_list(model: &Model, status: jirs_data::IssueStatus) -> Node<Msg
.map(|issue| project_issue(model, project, issue)) .map(|issue| project_issue(model, project, issue))
.collect(); .collect();
let label = status.to_label(); let label = status.to_label();
let send_status = status.clone();
let drop_handler = drag_ev(Ev::Drop, move |ev| {
ev.prevent_default();
Msg::IssueDropZone(send_status)
});
let drag_over_handler = drag_ev(Ev::DragOver, move |ev| {
ev.prevent_default();
Msg::NoOp
});
div![ div![
attrs![At::Class => "list"], attrs![At::Class => "list";],
div![ div![
attrs![At::Class => "title"], attrs![At::Class => "title"],
label, label,
div![attrs![At::Class => "issuesCount"]] div![attrs![At::Class => "issuesCount"]]
], ],
div![attrs![At::Class => "issues"], issues] div![
attrs![At::Class => "issues"; At::DropZone => "link"],
drop_handler,
drag_over_handler,
issues
]
] ]
} }
fn project_issue(_model: &Model, project: &FullProject, issue: &Issue) -> Node<Msg> { fn project_issue(model: &Model, project: &FullProject, issue: &Issue) -> Node<Msg> {
let avatars: Vec<Node<Msg>> = project let avatars: Vec<Node<Msg>> = project
.users .users
.iter() .iter()
@ -240,6 +315,7 @@ fn project_issue(_model: &Model, project: &FullProject, issue: &Issue) -> Node<M
.into_node() .into_node()
}) })
.collect(); .collect();
let mut issue_type_icon = match issue.issue_type.parse::<IssueType>() { let mut issue_type_icon = match issue.issue_type.parse::<IssueType>() {
Ok(icon) => { Ok(icon) => {
let mut node = crate::shared::styled_icon(icon.into()); let mut node = crate::shared::styled_icon(icon.into());
@ -252,16 +328,33 @@ fn project_issue(_model: &Model, project: &FullProject, issue: &Issue) -> Node<M
Err(e) => span![format!("{}", e)], Err(e) => span![format!("{}", e)],
}; };
let priority_icon = match issue.priority.parse::<IssuePriority>() { let priority_icon = match issue.priority.parse::<IssuePriority>() {
Ok(IssuePriority::Low) | Ok(IssuePriority::Lowest) => { Ok(p) => {
crate::shared::styled_icon(Icon::ArrowDown) let icon = match p {
IssuePriority::Low | IssuePriority::Lowest => Icon::ArrowDown,
_ => Icon::ArrowUp,
};
let mut node = crate::shared::styled_icon(icon);
node.add_style(St::Color, format!("var(--{})", p.to_lower_name()));
node
} }
Ok(_) => crate::shared::styled_icon(Icon::ArrowUp),
Err(e) => span![e.clone()], Err(e) => span![e.clone()],
}; };
let issue_id = issue.id;
let drag_started = drag_ev(Ev::DragStart, move |event| Msg::IssueDragStarted(issue_id));
let drag_stopped = drag_ev(Ev::DragEnd, move |_| Msg::IssueDragStopped(issue_id));
let mut class_list = vec!["issue"];
if Some(issue_id) == model.project_page.dragged_issue_id {
class_list.push("hidden");
}
a![ a![
attrs![At::Class => "issueLink"], attrs![At::Class => "issueLink"],
div![ div![
attrs![At::Class => "issue"], attrs![At::Class => class_list.join(" "), At::Draggable => true],
drag_started,
drag_stopped,
p![attrs![At::Class => "title"], issue.title,], p![attrs![At::Class => "title"], issue.title,],
div![ div![
attrs![At::Class => "bottom"], attrs![At::Class => "bottom"],

View File

@ -1,5 +1,6 @@
use seed::fetch::{FetchObject, ResponseWithDataResult}; use seed::fetch::{FetchObject, ResponseWithDataResult};
use seed::{prelude::*, *}; use seed::{prelude::*, *};
use wasm_bindgen::JsCast;
use jirs_data::FullProjectResponse; use jirs_data::FullProjectResponse;
@ -51,34 +52,22 @@ pub fn host_client(host_url: String, path: &str) -> Result<Request, String> {
pub fn update(msg: &Msg, model: &mut crate::model::Model, _orders: &mut impl Orders<Msg>) { pub fn update(msg: &Msg, model: &mut crate::model::Model, _orders: &mut impl Orders<Msg>) {
match msg { match msg {
Msg::CurrentProjectResult(FetchObject { Msg::CurrentProjectResult(fetched) => {
result: crate::api_handlers::current_project_response(fetched, model);
Ok(ResponseWithDataResult { }
data: Ok(body), Msg::CurrentUserResult(fetched) => {
status, crate::api_handlers::current_user_response(fetched, model);
.. }
}),
..
}) if status.is_ok() => match serde_json::from_str::<'_, FullProjectResponse>(body) {
Ok(project_response) => {
model.project = Some(project_response.project);
}
_ => (),
},
Msg::CurrentUserResult(FetchObject {
result:
Ok(ResponseWithDataResult {
data: Ok(body),
status,
..
}),
..
}) if status.is_ok() => match serde_json::from_str::<'_, jirs_data::User>(body) {
Ok(user) => {
model.user = Some(user);
}
_ => (),
},
_ => (), _ => (),
} }
} }
pub fn drag_ev<Ms>(
trigger: impl Into<Ev>,
handler: impl FnOnce(web_sys::DragEvent) -> Ms + 'static + Clone,
) -> EventHandler<Ms> {
let closure_handler = move |event: web_sys::Event| {
(handler.clone())(event.dyn_ref::<web_sys::DragEvent>().unwrap().clone())
};
EventHandler::new(trigger, closure_handler)
}

View File

@ -1,6 +1,7 @@
use std::str::FromStr;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::str::FromStr;
use uuid::Uuid; use uuid::Uuid;
pub trait ResponseData { pub trait ResponseData {
@ -96,12 +97,12 @@ impl FromStr for IssuePriority {
type Err = String; type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() { match s.to_lowercase().trim() {
"highest" => Ok(IssuePriority::Highest), "5" | "highest" => Ok(IssuePriority::Highest),
"high" => Ok(IssuePriority::High), "4" | "high" => Ok(IssuePriority::High),
"medium" => Ok(IssuePriority::Medium), "3" | "medium" => Ok(IssuePriority::Medium),
"low" => Ok(IssuePriority::Low), "2" | "low" => Ok(IssuePriority::Low),
"lowest" => Ok(IssuePriority::Lowest), "1" | "lowest" => Ok(IssuePriority::Lowest),
_ => Err(format!("Unknown priority {}", s)), _ => Err(format!("Unknown priority {}", s)),
} }
} }
@ -118,6 +119,16 @@ impl IssuePriority {
} }
} }
pub fn to_lower_name(&self) -> &str {
match self {
IssuePriority::Highest => "highest",
IssuePriority::High => "high",
IssuePriority::Medium => "medium",
IssuePriority::Low => "low",
IssuePriority::Lowest => "lowest",
}
}
pub fn to_value(&self) -> i32 { pub fn to_value(&self) -> i32 {
match self { match self {
IssuePriority::Highest => 5, IssuePriority::Highest => 5,

View File

@ -3,54 +3,55 @@ import PropTypes from 'prop-types';
import { useRouteMatch } from 'react-router-dom'; import { useRouteMatch } from 'react-router-dom';
import { Draggable } from 'react-beautiful-dnd'; import { Draggable } from 'react-beautiful-dnd';
import { IssueTypeIcon, IssuePriorityIcon } from '../../../../../shared/components'; import { IssuePriorityIcon, IssueTypeIcon } from '../../../../../shared/components';
import { IssueLink, Issue, Title, Bottom, Assignees, AssigneeAvatar } from './Styles'; import { AssigneeAvatar, Assignees, Bottom, Issue, IssueLink, Title } from './Styles';
const propTypes = { const propTypes = {
projectUsers: PropTypes.array.isRequired, projectUsers: PropTypes.array.isRequired,
issue: PropTypes.object.isRequired, issue: PropTypes.object.isRequired,
index: PropTypes.number.isRequired, index: PropTypes.number.isRequired,
}; };
const ProjectBoardListIssue = ({ projectUsers, issue, index }) => { const ProjectBoardListIssue = ({ projectUsers, issue, index }) => {
const match = useRouteMatch(); const match = useRouteMatch();
const assignees = issue.userIds.map(userId => projectUsers.find(user => user.id === userId)); const assignees = issue.userIds.map(userId => projectUsers.find(user => user.id === userId));
return ( return (
<Draggable draggableId={issue.id.toString()} index={index}> <Draggable draggableId={ issue.id.toString() } index={ index }>
{(provided, snapshot) => ( { (provided, snapshot) => (
<IssueLink <IssueLink
to={`${match.url}/issues/${issue.id}`} data-id="IssueLink"
ref={provided.innerRef} to={ `${ match.url }/issues/${ issue.id }` }
data-testid="list-issue" ref={ provided.innerRef }
{...provided.draggableProps} data-testid="list-issue"
{...provided.dragHandleProps} { ...provided.draggableProps }
> { ...provided.dragHandleProps }
<Issue isBeingDragged={snapshot.isDragging && !snapshot.isDropAnimating}> >
<Title>{issue.title}</Title> <Issue data-id='Issue' isBeingDragged={ snapshot.isDragging && !snapshot.isDropAnimating }>
<Bottom> <Title>{ issue.title }</Title>
<div> <Bottom>
<IssueTypeIcon type={issue.type} /> <div>
<IssuePriorityIcon priority={issue.priority} top={-1} left={4} /> <IssueTypeIcon type={ issue.type }/>
</div> <IssuePriorityIcon priority={ issue.priority } top={ -1 } left={ 4 }/>
<Assignees> </div>
{assignees.map(user => ( <Assignees>
<AssigneeAvatar { assignees.map(user => (
key={user.id} <AssigneeAvatar
size={24} key={ user.id }
avatarUrl={user.avatarUrl} size={ 24 }
name={user.name} avatarUrl={ user.avatarUrl }
/> name={ user.name }
))} />
</Assignees> )) }
</Bottom> </Assignees>
</Issue> </Bottom>
</IssueLink> </Issue>
)} </IssueLink>
</Draggable> ) }
); </Draggable>
);
}; };
ProjectBoardListIssue.propTypes = propTypes; ProjectBoardListIssue.propTypes = propTypes;

View File

@ -7,74 +7,75 @@ import { intersection } from 'lodash';
import { IssueStatusCopy } from 'shared/constants/issues'; import { IssueStatusCopy } from 'shared/constants/issues';
import Issue from './Issue'; import Issue from './Issue';
import { List, Title, IssuesCount, Issues } from './Styles'; import { Issues, IssuesCount, List, Title } from './Styles';
const ProjectBoardList = ({ status, project, filters, currentUserId }) => { const ProjectBoardList = ({ status, project, filters, currentUserId }) => {
const filteredIssues = filterIssues(project.issues, filters, currentUserId); const filteredIssues = filterIssues(project.issues, filters, currentUserId);
const filteredListIssues = getSortedListIssues(filteredIssues, status); const filteredListIssues = getSortedListIssues(filteredIssues, status);
const allListIssues = getSortedListIssues(project.issues, status); const allListIssues = getSortedListIssues(project.issues, status);
return ( return (
<Droppable key={status} droppableId={status}> <Droppable key={ status } droppableId={ status }>
{provided => ( { provided => (
<List> <List data-id='List'>
<Title> <Title data-id="Title">
{`${IssueStatusCopy[status]} `} { `${ IssueStatusCopy[status] } ` }
<IssuesCount>{formatIssuesCount(allListIssues, filteredListIssues)}</IssuesCount> <IssuesCount>{ formatIssuesCount(allListIssues, filteredListIssues) }</IssuesCount>
</Title> </Title>
<Issues <Issues
{...provided.droppableProps} data-id="Issues"
ref={provided.innerRef} { ...provided.droppableProps }
data-testid={`board-list:${status}`} ref={ provided.innerRef }
> data-testid={ `board-list:${ status }` }
{filteredListIssues.map((issue, index) => ( >
<Issue key={issue.id} projectUsers={project.users} issue={issue} index={index} /> { filteredListIssues.map((issue, index) => (
))} <Issue key={ issue.id } projectUsers={ project.users } issue={ issue } index={ index }/>
{provided.placeholder} )) }
</Issues> { provided.placeholder }
</List> </Issues>
)} </List>
</Droppable> ) }
); </Droppable>
);
}; };
const filterIssues = (projectIssues, filters, currentUserId) => { const filterIssues = (projectIssues, filters, currentUserId) => {
const { searchTerm, userIds, myOnly, recent } = filters; const { searchTerm, userIds, myOnly, recent } = filters;
let issues = projectIssues; let issues = projectIssues;
if (searchTerm) { if (searchTerm) {
issues = issues.filter(issue => issue.title.toLowerCase().includes(searchTerm.toLowerCase())); issues = issues.filter(issue => issue.title.toLowerCase().includes(searchTerm.toLowerCase()));
} }
if (userIds.length > 0) { if (userIds.length > 0) {
issues = issues.filter(issue => intersection(issue.userIds, userIds).length > 0); issues = issues.filter(issue => intersection(issue.userIds, userIds).length > 0);
} }
if (myOnly && currentUserId) { if (myOnly && currentUserId) {
issues = issues.filter(issue => issue.userIds.includes(currentUserId)); issues = issues.filter(issue => issue.userIds.includes(currentUserId));
} }
if (recent) { if (recent) {
issues = issues.filter(issue => moment(issue.updatedAt).isAfter(moment().subtract(3, 'days'))); issues = issues.filter(issue => moment(issue.updatedAt).isAfter(moment().subtract(3, 'days')));
} }
return issues; return issues;
}; };
const getSortedListIssues = (issues, status) => const getSortedListIssues = (issues, status) =>
issues.filter(issue => issue.status === status).sort((a, b) => a.listPosition - b.listPosition); issues.filter(issue => issue.status === status).sort((a, b) => a.listPosition - b.listPosition);
const formatIssuesCount = (allListIssues, filteredListIssues) => { const formatIssuesCount = (allListIssues, filteredListIssues) => {
if (allListIssues.length !== filteredListIssues.length) { if (allListIssues.length !== filteredListIssues.length) {
return `${filteredListIssues.length} of ${allListIssues.length}`; return `${ filteredListIssues.length } of ${ allListIssues.length }`;
} }
return allListIssues.length; return allListIssues.length;
}; };
ProjectBoardList.propTypes = { ProjectBoardList.propTypes = {
status: PropTypes.string.isRequired, status: PropTypes.string.isRequired,
project: PropTypes.object.isRequired, project: PropTypes.object.isRequired,
filters: PropTypes.object.isRequired, filters: PropTypes.object.isRequired,
currentUserId: PropTypes.number, currentUserId: PropTypes.number,
}; };
ProjectBoardList.defaultProps = { ProjectBoardList.defaultProps = {
currentUserId: null, currentUserId: null,
}; };
export default ProjectBoardList; export default ProjectBoardList;

View File

@ -4,94 +4,93 @@ import { DragDropContext } from 'react-beautiful-dnd';
import useCurrentUser from 'shared/hooks/currentUser'; import useCurrentUser from 'shared/hooks/currentUser';
import api from 'shared/utils/api'; import api from 'shared/utils/api';
import { moveItemWithinArray, insertItemIntoArray } from 'shared/utils/javascript'; import { insertItemIntoArray, moveItemWithinArray } from 'shared/utils/javascript';
import { IssueStatus } from 'shared/constants/issues'; import { IssueStatus } from 'shared/constants/issues';
import List from './List'; import List from './List';
import { Lists } from './Styles'; import { Lists } from './Styles';
const propTypes = {
project: PropTypes.object.isRequired,
filters: PropTypes.object.isRequired,
updateLocalProjectIssues: PropTypes.func.isRequired,
};
const ProjectBoardLists = ({ project, filters, updateLocalProjectIssues }) => { const ProjectBoardLists = ({ project, filters, updateLocalProjectIssues }) => {
const { currentUserId } = useCurrentUser(); const { currentUserId } = useCurrentUser();
const handleIssueDrop = ({ draggableId, destination, source }) => { const handleIssueDrop = ({ draggableId, destination, source }) => {
if (!isPositionChanged(source, destination)) return; if (!isPositionChanged(source, destination)) return;
const issueId = Number(draggableId); const issueId = Number(draggableId);
api.optimisticUpdate(`/issues/${issueId}`, { api.optimisticUpdate(`/issues/${ issueId }`, {
updatedFields: { updatedFields: {
status: destination.droppableId, status: destination.droppableId,
listPosition: calculateIssueListPosition(project.issues, destination, source, issueId), listPosition: calculateIssueListPosition(project.issues, destination, source, issueId),
}, },
currentFields: project.issues.find(({ id }) => id === issueId), currentFields: project.issues.find(({ id }) => id === issueId),
setLocalData: fields => updateLocalProjectIssues(issueId, fields), setLocalData: fields => updateLocalProjectIssues(issueId, fields),
}); });
}; };
return ( return (
<DragDropContext onDragEnd={handleIssueDrop}> <DragDropContext onDragEnd={ handleIssueDrop }>
<Lists> <Lists id='Lists'>
{Object.values(IssueStatus).map(status => ( { Object.values(IssueStatus).map(status => (
<List <List
key={status} data-name='List'
status={status} key={ status }
project={project} status={ status }
filters={filters} project={ project }
currentUserId={currentUserId} filters={ filters }
/> currentUserId={ currentUserId }
))} />
</Lists> )) }
</DragDropContext> </Lists>
); </DragDropContext>
);
}; };
const isPositionChanged = (destination, source) => { const isPositionChanged = (destination, source) => {
if (!destination) return false; if (!destination) return false;
const isSameList = destination.droppableId === source.droppableId; const isSameList = destination.droppableId === source.droppableId;
const isSamePosition = destination.index === source.index; const isSamePosition = destination.index === source.index;
return !isSameList || !isSamePosition; return !isSameList || !isSamePosition;
}; };
const calculateIssueListPosition = (...args) => { const calculateIssueListPosition = (...args) => {
const { prevIssue, nextIssue } = getAfterDropPrevNextIssue(...args); const { prevIssue, nextIssue } = getAfterDropPrevNextIssue(...args);
let position; let position;
if (!prevIssue && !nextIssue) { if (!prevIssue && !nextIssue) {
position = 1; position = 1;
} else if (!prevIssue) { } else if (!prevIssue) {
position = nextIssue.listPosition - 1; position = nextIssue.listPosition - 1;
} else if (!nextIssue) { } else if (!nextIssue) {
position = prevIssue.listPosition + 1; position = prevIssue.listPosition + 1;
} else { } else {
position = prevIssue.listPosition + (nextIssue.listPosition - prevIssue.listPosition) / 2; position = prevIssue.listPosition + (nextIssue.listPosition - prevIssue.listPosition) / 2;
} }
return position; return position;
}; };
const getAfterDropPrevNextIssue = (allIssues, destination, source, droppedIssueId) => { const getAfterDropPrevNextIssue = (allIssues, destination, source, droppedIssueId) => {
const beforeDropDestinationIssues = getSortedListIssues(allIssues, destination.droppableId); const beforeDropDestinationIssues = getSortedListIssues(allIssues, destination.droppableId);
const droppedIssue = allIssues.find(issue => issue.id === droppedIssueId); const droppedIssue = allIssues.find(issue => issue.id === droppedIssueId);
const isSameList = destination.droppableId === source.droppableId; const isSameList = destination.droppableId === source.droppableId;
const afterDropDestinationIssues = isSameList const afterDropDestinationIssues = isSameList
? moveItemWithinArray(beforeDropDestinationIssues, droppedIssue, destination.index) ? moveItemWithinArray(beforeDropDestinationIssues, droppedIssue, destination.index)
: insertItemIntoArray(beforeDropDestinationIssues, droppedIssue, destination.index); : insertItemIntoArray(beforeDropDestinationIssues, droppedIssue, destination.index);
return { return {
prevIssue: afterDropDestinationIssues[destination.index - 1], prevIssue: afterDropDestinationIssues[destination.index - 1],
nextIssue: afterDropDestinationIssues[destination.index + 1], nextIssue: afterDropDestinationIssues[destination.index + 1],
}; };
}; };
const getSortedListIssues = (issues, status) => const getSortedListIssues = (issues, status) =>
issues.filter(issue => issue.status === status).sort((a, b) => a.listPosition - b.listPosition); issues.filter(issue => issue.status === status).sort((a, b) => a.listPosition - b.listPosition);
ProjectBoardLists.propTypes = propTypes; ProjectBoardLists.propTypes = {
project: PropTypes.object.isRequired,
filters: PropTypes.object.isRequired,
updateLocalProjectIssues: PropTypes.func.isRequired,
};
export default ProjectBoardLists; export default ProjectBoardLists;