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]
version = "*"
features = [
"Window"
"Window",
"DataTransfer",
"DragEvent",
"HtmlDivElement",
"DomRect"
]

View File

@ -87,6 +87,7 @@
#projectPage > #projectBoardLists {
display: flex;
margin: 26px -5px 0;
position: relative;
}
#projectPage > #projectBoardLists > .list {
@ -134,9 +135,18 @@
user-select: none;
}
#projectPage > #projectBoardLists > .list > .issues > .issueLink > .issue.hidden {
display: none;
}
#projectPage > #projectBoardLists > .list > .issues > .issueLink > .issue.isBeingDragged {
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) {

View File

@ -1,3 +1,5 @@
use jirs_data::UpdateIssuePayload;
use crate::shared::host_client;
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)),
}
}
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::{prelude::*, *};
use jirs_data::IssueStatus;
use crate::model::Page;
mod api;
mod api_handlers;
mod login;
mod model;
mod project;
@ -12,10 +15,12 @@ mod register;
mod shared;
pub type UserId = i32;
pub type IssueId = i32;
pub type AvatarFilterActive = bool;
#[derive(Clone, Debug)]
pub enum Msg {
NoOp,
ChangePage(model::Page),
CurrentProjectResult(FetchObject<String>),
CurrentUserResult(FetchObject<String>),
@ -28,6 +33,15 @@ pub enum Msg {
ProjectToggleOnlyMy,
ProjectToggleRecentlyUpdated,
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>) {
@ -48,7 +62,7 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
Page::Register => register::update(msg, model, orders),
}
if cfg!(debug_assertions) {
log!(model);
// debug!(model);
}
}

View File

