This repository has been archived on 2024-02-08. You can view files and clone it, but cannot push or open issues or pull requests.
frontend/src/components/lists/views/Kanban.vue
konrad c7845bb9c1
All checks were successful
continuous-integration/drone/push Build is passing
Kanban (#118)
Add error message when trying to create an invalid new task in a bucket

Prevent creation of new buckets if the bucket title is empty

Disable deleting a bucket if it's the last one

Disable dragging tasks when they are being updated

Fix transition when opening tasks

Send the user to list view by default

Show loading spinner when updating multiple tasks

Add loading spinner when moving tasks

Add loading animation when bucket is loading / updating etc

Add bucket title edit

Fix creating new buckets

Add loading animation

Add removing buckets

Fix creating a new bucket after tasks were moved

Fix warning about labels on tasks

Fix labels on tasks not updating after retrieval from api

Fix property width

Add closing and mobile design

Make the task detail popup look good

Move list views

Move task detail view in a popup

Add link to tasks

Add saving the new task position after it was moved

Fix creating new bucket

Fix creating a new task

Cleanup

Disable user selection for task cards

Fix drag placeholder

Add dragging style to task

Add placeholder + change animation duration

More cleanup

Cleanup / docs

Working of dragging and dropping tasks

Adjust markup and styling for new library

Change kanban library to something that works

Add basic calculation of new positions

Don't try to create empty tasks

Add indicator if a task is done

Add moving tasks between buckets

Make empty buckets a little smaller

Add gimmick for button description

Fix color

Fix scrolling bucket layout

Add creating a new bucket

Add hiding the task input field

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: #118
2020-04-25 23:11:34 +00:00

448 lines
15 KiB
Vue

<template>
<div class="kanban loader-container" :class="{ 'is-loading': bucketService.loading}">
<div v-for="bucket in buckets" :key="`bucket${bucket.id}`" class="bucket">
<div class="bucket-header">
<h2
class="title input"
contenteditable="true"
@focusout="() => saveBucketTitle(bucket.id)"
:ref="`bucket${bucket.id}title`"
@keyup.ctrl.enter="() => saveBucketTitle(bucket.id)">{{ bucket.title }}</h2>
<div class="dropdown is-right options" :class="{ 'is-active': bucketOptionsDropDownActive[bucket.id] }">
<div class="dropdown-trigger" @click.stop="toggleBucketDropdown(bucket.id)">
<span class="icon">
<icon icon="ellipsis-v"/>
</span>
</div>
<div class="dropdown-menu" role="menu">
<div class="dropdown-content">
<a
class="dropdown-item has-text-danger"
@click="() => deleteBucketModal(bucket.id)"
:class="{'is-disabled': buckets.length <= 1}"
v-tooltip="buckets.length <= 1 ? 'You cannot remove the last bucket.' : ''"
>
<span class="icon is-small"><icon icon="trash-alt"/></span>
Delete
</a>
</div>
</div>
</div>
</div>
<div class="tasks">
<Container
@drop="e => onDrop(bucket.id, e)"
group-name="buckets"
:get-child-payload="getTaskPayload(bucket.id)"
:drop-placeholder="dropPlaceholderOptions"
:animation-duration="150"
drag-class="ghost-task"
drag-class-drop="ghost-task-drop"
drag-handle-selector=".task.draggable"
>
<Draggable v-for="task in bucket.tasks" :key="`bucket${bucket.id}-task${task.id}`">
<router-link
:to="{ name: 'task.kanban.detail', params: { id: task.id } }"
class="task loader-container draggable"
tag="div"
:class="{
'is-loading': taskService.loading && taskUpdating[task.id],
'draggable': !taskService.loading || !taskUpdating[task.id]
}"
>
<span
class="color"
:style="{ 'background-color': task.hexColor }"
v-if="task.hexColor !== '#' + task.defaultColor">
</span>
<span class="task-id">
<span class="is-done" v-if="task.done">Done</span>
#{{ task.id }}
</span>
<span
v-if="task.dueDate > 0"
class="due-date"
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
v-tooltip="formatDate(task.dueDate)">
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
<span>
{{ formatDateSince(task.dueDate) }}
</span>
</span>
<h3>{{ task.text }}</h3>
<labels :labels="task.labels"/>
<div class="footer">
<div class="items">
<priority-label :priority="task.priority" class="priority-label"/>
<div class="assignees" v-if="task.assignees.length > 0">
<user
v-for="u in task.assignees"
:key="task.id + 'assignee' + u.id"
:user="u"
:show-username="false"
:avatar-size="24"
/>
</div>
</div>
<div>
<span class="icon" v-if="task.attachments.length > 0">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect fill="none" rx="0" ry="0"></rect>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M19.86 8.29994C19.8823 8.27664 19.9026 8.25201 19.9207 8.22634C20.5666 7.53541 20.93 6.63567 20.93 5.68001C20.93 4.69001 20.55 3.76001 19.85 3.06001C18.45 1.66001 16.02 1.66001 14.62 3.06001L9.88002 7.80001C9.86705 7.81355 9.85481 7.82753 9.8433 7.8419L4.58 13.1C3.6 14.09 3.06 15.39 3.06 16.78C3.06 18.17 3.6 19.48 4.58 20.46C5.6 21.47 6.93 21.98 8.26 21.98C9.59 21.98 10.92 21.47 11.94 20.46L17.74 14.66C17.97 14.42 17.98 14.04 17.74 13.81C17.5 13.58 17.12 13.58 16.89 13.81L11.09 19.61C10.33 20.36 9.33 20.78 8.26 20.78C7.19 20.78 6.19 20.37 5.43 19.61C4.68 18.85 4.26 17.85 4.26 16.78C4.26 15.72 4.68 14.71 5.43 13.96L15.47 3.91996C15.4962 3.89262 15.5195 3.86346 15.54 3.83292C16.4992 2.95103 18.0927 2.98269 19.01 3.90001C19.48 4.37001 19.74 5.00001 19.74 5.67001C19.74 6.34001 19.48 6.97001 19.01 7.44001L14.27 12.18C14.2571 12.1935 14.2448 12.2075 14.2334 12.2218L8.96 17.4899C8.59 17.8699 7.93 17.8699 7.55 17.4899C7.36 17.2999 7.26 17.0399 7.26 16.7799C7.26 16.5199 7.36 16.2699 7.55 16.0699L15.47 8.14994C15.7 7.90994 15.71 7.52994 15.47 7.29994C15.23 7.06994 14.85 7.06994 14.62 7.29994L6.7 15.2199C6.29 15.6399 6.06 16.1899 6.06 16.7799C6.06 17.3699 6.29 17.9199 6.7 18.3399C7.12 18.7499 7.67 18.9799 8.26 18.9799C8.85 18.9799 9.4 18.7599 9.82 18.3399L19.86 8.29994Z"></path>
</svg>
</span>
</div>
</div>
</router-link>
</Draggable>
</Container>
</div>
<div class="bucket-footer">
<div class="field" v-if="showNewTaskInput[bucket.id]">
<div class="control">
<input
class="input"
type="text"
placeholder="Enter the new task text..."
v-focus
@focusout="toggleShowNewTaskInput(bucket.id)"
@keyup.esc="toggleShowNewTaskInput(bucket.id)"
@keyup.enter="addTaskToBucket(bucket.id)"
v-model="newTaskText"
:disabled="taskService.loading"
:class="{'is-loading': taskService.loading}"
/>
</div>
<p class="help is-danger" v-if="newTaskError[bucket.id] && newTaskText.length < 3">
Please specify at least three characters.
</p>
</div>
<a
class="button noshadow is-transparent is-fullwidth has-text-centered"
@click="toggleShowNewTaskInput(bucket.id)"
v-if="!showNewTaskInput[bucket.id]">
<span class="icon is-small">
<icon icon="plus"/>
</span>
<span v-if="bucket.tasks.length === 0">
Add a task
</span>
<span v-else>
Add another task
</span>
</a>
</div>
</div>
<div class="bucket new-bucket" v-if="!bucketService.loading">
<input
v-if="showNewBucketInput"
class="input"
type="text"
placeholder="Enter the new bucket title..."
v-focus
@focusout="() => showNewBucketInput = false"
@keyup.esc="() => showNewBucketInput = false"
@keyup.enter="createNewBucket"
v-model="newBucketTitle"
:disabled="bucketService.loading"
:class="{'is-loading': bucketService.loading}"
/>
<a
class="button noshadow is-transparent is-fullwidth has-text-centered"
@click="() => showNewBucketInput = true" v-if="!showNewBucketInput">
<span class="icon is-small">
<icon icon="plus"/>
</span>
<span>
Create a new bucket
</span>
</a>
</div>
<!-- This router view is used to show the task popup while keeping the kanban board itself -->
<transition name="modal">
<router-view/>
</transition>
<modal
v-if="showBucketDeleteModal"
@close="showBucketDeleteModal = false"
@submit="deleteBucket()">
<span slot="header">Delete the bucket</span>
<p slot="text">
Are you sure you want to delete this bucket?<br/>
This will not delete any tasks but move them into the default bucket.
</p>
</modal>
</div>
</template>
<script>
import BucketService from '../../../services/bucket'
import TaskService from '../../../services/task'
import TaskModel from '../../../models/task'
import BucketModel from '../../../models/bucket'
import {Container, Draggable} from 'vue-smooth-dnd'
import PriorityLabel from '../../tasks/reusable/priorityLabel'
import User from '../../global/user'
import Labels from '../../tasks/reusable/labels'
import {filterObject} from '../../../helpers/filterObject'
import {applyDrag} from '../../../helpers/applyDrag'
export default {
name: 'Kanban',
components: {
Container,
Draggable,
Labels,
User,
PriorityLabel,
},
data() {
return {
bucketService: BucketService,
buckets: [],
taskService: TaskService,
// We're directly using the list id from the route since that one is always available
listId: this.$route.params.listId,
dropPlaceholderOptions: {
className: 'drop-preview',
animationDuration: 150,
showOnTop: true,
},
bucketOptionsDropDownActive: {},
showBucketDeleteModal: false,
bucketToDelete: 0,
newTaskText: '',
showNewTaskInput: {},
newBucketTitle: '',
showNewBucketInput: false,
newTaskError: {},
// We're using this to show the loading animation only at the task when updating it
taskUpdating: {},
}
},
created() {
this.bucketService = new BucketService()
this.taskService = new TaskService()
this.loadBuckets()
setTimeout(() => document.addEventListener('click', this.closeBucketDropdowns), 0)
},
methods: {
loadBuckets() {
this.bucketService.getAll({listId: this.listId})
.then(r => {
this.buckets = r
})
.catch(e => {
this.error(e, this)
})
},
onDrop(bucketId, dropResult) {
// Note: A lot of this example comes from the excellent kanban example on https://github.com/kutlugsahin/vue-smooth-dnd/blob/master/demo/src/pages/cards.vue
const bucketIndex = filterObject(this.buckets, b => b.id === bucketId)
if (dropResult.removedIndex !== null || dropResult.addedIndex !== null) {
// FIXME: This is probably not the best solution and more of a naive brute-force approach
// Duplicate the buckets to avoid stuff moving around without noticing
const buckets = Object.assign({}, this.buckets)
// Get the index of the bucket and the bucket itself
const bucket = buckets[bucketIndex]
// Rebuild the tasks from the bucket, removing/adding the moved task
bucket.tasks = applyDrag(bucket.tasks, dropResult)
// Update the bucket in the list of all buckets
delete buckets[bucketIndex]
buckets[bucketIndex] = bucket
// Set the buckets, triggering a state update in vue
this.buckets = buckets
}
if (dropResult.addedIndex !== null) {
const taskIndex = dropResult.addedIndex
const taskBefore = typeof this.buckets[bucketIndex].tasks[taskIndex - 1] === 'undefined' ? null : this.buckets[bucketIndex].tasks[taskIndex - 1]
const taskAfter = typeof this.buckets[bucketIndex].tasks[taskIndex + 1] === 'undefined' ? null : this.buckets[bucketIndex].tasks[taskIndex + 1]
const task = this.buckets[bucketIndex].tasks[taskIndex]
this.$set(this.taskUpdating, task.id, true)
// If there is no task before, our task is the first task in which case we let it have half of the position of the task after it
if (taskBefore === null && taskAfter !== null) {
task.position = taskAfter.position / 2
}
// If there is no task after it, we just add 2^16 to the last position
if (taskBefore !== null && taskAfter === null) {
task.position = taskBefore.position + Math.pow(2, 16)
}
// If we have both a task before and after it, we acually calculate the position
if (taskAfter !== null && taskBefore !== null) {
task.position = taskBefore.position + (taskAfter.position - taskBefore.position) / 2
}
task.bucketId = bucketId
this.taskService.update(task)
.then(t => {
// Update the block with the new task details
this.$set(this.buckets[bucketIndex].tasks, taskIndex, t)
this.success({message: 'The task was moved successfully!'}, this)
})
.catch(e => {
this.error(e, this)
})
.finally(() => {
this.$set(this.taskUpdating, task.id, false)
})
}
},
getTaskPayload(bucketId) {
return index => {
const bucket = this.buckets[filterObject(this.buckets, b => b.id === bucketId)]
return bucket.tasks[index]
}
},
getBlockFromTask(task) {
return {
id: task.id,
status: 'bucket' + task.bucketId,
// We're putting the task in an extra property so we won't have to maintin this whole thing because of basically recreating the task model.
task: task,
}
},
toggleShowNewTaskInput(bucket) {
this.$set(this.showNewTaskInput, bucket, !this.showNewTaskInput[bucket])
},
toggleBucketDropdown(bucketId) {
this.$set(this.bucketOptionsDropDownActive, bucketId, !this.bucketOptionsDropDownActive[bucketId])
},
closeBucketDropdowns() {
for (const bucketId in this.bucketOptionsDropDownActive) {
this.bucketOptionsDropDownActive[bucketId] = false
}
},
addTaskToBucket(bucketId) {
if (this.newTaskText.length < 3) {
this.$set(this.newTaskError, bucketId, true)
return
}
this.$set(this.newTaskError, bucketId, false)
// We need the actual bucket index so we put that in a seperate function
const bucketIndex = () => {
for (const t in this.buckets) {
if (this.buckets[t].id === bucketId) {
return t
}
}
}
const bi = bucketIndex()
const task = new TaskModel({text: this.newTaskText, bucketId: this.buckets[bi].id, listId: this.listId})
this.taskService.create(task)
.then(r => {
this.newTaskText = ''
this.buckets[bi].tasks.push(r)
this.success({message: 'The task was created successfully!'}, this)
})
.catch(e => {
this.error(e, this)
})
},
createNewBucket() {
if (this.newBucketTitle === '') {
return
}
const newBucket = new BucketModel({title: this.newBucketTitle, listId: parseInt(this.listId)})
this.bucketService.create(newBucket)
.then(r => {
this.newBucketTitle = ''
this.showNewBucketInput = false
if (Array.isArray(this.buckets)) {
this.buckets.push(r)
} else {
this.buckets[r.id] = r
}
this.success({message: 'The bucket was created successfully!'}, this)
})
.catch(e => {
this.error(e, this)
})
},
deleteBucketModal(bucketId) {
if (this.buckets.length <= 1) {
return
}
this.bucketToDelete = bucketId
this.showBucketDeleteModal = true
},
deleteBucket() {
const bucket = new BucketModel({
id: this.bucketToDelete,
listId: this.listId,
})
this.bucketService.delete(bucket)
.then(r => {
this.loadBuckets()
this.success(r, this)
})
.catch(e => {
this.error(e, this)
})
.finally(() => {
this.showBucketDeleteModal = false
})
},
saveBucketTitle(bucketId) {
const bucketTitle = this.$refs[`bucket${bucketId}title`][0].textContent
const bucket = new BucketModel({
id: bucketId,
title: bucketTitle,
listId: Number(this.listId),
})
// Because the contenteditable does not have a change event,
// we're building it ourselves here and only updating the bucket
// if the title changed.
const realBucket = this.buckets[filterObject(this.buckets, b => b.id === bucketId)]
if (realBucket.title === bucketTitle) {
return
}
this.bucketService.update(bucket)
.then(r => {
this.success({message: 'The bucket title was updated successfully!'}, this)
realBucket.title = r.title
})
.catch(e => {
this.error(e, this)
})
},
},
}
</script>