wasp/examples/waspello/ext/MainPage.js
2022-01-12 16:47:05 +01:00

451 lines
14 KiB
JavaScript

import React, { useState, useRef } from 'react'
import { Plus, X, MoreHorizontal } from 'react-feather'
import { Popover } from 'react-tiny-popover'
import classnames from 'classnames'
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'
import { useQuery } from '@wasp/queries'
import getListsAndCards from '@wasp/queries/getListsAndCards'
import createList from '@wasp/actions/createList'
import updateList from '@wasp/actions/updateList'
import deleteList from '@wasp/actions/deleteList'
import createCard from '@wasp/actions/createCard'
import updateCard from '@wasp/actions/updateCard'
import UserPageLayout from './UserPageLayout'
import waspLogo from './waspLogo.png'
import './Main.css'
const DND_ITEM_POS_SPACING = 2 ** 16
// It is expected that each item has .pos property.
const calcNewDndItemPos = (items) => {
if (!Array.isArray(items) || items.length === 0) return DND_ITEM_POS_SPACING - 1
return Math.max(...items.map(l => l.pos)) + DND_ITEM_POS_SPACING
}
// It is assummed that items are sorted by pos, ascending.
const calcNewPosOfDndItemMovedWithinList = (items, srcIdx, destIdx) => {
if (srcIdx === destIdx) return items[srcIdx].pos
if (destIdx === 0) return (items[0].pos / 2)
if (destIdx === items.length - 1) return items[items.length - 1].pos + DND_ITEM_POS_SPACING
if (destIdx > srcIdx) return (items[destIdx].pos + items[destIdx + 1].pos) / 2
if (destIdx < srcIdx) return (items[destIdx - 1].pos + items[destIdx].pos) / 2
}
// It is assummed that items is sorted by pos, ascending.
const calcNewPosOfDndItemInsertedInAnotherList = (items, destIdx) => {
if (items.length === 0) return DND_ITEM_POS_SPACING - 1
if (destIdx === 0) return (items[0].pos / 2)
if (destIdx === items.length) return items[items.length - 1].pos + DND_ITEM_POS_SPACING
return (items[destIdx - 1].pos + items[destIdx].pos) / 2
}
const createListIdToSortedCardsMap = (listsAndCards) => {
const listIdToSortedCardsMap = {}
listsAndCards.forEach(list => {
listIdToSortedCardsMap[list.id] = [...list.cards].sort((a, b) => a.pos - b.pos)
})
return listIdToSortedCardsMap
}
const MainPage = ({ user }) => {
const { data: listsAndCards, isFetchingListsAndCards, errorListsAndCards }
= useQuery(getListsAndCards)
// NOTE(matija): this is only a shallow copy.
const listsSortedByPos = listsAndCards && [...listsAndCards].sort((a, b) => a.pos - b.pos)
// Create a map with listId -> cards sorted by pos.
const listIdToSortedCardsMap = listsAndCards && createListIdToSortedCardsMap(listsAndCards)
const onDragEnd = async (result) => {
// Item was dropped outside of the droppable area.
if (!result.destination) {
return
}
// TODO(matija): make an enum for type strings (BOARD, CARD).
if (result.type === 'BOARD') {
const newPos =
calcNewPosOfDndItemMovedWithinList(
listsSortedByPos, result.source.index, result.destination.index
)
try {
const movedListId = listsSortedByPos[result.source.index].id
await updateList({ listId: movedListId, data: { pos: newPos } })
} catch (err) {
window.alert('Error while updating list position: ' + err.message)
}
} else if (result.type === 'CARD') {
const sourceListId = result.source.droppableId
const destListId = result.destination.droppableId
// TODO(matija): this is not the nicest solution, we should have a consistent naming system
// for draggable ids (for lists we put prefix in the id, while for cards we use
// their db id directly, because that saves us a bit of work in the further code.
//
// NOTE(matija): All draggable ids must be unique, even if they belong to different
// droppable areas. This is why for lists we didn't use a db id directly, because it would
// overlap with card ids. And for cards it was handy to have db id as draggable id, because
// then we can easily access the data for the specific card.
const movedCardId = Number(result.draggableId)
const destListCardsSortedByPos = listIdToSortedCardsMap[destListId]
let newPos = undefined
if (sourceListId === destListId) { // Card got moved within the same list.
newPos = calcNewPosOfDndItemMovedWithinList(
destListCardsSortedByPos, result.source.index, result.destination.index
)
} else { // Card got inserted from another list.
newPos = calcNewPosOfDndItemInsertedInAnotherList(
destListCardsSortedByPos, result.destination.index
)
}
try {
await updateCard({ cardId: movedCardId, data: { pos: newPos, listId: destListId } })
} catch (err) {
window.alert('Error while updating card position: ' + err.message)
}
} else {
// TODO(matija): throw error.
}
}
return (
<UserPageLayout user={user}>
<div className='board-header'>
<div className='board-name'>
<h1 className='board-header-text'>Your board</h1>
</div>
</div>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="board" direction="horizontal" type="BOARD" >
{(provided, snapshot) => (
<div id='board' className='u-fancy-scrollbar'
ref={provided.innerRef}
{...provided.droppableProps}
>
{ listsSortedByPos && listIdToSortedCardsMap &&
<Lists
lists={listsSortedByPos}
listIdToCardsMap={listIdToSortedCardsMap}
/>
}
{provided.placeholder}
<AddList newPos={calcNewDndItemPos(listsAndCards)} />
</div>
)}
</Droppable>
</DragDropContext>
</UserPageLayout>
)
}
const Lists = ({ lists, listIdToCardsMap }) => {
// TODO(matija): what if some of the props is empty? Although we make sure not to add it
// to DOM in that case.
return lists.map((list, index) => {
return (
<List list={list} key={list.id} index={index}
cards={listIdToCardsMap[list.id]}
/>
)
})
}
const List = ({ list, index, cards }) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const handleListNameUpdated = async (listId, newName) => {
try {
await updateList({ listId, data: { name: newName } })
} catch (err) {
window.alert('Error while updating list name: ' + err.message)
}
}
const handleDeleteList = async (listId) => {
try {
await deleteList({ listId })
} catch (err) {
window.alert('Error while deleting list: ' + err.message)
}
setIsPopoverOpen(false)
}
const ListMenu = () => {
return (
<div className='popover-menu'>
<div className='popover-header'>
<div className='popover-header-item'>
<button className='popover-header-close-btn dark-hover fake-invisible-item'>
<X size={16}/>
</button>
</div>
<span className='popover-header-title popover-header-item'>List&nbsp;actions</span>
<div className='popover-header-item'>
<button
className='popover-header-close-btn dark-hover'
onClick={() => setIsPopoverOpen(false)}
>
<X size={16}/>
</button>
</div>
</div>
<div className='popover-content'>
<ul className='popover-content-list'>
<li><button>Add card...</button></li>
<li><button>Copy list...</button></li>
<li>
<button onClick={() => handleDeleteList(list.id)}>
Delete this list
</button>
</li>
</ul>
</div>
</div>
)
}
return (
<Draggable
key={list.id}
draggableId={`listDraggable-${list.id}`}
index={index}
>
{(provided, snapshot) => (
<div className='list-wrapper'
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div className='list'>
<div className='list-header'>
<textarea
className='list-header-name mod-list-name'
onBlur={(e) => handleListNameUpdated(list.id, e.target.value)}
defaultValue={ list.name }
/>
<div className='list-header-extras'>
<Popover
isOpen={isPopoverOpen}
onClickOutside={() => setIsPopoverOpen(false)}
positions={['bottom', 'right', 'left']}
align='start'
padding={6}
content={<ListMenu/>}
>
<div
className='list-header-extras-menu dark-hover'
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
>
<MoreHorizontal size={16}/>
</div>
</Popover>
</div>
</div> {/* eof list-header */}
<Droppable
droppableId={`${list.id}`}
direction="vertical"
type="CARD"
>
{(provided, snapshot) => (
<div className='cards'
ref={provided.innerRef}
{...provided.droppableProps}
>
{ cards && <Cards cards={cards} /> }
{provided.placeholder}
</div>
)}
</Droppable>
<div className='card-composer-container'>
<AddCard listId={list.id} newPos={calcNewDndItemPos(cards)} />
</div>
</div>
</div>
)}
</Draggable>
)
}
const Cards = ({ cards }) => {
return (
<div className='list-cards'>
{ cards.map((card, index) => <Card card={card} key={card.id} index={index} />) }
</div>
)
}
const Card = ({ card, index }) => {
return (
<Draggable
key={card.id}
draggableId={`${card.id}`}
index={index}
>
{(provided, snapshot) => (
<div className='list-card'
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<span className='list-card-title'>{ card.title }</span>
</div>
)}
</Draggable>
)
}
const AddList = ({ newPos }) => {
const [isInEditMode, setIsInEditMode] = useState(false)
const AddListButton = () => {
return (
<button
className='open-add-list'
onClick={() => setIsInEditMode(true)}
>
<div className='add-icon'>
<Plus size={16} strokeWidth={2} />
</div>
Add a list
</button>
)
}
const AddListInput = () => {
const handleAddList = async (event) => {
event.preventDefault()
try {
const listName = event.target.listName.value
event.target.reset()
await createList({ name: listName, pos: newPos })
} catch (err) {
window.alert('Error: ' + err.message)
}
}
return (
<form onSubmit={handleAddList}>
<input
className='list-name-input'
autoFocus
name='listName'
type='text'
defaultValue=''
placeholder='Enter list title...'
/>
<div className='list-add-controls'>
<input className='list-add-button' type='submit' value='Add list' />
<div
className='list-cancel-edit'
onClick={() => setIsInEditMode(false)}
>
<X/>
</div>
</div>
</form>
)
}
return (
<div
className={classnames(
'add-list', 'list-wrapper', 'mod-add', { 'is-idle': !isInEditMode }
)}
>
{ isInEditMode ? <AddListInput /> : <AddListButton /> }
</div>
)
}
const AddCard = ({ listId, newPos }) => {
const [isInEditMode, setIsInEditMode] = useState(false)
const AddCardButton = () => {
return (
<button
className='open-card-composer dark-hover'
onClick={() => setIsInEditMode(true)}
>
<div className='add-icon'>
<Plus size={16} strokeWidth={2} />
</div>
Add a card
</button>
)
}
const AddCardInput = ({ listId }) => {
const formRef = useRef(null)
const submitOnEnter = (e) => {
if (e.keyCode === 13 /* && e.shiftKey == false */) {
e.preventDefault()
formRef.current.dispatchEvent(
new Event('submit', { cancelable: true, bubbles: true })
)
}
}
const handleAddCard = async (event, listId) => {
event.preventDefault()
try {
const cardTitle = event.target.cardTitle.value
event.target.reset()
await createCard({ title: cardTitle, pos: newPos, listId })
} catch (err) {
window.alert('Error: ' + err.message)
}
}
return (
<form className='card-composer' ref={formRef} onSubmit={(e) => handleAddCard(e, listId)}>
<div className='list-card'>
<textarea
className='card-composer-textarea'
onKeyDown={submitOnEnter}
autoFocus
name='cardTitle'
placeholder='Enter a title for this card...'
/>
</div>
<div className='card-add-controls'>
<input className='card-add-button' type='submit' value='Add card' />
<div
className='card-cancel-edit'
onClick={() => setIsInEditMode(false)}
>
<X/>
</div>
</div>
</form>
)
}
return (
<div>
{ isInEditMode ? <AddCardInput listId={listId} /> : <AddCardButton /> }
</div>
)
}
export default MainPage