@ -5,7 +5,7 @@ use uuid::Uuid;
use jirs_data::*;
use crate::HOST_URL;
use crate::{IssueId, UserId, HOST_URL};
pub type ProjectId = i32;
pub type StatusCode = u32;
@ -47,13 +47,21 @@ pub struct UpdateProjectForm {
pub fields: UpdateProjectPayload,
}
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ProjectPage {
pub about_tooltip_visible: bool,
pub text_filter: String,
pub active_avatar_filters: Vec<i32>,
pub active_avatar_filters: Vec<UserId>,
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)]
@ -90,7 +98,9 @@ impl Default for Model {
text_filter: "".to_string(),
active_avatar_filters: vec![],
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 jirs_data::{FullProject, Issue, IssuePriority, IssueType, UpdateIssuePayload};
use crate::model::{Icon, Model, Page};
use crate::shared::styled_avatar::StyledAvatar;
use crate::shared::styled_button::{StyledButton, Variant};
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 jirs_data::{FullProject, Issue, IssuePriority, IssueStatus, IssueType};
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<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;
}
Msg::ProjectToggleRecentlyUpdated => {
model.project_page.recenlty_updated_filter =
!model.project_page.recenlty_updated_filter;
model.project_page.recently_updated_filter =
!model.project_page.recently_updated_filter;
}
Msg::ProjectClearFilters => {
let pp = &mut model.project_page;
pp.active_avatar_filters = vec![];
pp.recenlty_updated_filter = false;
pp.recently_updated_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,
icon_only: false,
disabled: false,
active: model.project_page.recenlty_updated_filter,
active: model.project_page.recently_updated_filter,
text: Some("Recently Updated".to_string()),
icon: None,
on_click: Some(mouse_ev(Ev::Click, |_| Msg::ProjectToggleRecentlyUpdated)),
@ -137,7 +196,7 @@ fn project_board_filters(model: &Model) -> Node<Msg> {
.into_node();
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()
{
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))
.collect();
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![
attrs![At::Class => "list"],
attrs![At::Class => "list";],
div![
attrs![At::Class => "title"],
label,
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
.users
.iter()
@ -240,6 +315,7 @@ fn project_issue(_model: &Model, project: &FullProject, issue: &Issue) -> Node<M
.into_node()
})
.collect();
let mut issue_type_icon = match issue.issue_type.parse::<IssueType>() {
Ok(icon) => {
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)],
};
let priority_icon = match issue.priority.parse::<IssuePriority>() {
Ok(IssuePriority::Low) | Ok(IssuePriority::Lowest) => {
crate::shared::styled_icon(Icon::ArrowDown)
Ok(p) => {
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()],
};
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![
attrs![At::Class => "issueLink"],
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,],
div![
attrs![At::Class => "bottom"],

View File

@ -1,5 +1,6 @@
use seed::fetch::{FetchObject, ResponseWithDataResult};
use seed::{prelude::*, *};
use wasm_bindgen::JsCast;
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>) {
match msg {
Msg::CurrentProjectResult(FetchObject {
result:
Ok(ResponseWithDataResult {
data: Ok(body),
status,
..
}),
..
}) if status.is_ok() => match serde_json::from_str::<'_, FullProjectResponse>(body) {
Ok(project_response) => {
model.project = Some(project_response.project);
Msg::CurrentProjectResult(fetched) => {
crate::api_handlers::current_project_response(fetched, model);
}
_ => (),
},
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);
Msg::CurrentUserResult(fetched) => {
crate::api_handlers::current_user_response(fetched, model);
}
_ => (),
},
_ => (),
}
}
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 serde::{Deserialize, Serialize};
use std::str::FromStr;
use uuid::Uuid;
pub trait ResponseData {
@ -96,12 +97,12 @@ impl FromStr for IssuePriority {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"highest" => Ok(IssuePriority::Highest),
"high" => Ok(IssuePriority::High),
"medium" => Ok(IssuePriority::Medium),
"low" => Ok(IssuePriority::Low),
"lowest" => Ok(IssuePriority::Lowest),
match s.to_lowercase().trim() {
"5" | "highest" => Ok(IssuePriority::Highest),
"4" | "high" => Ok(IssuePriority::High),
"3" | "medium" => Ok(IssuePriority::Medium),
"2" | "low" => Ok(IssuePriority::Low),
"1" | "lowest" => Ok(IssuePriority::Lowest),
_ => 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 {
match self {
IssuePriority::Highest => 5,

View File

@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
import { useRouteMatch } from 'react-router-dom';
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 = {
projectUsers: PropTypes.array.isRequired,
@ -19,36 +19,37 @@ const ProjectBoardListIssue = ({ projectUsers, issue, index }) => {
const assignees = issue.userIds.map(userId => projectUsers.find(user => user.id === userId));
return (
<Draggable draggableId={issue.id.toString()} index={index}>
{(provided, snapshot) => (
<Draggable draggableId={ issue.id.toString() } index={ index }>
{ (provided, snapshot) => (
<IssueLink
to={`${match.url}/issues/${issue.id}`}
ref={provided.innerRef}
data-id="IssueLink"
to={ `${ match.url }/issues/${ issue.id }` }
ref={ provided.innerRef }
data-testid="list-issue"
{...provided.draggableProps}
{...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 }>
<Title>{ issue.title }</Title>
<Bottom>
<div>
<IssueTypeIcon type={issue.type} />
<IssuePriorityIcon priority={issue.priority} top={-1} left={4} />
<IssueTypeIcon type={ issue.type }/>
<IssuePriorityIcon priority={ issue.priority } top={ -1 } left={ 4 }/>
</div>
<Assignees>
{assignees.map(user => (
{ assignees.map(user => (
<AssigneeAvatar
key={user.id}
size={24}
avatarUrl={user.avatarUrl}
name={user.name}
key={ user.id }
size={ 24 }
avatarUrl={ user.avatarUrl }
name={ user.name }
/>
))}
)) }
</Assignees>
</Bottom>
</Issue>
</IssueLink>
)}
) }
</Draggable>
);
};

View File

@ -7,7 +7,7 @@ import { intersection } from 'lodash';
import { IssueStatusCopy } from 'shared/constants/issues';
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 filteredIssues = filterIssues(project.issues, filters, currentUserId);
@ -15,25 +15,26 @@ const ProjectBoardList = ({ status, project, filters, currentUserId }) => {
const allListIssues = getSortedListIssues(project.issues, status);
return (
<Droppable key={status} droppableId={status}>
{provided => (
<List>
<Title>
{`${IssueStatusCopy[status]} `}
<IssuesCount>{formatIssuesCount(allListIssues, filteredListIssues)}</IssuesCount>
<Droppable key={ status } droppableId={ status }>
{ provided => (
<List data-id='List'>
<Title data-id="Title">
{ `${ IssueStatusCopy[status] } ` }
<IssuesCount>{ formatIssuesCount(allListIssues, filteredListIssues) }</IssuesCount>
</Title>
<Issues
{...provided.droppableProps}
ref={provided.innerRef}
data-testid={`board-list:${status}`}
data-id="Issues"
{ ...provided.droppableProps }
ref={ provided.innerRef }
data-testid={ `board-list:${ status }` }
>
{filteredListIssues.map((issue, index) => (
<Issue key={issue.id} projectUsers={project.users} issue={issue} index={index} />
))}
{provided.placeholder}
{ filteredListIssues.map((issue, index) => (
<Issue key={ issue.id } projectUsers={ project.users } issue={ issue } index={ index }/>
)) }
{ provided.placeholder }
</Issues>
</List>
)}
) }
</Droppable>
);
};
@ -62,7 +63,7 @@ const getSortedListIssues = (issues, status) =>
const formatIssuesCount = (allListIssues, filteredListIssues) => {
if (allListIssues.length !== filteredListIssues.length) {
return `${filteredListIssues.length} of ${allListIssues.length}`;
return `${ filteredListIssues.length } of ${ allListIssues.length }`;
}
return allListIssues.length;
};

View File

@ -4,18 +4,12 @@ import { DragDropContext } from 'react-beautiful-dnd';
import useCurrentUser from 'shared/hooks/currentUser';
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 List from './List';
import { Lists } from './Styles';
const propTypes = {
project: PropTypes.object.isRequired,
filters: PropTypes.object.isRequired,
updateLocalProjectIssues: PropTypes.func.isRequired,
};
const ProjectBoardLists = ({ project, filters, updateLocalProjectIssues }) => {
const { currentUserId } = useCurrentUser();
@ -24,7 +18,7 @@ const ProjectBoardLists = ({ project, filters, updateLocalProjectIssues }) => {
const issueId = Number(draggableId);
api.optimisticUpdate(`/issues/${issueId}`, {
api.optimisticUpdate(`/issues/${ issueId }`, {
updatedFields: {
status: destination.droppableId,
listPosition: calculateIssueListPosition(project.issues, destination, source, issueId),
@ -35,17 +29,18 @@ const ProjectBoardLists = ({ project, filters, updateLocalProjectIssues }) => {
};
return (
<DragDropContext onDragEnd={handleIssueDrop}>
<Lists>
{Object.values(IssueStatus).map(status => (
<DragDropContext onDragEnd={ handleIssueDrop }>
<Lists id='Lists'>
{ Object.values(IssueStatus).map(status => (
<List
key={status}
status={status}
project={project}
filters={filters}
currentUserId={currentUserId}
data-name='List'
key={ status }
status={ status }
project={ project }
filters={ filters }
currentUserId={ currentUserId }
/>
))}
)) }
</Lists>
</DragDropContext>
);
@ -92,6 +87,10 @@ const getAfterDropPrevNextIssue = (allIssues, destination, source, droppedIssueI
const getSortedListIssues = (issues, status) =>
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;