Skip to content

Commit

Permalink
feat: allow cards to be moved to other pages or to new pages
Browse files Browse the repository at this point in the history
Blocked on atlassian/react-beautiful-dnd#930
for actual mouse-driven drag and drop (since the size of a card is too
large relative to the table of contents), but using the keyboard mode
this works. Drag a card onto a page in the TOC to move it to that page.
Or drag it between two entries in the TOC to create a new page at that
location.
  • Loading branch information
cbothner committed Nov 22, 2018
1 parent e311f7b commit b75853b
Show file tree
Hide file tree
Showing 15 changed files with 246 additions and 28 deletions.
12 changes: 8 additions & 4 deletions app/controllers/cards_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ def create
def update
authorize @card

if @card.update(card_params)
render @card
form = UpdateCardForm.new card: @card
form.assign_attributes card_params

if form.save
render form.card
else
render json: @card.errors, status: :unprocessable_entity
render json: form.errors, status: :unprocessable_entity
end
end

Expand All @@ -57,7 +60,8 @@ def card_params
Sv.hash_of(
position: Sv.scalar,
solid: Sv.scalar,
raw_content: raw_draft_content_state
raw_content: raw_draft_content_state,
page_id: Sv.scalar
).(params.require(:card))
end

Expand Down
6 changes: 3 additions & 3 deletions app/controllers/pages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ class PagesController < ApplicationController
# @route [POST] `/cases/case-slug/pages`
def create
@page = Page.new page_params
@page.build_case_element case: @case
@page.build_case_element case: @case, position: params[:page][:position]

authorize @page

if @page.save
render @page
render json: @page
else
render json: @page.errors, status: :unprocessable_entity
end
Expand Down Expand Up @@ -55,6 +55,6 @@ def set_page
end

def page_params
params[:page].permit(:title, :position, :icon_slug)
params[:page].permit(:title, :icon_slug)
end
end
25 changes: 25 additions & 0 deletions app/forms/move_card_form.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

# For validating updates to {Card}s
class UpdateCardForm
include ActiveModel::Model

attr_accessor :card

validate :new_element_is_page_in_case?

def save
return unless valid? && card.valid?

card.save
end

private

def new_element_is_page_in_case?
return unless card.element_changed?
return unless card.element.is_a?(Page) && card.element.case == card.case

errors.add :card, 'can only be moved to a Page in the same Case'
end
end
44 changes: 44 additions & 0 deletions app/forms/update_card_form.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

# For validating updates to {Card}s
class UpdateCardForm
include ActiveModel::Model

attr_accessor :card, :position, :solid, :raw_content, :page_id

validates :card, presence: true
validate :new_element_is_page_in_case?

def save
card.assign_attributes card_params

return unless valid?

card.save!
true
end

private

def new_element_is_page_in_case?
return unless page.present?
return if page.case == card.case

errors.add :card, 'can only be moved to a Page in the same Case'
end

def page
@page ||= Page.find page_id
rescue ActiveRecord::RecordNotFound
nil
end

def card_params
{
position: position,
solid: solid,
raw_content: raw_content,
element: page
}.compact
end
end
2 changes: 1 addition & 1 deletion app/javascript/overview/TableOfContents.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ function TableOfContents ({
</NoElements>
)}

