574 lines
17 KiB
Vue
574 lines
17 KiB
Vue
<template>
|
|
<div class="kanban-view">
|
|
<div class="filter-container" v-if="list.isSavedFilter && !list.isSavedFilter()">
|
|
<div class="items">
|
|
<x-button
|
|
@click.prevent.stop="showFilters = !showFilters"
|
|
icon="filter"
|
|
type="secondary"
|
|
>
|
|
{{ $t('filters.title') }}
|
|
</x-button>
|
|
</div>
|
|
<filter-popup
|
|
@change="() => {filtersChanged = true; loadBuckets()}"
|
|
:visible="showFilters"
|
|
v-model="params"
|
|
/>
|
|
</div>
|
|
<div
|
|
:class="{ 'is-loading': loading && !oneTaskUpdating}"
|
|
class="kanban kanban-bucket-container loader-container">
|
|
<draggable
|
|
v-model="buckets"
|
|
@start="() => dragBucket = true"
|
|
@end="updateBucketPosition"
|
|
group="buckets"
|
|
v-bind="dragOptions"
|
|
:disabled="!canWrite"
|
|
:class="{'dragging-disabled': !canWrite}"
|
|
>
|
|
<transition-group
|
|
type="transition"
|
|
:name="!dragBucket ? 'move-bucket': null"
|
|
tag="div"
|
|
class="kanban-bucket-container">
|
|
<div
|
|
:key="`bucket${bucket.id}`"
|
|
class="bucket"
|
|
:class="{'is-collapsed': collapsedBuckets[bucket.id]}"
|
|
v-for="(bucket, k) in buckets"
|
|
>
|
|
<div class="bucket-header" @click="() => unCollapseBucket(bucket)">
|
|
<span
|
|
v-if="bucket.isDoneBucket"
|
|
class="icon is-small has-text-success mr-2"
|
|
v-tooltip="$t('list.kanban.doneBucketHint')"
|
|
>
|
|
<icon icon="check-double"/>
|
|
</span>
|
|
<h2
|
|
:ref="`bucket${bucket.id}title`"
|
|
@focusout="() => saveBucketTitle(bucket.id)"
|
|
@keydown.enter.prevent.stop="() => saveBucketTitle(bucket.id)"
|
|
@click="focusBucketTitle"
|
|
class="title input"
|
|
:contenteditable="bucketTitleEditable && canWrite && !collapsedBuckets[bucket.id]"
|
|
spellcheck="false">{{ bucket.title }}</h2>
|
|
<span
|
|
:class="{'is-max': bucket.tasks.length >= bucket.limit}"
|
|
class="limit"
|
|
v-if="bucket.limit > 0">
|
|
{{ bucket.tasks.length }}/{{ bucket.limit }}
|
|
</span>
|
|
<dropdown
|
|
class="is-right options"
|
|
v-if="canWrite && !collapsedBuckets[bucket.id]"
|
|
trigger-icon="ellipsis-v"
|
|
@close="() => showSetLimitInput = false"
|
|
>
|
|
<a
|
|
@click.stop="showSetLimitInput = true"
|
|
class="dropdown-item"
|
|
>
|
|
<div class="field has-addons" v-if="showSetLimitInput">
|
|
<div class="control">
|
|
<input
|
|
@change="() => setBucketLimit(bucket)"
|
|
@keyup.enter="() => setBucketLimit(bucket)"
|
|
@keyup.esc="() => showSetLimitInput = false"
|
|
class="input"
|
|
type="number"
|
|
min="0"
|
|
v-focus.always
|
|
v-model="bucket.limit"
|
|
/>
|
|
</div>
|
|
<div class="control">
|
|
<x-button
|
|
:disabled="bucket.limit < 0"
|
|
:icon="['far', 'save']"
|
|
:shadow="false"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<template v-else>
|
|
{{
|
|
$t('list.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('list.kanban.noLimit')})
|
|
}}
|
|
</template>
|
|
</a>
|
|
<a
|
|
@click.stop="toggleDoneBucket(bucket)"
|
|
class="dropdown-item"
|
|
v-tooltip="$t('list.kanban.doneBucketHintExtended')"
|
|
>
|
|
<span class="icon is-small" :class="{'has-text-success': bucket.isDoneBucket}">
|
|
<icon icon="check-double"/>
|
|
</span>
|
|
{{ $t('list.kanban.doneBucket') }}
|
|
</a>
|
|
<a
|
|
class="dropdown-item"
|
|
@click.stop="() => collapseBucket(bucket)"
|
|
>
|
|
{{ $t('list.kanban.collapse') }}
|
|
</a>
|
|
<a
|
|
:class="{'is-disabled': buckets.length <= 1}"
|
|
@click.stop="() => deleteBucketModal(bucket.id)"
|
|
class="dropdown-item has-text-danger"
|
|
v-tooltip="buckets.length <= 1 ? $t('list.kanban.deleteLast') : ''"
|
|
>
|
|
<span class="icon is-small">
|
|
<icon icon="trash-alt"/>
|
|
</span>
|
|
{{ $t('misc.delete') }}
|
|
</a>
|
|
</dropdown>
|
|
</div>
|
|
<div :ref="`tasks-container${bucket.id}`" class="tasks">
|
|
<draggable
|
|
v-model="bucket.tasks"
|
|
@start="() => dragstart(bucket)"
|
|
@end="updateTaskPosition"
|
|
:group="{name: 'tasks', put: shouldAcceptDrop(bucket) && !dragBucket}"
|
|
v-bind="dragOptions"
|
|
:disabled="!canWrite"
|
|
:class="{'dragging-disabled': !canWrite}"
|
|
:data-bucket-index="k"
|
|
class="dropper"
|
|
>
|
|
<transition-group type="transition" :name="!drag ? 'move-card': null" tag="div">
|
|
<kanban-card
|
|
:key="`bucket${bucket.id}-task${task.id}`"
|
|
v-for="task in bucket.tasks"
|
|
:task="task"
|
|
/>
|
|
</transition-group>
|
|
</draggable>
|
|
</div>
|
|
<div class="bucket-footer" v-if="canWrite">
|
|
<div class="field" v-if="showNewTaskInput[bucket.id]">
|
|
<div class="control" :class="{'is-loading': loading}">
|
|
<input
|
|
class="input"
|
|
:disabled="loading"
|
|
@focusout="toggleShowNewTaskInput(bucket.id)"
|
|
@keyup.enter="addTaskToBucket(bucket.id)"
|
|
@keyup.esc="toggleShowNewTaskInput(bucket.id)"
|
|
:placeholder="$t('list.kanban.addTaskPlaceholder')"
|
|
type="text"
|
|
v-focus.always
|
|
v-model="newTaskText"
|
|
/>
|
|
</div>
|
|
<p class="help is-danger" v-if="newTaskError[bucket.id] && newTaskText === ''">
|
|
{{ $t('list.list.addTitleRequired') }}
|
|
</p>
|
|
</div>
|
|
<x-button
|
|
@click="toggleShowNewTaskInput(bucket.id)"
|
|
class="is-transparent is-fullwidth has-text-centered"
|
|
:shadow="false"
|
|
v-if="!showNewTaskInput[bucket.id]"
|
|
icon="plus"
|
|
type="secondary"
|
|
>
|
|
{{
|
|
bucket.tasks.length === 0 ? $t('list.kanban.addTask') : $t('list.kanban.addAnotherTask')
|
|
}}
|
|
</x-button>
|
|
</div>
|
|
</div>
|
|
</transition-group>
|
|
</draggable>
|
|
|
|
<div class="bucket new-bucket" v-if="canWrite && !loading && buckets.length > 0">
|
|
<input
|
|
:class="{'is-loading': loading}"
|
|
:disabled="loading"
|
|
@focusout="() => showNewBucketInput = false"
|
|
@keyup.enter="createNewBucket"
|
|
@keyup.esc="() => showNewBucketInput = false"
|
|
class="input"
|
|
:placeholder="$t('list.kanban.addBucketPlaceholder')"
|
|
type="text"
|
|
v-focus.always
|
|
v-if="showNewBucketInput"
|
|
v-model="newBucketTitle"
|
|
/>
|
|
<x-button
|
|
@click="() => showNewBucketInput = true"
|
|
:shadow="false"
|
|
class="is-transparent is-fullwidth has-text-centered"
|
|
v-if="!showNewBucketInput"
|
|
type="secondary"
|
|
icon="plus"
|
|
>
|
|
{{ $t('list.kanban.addBucket') }}
|
|
</x-button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- This router view is used to show the task popup while keeping the kanban board itself -->
|
|
<transition name="modal">
|
|
<router-view/>
|
|
</transition>
|
|
|
|
<transition name="modal">
|
|
<modal
|
|
@close="showBucketDeleteModal = false"
|
|
@submit="deleteBucket()"
|
|
v-if="showBucketDeleteModal">
|
|
<span slot="header">{{ $t('list.kanban.deleteHeaderBucket') }}</span>
|
|
<p slot="text">
|
|
{{ $t('list.kanban.deleteBucketText1') }}<br/>
|
|
{{ $t('list.kanban.deleteBucketText2') }}
|
|
</p>
|
|
</modal>
|
|
</transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import isTouchDevice from 'is-touch-device'
|
|
import draggable from 'vuedraggable'
|
|
|
|
import BucketModel from '../../../models/bucket'
|
|
import {filterObject} from '@/helpers/filterObject'
|
|
import {mapState} from 'vuex'
|
|
import {saveListView} from '@/helpers/saveListView'
|
|
import Rights from '../../../models/rights.json'
|
|
import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
|
|
import FilterPopup from '@/components/list/partials/filter-popup.vue'
|
|
import Dropdown from '@/components/misc/dropdown.vue'
|
|
import createTask from '../../../components/tasks/mixins/createTask'
|
|
import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState'
|
|
import {calculateItemPosition} from '../../../helpers/calculateItemPosition'
|
|
import KanbanCard from '../../../components/tasks/partials/kanban-card'
|
|
|
|
export default {
|
|
name: 'Kanban',
|
|
components: {
|
|
KanbanCard,
|
|
Dropdown,
|
|
FilterPopup,
|
|
draggable,
|
|
},
|
|
data() {
|
|
return {
|
|
drag: false,
|
|
dragBucket: false,
|
|
sourceBucket: 0,
|
|
|
|
showBucketDeleteModal: false,
|
|
bucketToDelete: 0,
|
|
bucketTitleEditable: false,
|
|
|
|
newTaskText: '',
|
|
showNewTaskInput: {},
|
|
newBucketTitle: '',
|
|
showNewBucketInput: false,
|
|
newTaskError: {},
|
|
showSetLimitInput: false,
|
|
collapsedBuckets: {},
|
|
|
|
// We're using this to show the loading animation only at the task when updating it
|
|
taskUpdating: {},
|
|
oneTaskUpdating: false,
|
|
|
|
params: {
|
|
filter_by: [],
|
|
filter_value: [],
|
|
filter_comparator: [],
|
|
filter_concat: 'and',
|
|
},
|
|
showFilters: false,
|
|
filtersChanged: false, // To trigger a reload of the board
|
|
}
|
|
},
|
|
mixins: [
|
|
createTask,
|
|
],
|
|
created() {
|
|
this.loadBuckets()
|
|
|
|
// Save the current list view to local storage
|
|
// We use local storage and not vuex here to make it persistent across reloads.
|
|
saveListView(this.$route.params.listId, this.$route.name)
|
|
},
|
|
watch: {
|
|
'$route.params.listId': 'loadBuckets',
|
|
},
|
|
computed: {
|
|
buckets: {
|
|
get() {
|
|
return this.$store.state.kanban.buckets
|
|
},
|
|
set(value) {
|
|
this.$store.commit('kanban/setBuckets', value)
|
|
},
|
|
},
|
|
dragOptions() {
|
|
const options = {
|
|
animation: 150,
|
|
ghostClass: 'ghost',
|
|
dragClass: 'task-dragging',
|
|
}
|
|
|
|
if (isTouchDevice()) {
|
|
options.delay = 150
|
|
}
|
|
|
|
return options
|
|
},
|
|
...mapState({
|
|
loadedListId: state => state.kanban.listId,
|
|
loading: state => state[LOADING] && state[LOADING_MODULE] === 'kanban',
|
|
taskLoading: state => state[LOADING] && state[LOADING_MODULE] === 'tasks',
|
|
canWrite: state => state.currentList.maxRight > Rights.READ,
|
|
list: state => state.currentList,
|
|
}),
|
|
},
|
|
methods: {
|
|
loadBuckets() {
|
|
|
|
// Prevent trying to load buckets if the task popup view is active
|
|
if (this.$route.name !== 'list.kanban') {
|
|
return
|
|
}
|
|
|
|
// Only load buckets if we don't already loaded them
|
|
if (
|
|
!this.filtersChanged && (
|
|
this.loadedListId === this.$route.params.listId ||
|
|
this.loadedListId === parseInt(this.$route.params.listId))
|
|
) {
|
|
return
|
|
}
|
|
|
|
this.collapsedBuckets = getCollapsedBucketState(this.$route.params.listId)
|
|
|
|
console.debug(`Loading buckets, loadedListId = ${this.loadedListId}, $route.params =`, this.$route.params)
|
|
this.filtersChanged = false
|
|
|
|
const minScrollHeightPercent = 0.25
|
|
|
|
this.$store.dispatch('kanban/loadBucketsForList', {listId: this.$route.params.listId, params: this.params})
|
|
.then(bs => {
|
|
bs.forEach(b => {
|
|
const e = this.$refs[`tasks-container${b.id}`][0]
|
|
e.addEventListener('scroll', () => {
|
|
const scrollTopMax = e.scrollHeight - e.clientHeight
|
|
if (scrollTopMax <= e.scrollTop + e.scrollTop * minScrollHeightPercent) {
|
|
this.$store.dispatch('kanban/loadNextTasksForBucket', {
|
|
listId: this.$route.params.listId,
|
|
params: this.params,
|
|
bucketId: b.id,
|
|
})
|
|
.catch(e => {
|
|
this.error(e)
|
|
})
|
|
}
|
|
})
|
|
})
|
|
})
|
|
.catch(e => {
|
|
this.error(e)
|
|
})
|
|
},
|
|
updateTaskPosition(e) {
|
|
this.drag = false
|
|
|
|
// While we could just pass the bucket index in through the function call, this would not give us the
|
|
// new bucket id when a task has been moved between buckets, only the new bucket. Using the data-bucket-id
|
|
// of the drop target works all the time.
|
|
const bucketIndex = parseInt(e.to.parentNode.dataset.bucketIndex)
|
|
|
|
const newBucket = this.buckets[bucketIndex]
|
|
const task = newBucket.tasks[e.newIndex]
|
|
const taskBefore = newBucket.tasks[e.newIndex - 1] ?? null
|
|
const taskAfter = newBucket.tasks[e.newIndex + 1] ?? null
|
|
|
|
task.kanbanPosition = calculateItemPosition(taskBefore !== null ? taskBefore.kanbanPosition : null, taskAfter !== null ? taskAfter.kanbanPosition : null)
|
|
task.bucketId = newBucket.id
|
|
|
|
this.$store.dispatch('tasks/update', task)
|
|
.catch(e => {
|
|
this.error(e)
|
|
})
|
|
.finally(() => {
|
|
this.$set(this.taskUpdating, task.id, false)
|
|
this.oneTaskUpdating = false
|
|
})
|
|
},
|
|
toggleShowNewTaskInput(bucket) {
|
|
this.$set(this.showNewTaskInput, bucket, !this.showNewTaskInput[bucket])
|
|
},
|
|
addTaskToBucket(bucketId) {
|
|
|
|
if (this.newTaskText === '') {
|
|
this.$set(this.newTaskError, bucketId, true)
|
|
return
|
|
}
|
|
this.$set(this.newTaskError, bucketId, false)
|
|
|
|
this.createNewTask(this.newTaskText, bucketId)
|
|
.then(r => {
|
|
this.newTaskText = ''
|
|
this.$store.commit('kanban/addTaskToBucket', r)
|
|
})
|
|
.catch(e => {
|
|
this.error(e)
|
|
})
|
|
.finally(() => {
|
|
if (!this.$refs[`tasks-container${bucketId}`][0]) {
|
|
return
|
|
}
|
|
this.$refs[`tasks-container${bucketId}`][0].scrollTop = this.$refs[`tasks-container${bucketId}`][0].scrollHeight
|
|
})
|
|
},
|
|
createNewBucket() {
|
|
if (this.newBucketTitle === '') {
|
|
return
|
|
}
|
|
|
|
const newBucket = new BucketModel({
|
|
title: this.newBucketTitle,
|
|
listId: parseInt(this.$route.params.listId),
|
|
})
|
|
|
|
this.$store.dispatch('kanban/createBucket', newBucket)
|
|
.then(() => {
|
|
this.newBucketTitle = ''
|
|
this.showNewBucketInput = false
|
|
})
|
|
.catch(e => {
|
|
this.error(e)
|
|
})
|
|
},
|
|
deleteBucketModal(bucketId) {
|
|
if (this.buckets.length <= 1) {
|
|
return
|
|
}
|
|
|
|
this.bucketToDelete = bucketId
|
|
this.showBucketDeleteModal = true
|
|
},
|
|
deleteBucket() {
|
|
const bucket = new BucketModel({
|
|
id: this.bucketToDelete,
|
|
listId: this.$route.params.listId,
|
|
})
|
|
|
|
this.$store.dispatch('kanban/deleteBucket', {bucket: bucket, params: this.params})
|
|
.then(() => {
|
|
this.success({message: this.$t('list.kanban.deleteBucketSuccess')})
|
|
})
|
|
.catch(e => {
|
|
this.error(e)
|
|
})
|
|
.finally(() => {
|
|
this.showBucketDeleteModal = false
|
|
})
|
|
},
|
|
focusBucketTitle(e) {
|
|
// This little helper allows us to drag a bucket around at the title without focusing on it right away.
|
|
this.bucketTitleEditable = true
|
|
this.$nextTick(() => e.target.focus())
|
|
},
|
|
saveBucketTitle(bucketId) {
|
|
this.bucketTitleEditable = false
|
|
const bucketTitleElement = this.$refs[`bucket${bucketId}title`][0]
|
|
const bucketTitle = bucketTitleElement.textContent
|
|
const bucket = new BucketModel({
|
|
id: bucketId,
|
|
title: bucketTitle,
|
|
listId: Number(this.$route.params.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.$store.dispatch('kanban/updateBucket', bucket)
|
|
.then(r => {
|
|
realBucket.title = r.title
|
|
bucketTitleElement.blur()
|
|
this.success({message: this.$t('list.kanban.bucketTitleSavedSuccess')})
|
|
})
|
|
.catch(e => {
|
|
this.error(e)
|
|
})
|
|
},
|
|
updateBucket(bucket) {
|
|
bucket.limit = parseInt(bucket.limit)
|
|
this.$store.dispatch('kanban/updateBucket', bucket)
|
|
.then(() => {
|
|
this.success({message: this.$t('list.kanban.bucketLimitSavedSuccess')})
|
|
})
|
|
.catch(e => {
|
|
this.error(e)
|
|
})
|
|
},
|
|
updateBucketPosition(e) {
|
|
this.dragBucket = false
|
|
|
|
const bucket = this.buckets[e.newIndex]
|
|
const bucketBefore = this.buckets[e.newIndex - 1] ?? null
|
|
const bucketAfter = this.buckets[e.newIndex + 1] ?? null
|
|
|
|
bucket.position = calculateItemPosition(bucketBefore !== null ? bucketBefore.position : null, bucketAfter !== null ? bucketAfter.position : null)
|
|
|
|
this.$store.dispatch('kanban/updateBucket', bucket)
|
|
.catch(e => {
|
|
this.error(e)
|
|
})
|
|
},
|
|
setBucketLimit(bucket) {
|
|
if (bucket.limit < 0) {
|
|
return
|
|
}
|
|
|
|
this.updateBucket(bucket)
|
|
},
|
|
shouldAcceptDrop(bucket) {
|
|
return bucket.id === this.sourceBucket || // When dragging from a bucket who has its limit reached, dragging should still be possible
|
|
bucket.limit === 0 || // If there is no limit set, dragging & dropping should always work
|
|
bucket.tasks.length < bucket.limit // Disallow dropping to buckets which have their limit reached
|
|
},
|
|
dragstart(bucket) {
|
|
this.drag = true
|
|
this.sourceBucket = bucket.id
|
|
},
|
|
toggleDoneBucket(bucket) {
|
|
bucket.isDoneBucket = !bucket.isDoneBucket
|
|
this.$store.dispatch('kanban/updateBucket', bucket)
|
|
.then(() => {
|
|
this.success({message: this.$t('list.kanban.doneBucketSavedSuccess')})
|
|
})
|
|
.catch(e => {
|
|
this.error(e)
|
|
bucket.isDoneBucket = !bucket.isDoneBucket
|
|
})
|
|
},
|
|
collapseBucket(bucket) {
|
|
this.$set(this.collapsedBuckets, bucket.id, true)
|
|
saveCollapsedBucketState(this.$route.params.listId, this.collapsedBuckets)
|
|
},
|
|
unCollapseBucket(bucket) {
|
|
if (!this.collapsedBuckets[bucket.id]) {
|
|
return
|
|
}
|
|
|
|
this.$set(this.collapsedBuckets, bucket.id, false)
|
|
saveCollapsedBucketState(this.$route.params.listId, this.collapsedBuckets)
|
|
},
|
|
},
|
|
}
|
|
</script>
|