<Droppable droppableId="table-of-contents" type="CaseElement">
<Droppable droppableId="table-of-contents" isCombineEnabled={onSidebar}>
{(provided, snapshot) => (
<List
ref={provided.innerRef}
Expand Down
2 changes: 1 addition & 1 deletion app/javascript/overview/TableOfContentsElement.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ function TableOfContentsElement ({
}: Props) {
return (
<Draggable
draggableId={caseElement.id}
draggableId={`caseElements/${caseElement.id}`}
index={position}
isDragDisabled={readOnly || !editing}
>
Expand Down
2 changes: 1 addition & 1 deletion app/javascript/page/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const Page = (props: Props) => {
let { id, title, cards } = page

return (
<Droppable droppableId={`pages/${id}`} type="Page">
<Droppable droppableId={`pages/${id}`}>
{({ placeholder, innerRef: droppableRef }) => (
<div ref={droppableRef}>
<article>
Expand Down
45 changes: 43 additions & 2 deletions app/javascript/redux/actions/card.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
* @flow
*/

import { setUnsaved, displayToast } from 'redux/actions'
import { createPage, setUnsaved, displayToast } from 'redux/actions'

import { Orchard } from 'shared/orchard'
import { Intent } from '@blueprintjs/core'
import { EditorState } from 'draft-js'

import type { ThunkAction, Dispatch } from 'redux/actions'
import type { ThunkAction, Dispatch, GetState } from 'redux/actions'
import type { CardsState, Card, Citation } from 'redux/state'

export type SetCardsAction = { type: 'SET_CARDS', cards: CardsState }
Expand Down Expand Up @@ -74,11 +74,52 @@ export function reorderCard (
return { type: 'REORDER_CARD', id, destination }
}

export function moveCardToNewPage (id: string, pageIndex: number): ThunkAction {
return async (dispatch: Dispatch, getState: GetState) => {
const {
caseData: { slug },
} = getState()

dispatch(removeCard(id))
const page = await dispatch(createPage(slug, { position: pageIndex + 1 }))
return dispatch(moveCardToPage(id, page.param))
}
}

export function moveCardToExistingPage (
id: string,
draggableId: string
): ThunkAction {
return (dispatch: Dispatch, getState: GetState) => {
const [, caseElementId] = draggableId.split('/')
const caseElement = getState().caseData.caseElements.find(
e => e.param === caseElementId
)
if (caseElement == null) return

const { elementType, elementId } = caseElement

if (elementType === 'Page') {
dispatch(removeCard(id))
return dispatch(moveCardToPage(id, elementId))
}
}
}

function moveCardToPage (cardId, pageId): ThunkAction {
return async (dispatch: Dispatch) => {
console.log(`moveCardToPage(${cardId}, ${pageId})`)
const card = await Orchard.espalier(`cards/${cardId}`, { card: { pageId }})
return dispatch(addCard(pageId, card))
}
}

export type RemoveCardAction = {
type: 'REMOVE_CARD',
id: string,
}
export function removeCard (id: string): RemoveCardAction {
console.log(`removeCard(${id})`)
return { type: 'REMOVE_CARD', id }
}

Expand Down
12 changes: 8 additions & 4 deletions app/javascript/redux/actions/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ export function addPage (data: Page): AddPageAction {
return { type: 'ADD_PAGE', data }
}

export function createPage (caseSlug: string): ThunkAction {
export function createPage (
caseSlug: string,
data: $Shape<Page> = {}
): ThunkAction {
return async (dispatch: Dispatch) => {
const data: Page = await Orchard.graft(`cases/${caseSlug}/pages`, {})
dispatch(addPage(data))
dispatch(createCard(data.id))
const page: Page = await Orchard.graft(`cases/${caseSlug}/pages`, data)
dispatch(addPage(page))
if (!data.position) dispatch(createCard(page.id))
return page
}
}

Expand Down
25 changes: 25 additions & 0 deletions app/javascript/redux/reducers/__tests__/caseData.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/* @noflow */

import reducer from '../caseData.js'

describe('caseData reducer', () => {
describe('AddPageAction', () => {
test('works', () => {
const state = {
caseElements: [{ id: 1, position: 1 }, { id: 2, position: 2 }],
}
const action = {
type: 'ADD_PAGE',
data: { caseElement: { id: 3, position: 2 }},
}

expect(reducer(state, action)).toEqual({
caseElements: [
{ id: 1, position: 1 },
{ id: 3, position: 2 },
{ id: 2, position: 2 },
],
})
})
})
})
7 changes: 6 additions & 1 deletion app/javascript/redux/reducers/caseData.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import update from 'immutability-helper'
import { insert } from 'ramda'

import type {
UpdateCaseAction,
Expand Down Expand Up @@ -70,7 +71,11 @@ export default function caseData (
const { caseElement } = action.data
return {
...state,
caseElements: [...state.caseElements, caseElement],
caseElements: insert(
caseElement.position - 1,
caseElement,
state.caseElements
),
}
}

Expand Down
1 change: 1 addition & 0 deletions app/javascript/redux/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export type CaseElement = {
elementStore: CaseElementStore,
elementType: string,
id: string,
param: string,
position: number,
}

Expand Down
54 changes: 43 additions & 11 deletions app/javascript/shared/GalaDragDropContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,44 +7,76 @@ import * as React from 'react'
import { connect } from 'react-redux'
import { DragDropContext } from 'react-beautiful-dnd'

import { reorderCaseElements, reorderCard } from 'redux/actions'
import {
moveCardToNewPage,
moveCardToExistingPage,
reorderCaseElements,
reorderCard,
} from 'redux/actions'

import type { State } from 'redux/state'

type Props = {
children: React.Node,
moveCardToExistingPage: typeof moveCardToExistingPage,
moveCardToNewPage: typeof moveCardToNewPage,
reorderCaseElements: typeof reorderCaseElements,
reorderCard: typeof reorderCard,
}

function GalaDragDropContext ({
children,
moveCardToExistingPage,
moveCardToNewPage,
reorderCaseElements,
reorderCard,
}: Props) {
return <DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>

function onDragEnd (result) {
const { draggableId, type, source, destination } = result
const { draggableId, source, destination } = result
const [table, param] = draggableId.split('/')

switch (type) {
case 'CaseElement': {
switch (table) {
case 'caseElements': {
if (!destination) return

reorderCaseElements(source.index, destination.index)
break
}

case 'Page': {
if (!destination) return

const [, cardId] = draggableId.split('/')
reorderCard(cardId, destination.index)
case 'cards': {
handleCardDragEnd(param, result)
break
}
}
}

return <DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>
function handleCardDragEnd (cardId, result) {
const { destination } = result

if (destination && destination.droppableId.match(/^pages/)) {
reorderCard(cardId, destination.index)
} else {
moveCard(cardId, result)
}
}

function moveCard (cardId, { destination, combine }) {
if (destination) {
moveCardToNewPage(cardId, destination.index)
} else if (combine) {
moveCardToExistingPage(cardId, combine.draggableId)
}
}
}

export default connect(
null,
{ reorderCaseElements, reorderCard }
{
moveCardToNewPage,
moveCardToExistingPage,
reorderCaseElements,
reorderCard,
}
)(GalaDragDropContext)
4 changes: 4 additions & 0 deletions app/javascript/table_of_contents/shared.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export const List = styled.ol`
padding: 0;
width: 100%;
#Sidebar & {
width: 224px;
}
${p =>
p.isDraggingOver &&
css`
Expand Down
Loading

0 comments on commit b75853b

Please sign in to comment.