Reorder tasks, lists and kanban buckets (#620)
Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/620 Co-authored-by: konrad <konrad@kola-entertainments.de> Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
@ -49,7 +49,7 @@
|
||||
</div>
|
||||
|
||||
<aside class="menu namespaces-lists loader-container" :class="{'is-loading': loading}">
|
||||
<template v-for="n in namespaces">
|
||||
<template v-for="(n, nk) in namespaces">
|
||||
<div :key="n.id" class="namespace-title" :class="{'has-menu': n.id > 0}">
|
||||
<span
|
||||
@click="toggleLists(n.id)"
|
||||
@ -73,38 +73,59 @@
|
||||
</a>
|
||||
<namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/>
|
||||
</div>
|
||||
<div :key="n.id + 'child'" class="more-container" v-if="typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true">
|
||||
<div
|
||||
:key="n.id + 'child'"
|
||||
class="more-container"
|
||||
v-if="typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true"
|
||||
>
|
||||
<ul class="menu-list can-be-hidden">
|
||||
<template v-for="l in n.lists">
|
||||
<!-- This is a bit ugly but vue wouldn't want to let me filter this - probably because the lists
|
||||
are nested inside of the namespaces makes it a lot harder.-->
|
||||
<li :key="l.id" v-if="!l.isArchived">
|
||||
<router-link
|
||||
class="list-menu-link"
|
||||
:class="{'router-link-exact-active': currentList.id === l.id}"
|
||||
:to="{ name: 'list.index', params: { listId: l.id} }"
|
||||
tag="span"
|
||||
<draggable
|
||||
v-model="n.lists"
|
||||
:group="`namespace-${n.id}-lists`"
|
||||
@start="() => drag = true"
|
||||
@end="e => saveListPosition(e, nk)"
|
||||
v-bind="dragOptions"
|
||||
handle=".handle"
|
||||
>
|
||||
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
|
||||
<!-- eslint-disable vue/no-use-v-if-with-v-for,vue/no-confusing-v-for-v-if -->
|
||||
<li
|
||||
v-for="l in n.lists"
|
||||
:key="l.id"
|
||||
v-if="!l.isArchived"
|
||||
class="loader-container"
|
||||
:class="{'is-loading': listUpdating[l.id]}"
|
||||
>
|
||||
<span
|
||||
:style="{ backgroundColor: l.hexColor }"
|
||||
class="color-bubble"
|
||||
v-if="l.hexColor !== ''">
|
||||
</span>
|
||||
<span class="list-menu-title">
|
||||
{{ getListTitle(l) }}
|
||||
</span>
|
||||
<span
|
||||
:class="{'is-favorite': l.isFavorite}"
|
||||
@click.stop="toggleFavoriteList(l)"
|
||||
class="favorite">
|
||||
<icon icon="star" v-if="l.isFavorite"/>
|
||||
<icon :icon="['far', 'star']" v-else/>
|
||||
</span>
|
||||
</router-link>
|
||||
<list-settings-dropdown :list="l" v-if="l.id > 0"/>
|
||||
<span class="list-setting-spacer" v-else></span>
|
||||
</li>
|
||||
</template>
|
||||
<router-link
|
||||
class="list-menu-link"
|
||||
:class="{'router-link-exact-active': currentList.id === l.id}"
|
||||
:to="{ name: 'list.index', params: { listId: l.id} }"
|
||||
tag="span"
|
||||
>
|
||||
<span class="icon handle">
|
||||
<icon icon="grip-lines"/>
|
||||
</span>
|
||||
<span
|
||||
:style="{ backgroundColor: l.hexColor }"
|
||||
class="color-bubble"
|
||||
v-if="l.hexColor !== ''">
|
||||
</span>
|
||||
<span class="list-menu-title">
|
||||
{{ getListTitle(l) }}
|
||||
</span>
|
||||
<span
|
||||
:class="{'is-favorite': l.isFavorite}"
|
||||
@click.stop="toggleFavoriteList(l)"
|
||||
class="favorite">
|
||||
<icon icon="star" v-if="l.isFavorite"/>
|
||||
<icon :icon="['far', 'star']" v-else/>
|
||||
</span>
|
||||
</router-link>
|
||||
<list-settings-dropdown :list="l" v-if="l.id > 0"/>
|
||||
<span class="list-setting-spacer" v-else></span>
|
||||
</li>
|
||||
</transition-group>
|
||||
</draggable>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@ -120,17 +141,26 @@ import {mapState} from 'vuex'
|
||||
import {CURRENT_LIST, MENU_ACTIVE, LOADING, LOADING_MODULE} from '@/store/mutation-types'
|
||||
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
|
||||
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
|
||||
export default {
|
||||
name: 'navigation',
|
||||
data() {
|
||||
return {
|
||||
listsVisible: {},
|
||||
drag: false,
|
||||
dragOptions: {
|
||||
animation: 100,
|
||||
ghostClass: 'ghost',
|
||||
},
|
||||
listUpdating: {},
|
||||
}
|
||||
},
|
||||
components: {
|
||||
ListSettingsDropdown,
|
||||
NamespaceSettingsDropdown,
|
||||
draggable,
|
||||
},
|
||||
computed: mapState({
|
||||
namespaces: state => state.namespaces.namespaces.filter(n => !n.isArchived),
|
||||
@ -176,6 +206,23 @@ export default {
|
||||
toggleLists(namespaceId) {
|
||||
this.$set(this.listsVisible, namespaceId, !this.listsVisible[namespaceId] ?? false)
|
||||
},
|
||||
saveListPosition(e, namespaceIndex) {
|
||||
const listsFiltered = this.namespaces[namespaceIndex].lists.filter(l => !l.isArchived)
|
||||
const list = listsFiltered[e.newIndex]
|
||||
const listBefore = listsFiltered[e.newIndex - 1] ?? null
|
||||
const listAfter = listsFiltered[e.newIndex + 1] ?? null
|
||||
this.$set(this.listUpdating, list.id, true)
|
||||
|
||||
list.position = calculateItemPosition(listBefore !== null ? listBefore.position : null, listAfter !== null ? listAfter.position : null)
|
||||
|
||||
this.$store.dispatch('lists/updateList', list)
|
||||
.catch(e => {
|
||||
this.error(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.$set(this.listUpdating, list.id, false)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
@ -66,6 +66,12 @@ export default {
|
||||
this.labelService = new LabelService()
|
||||
this.labelTaskService = new LabelTaskService()
|
||||
},
|
||||
props: {
|
||||
defaultPosition: {
|
||||
type: Number,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addTask() {
|
||||
if (this.newTaskTitle === '') {
|
||||
@ -74,7 +80,7 @@ export default {
|
||||
}
|
||||
this.errorMessage = ''
|
||||
|
||||
this.createNewTask(this.newTaskTitle, 0, this.$store.state.auth.settings.defaultListId)
|
||||
this.createNewTask(this.newTaskTitle, 0, this.$store.state.auth.settings.defaultListId, this.defaultPosition)
|
||||
.then(task => {
|
||||
this.newTaskTitle = ''
|
||||
this.$emit('taskAdded', task)
|
||||
|
@ -6,10 +6,12 @@ import LabelModel from '@/models/label'
|
||||
import LabelTaskService from '@/services/labelTask'
|
||||
import {mapState} from 'vuex'
|
||||
import UserService from '@/services/user'
|
||||
import TaskService from '@/services/task'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
taskService: TaskService,
|
||||
labelTaskService: LabelTaskService,
|
||||
userService: UserService,
|
||||
}
|
||||
@ -17,12 +19,13 @@ export default {
|
||||
created() {
|
||||
this.labelTaskService = new LabelTaskService()
|
||||
this.userService = new UserService()
|
||||
this.taskService = new TaskService()
|
||||
},
|
||||
computed: mapState({
|
||||
labels: state => state.labels.labels,
|
||||
}),
|
||||
methods: {
|
||||
createNewTask(newTaskTitle, bucketId = 0, lId = 0) {
|
||||
createNewTask(newTaskTitle, bucketId = 0, lId = 0, position = 0) {
|
||||
const parsedTask = parseTaskText(newTaskTitle)
|
||||
const assignees = []
|
||||
|
||||
@ -36,14 +39,17 @@ export default {
|
||||
const list = this.$store.getters['lists/findListByExactname'](parsedTask.list)
|
||||
listId = list === null ? null : list.id
|
||||
}
|
||||
if (listId === null) {
|
||||
listId = lId !== 0 ? lId : this.$route.params.listId
|
||||
if (lId !== 0) {
|
||||
listId = lId
|
||||
}
|
||||
if (typeof this.$route.params.listId !== 'undefined') {
|
||||
listId = parseInt(this.$route.params.listId)
|
||||
}
|
||||
|
||||
if (typeof listId === 'undefined' || listId === 0) {
|
||||
if (typeof listId === 'undefined' || listId === null) {
|
||||
return Promise.reject('NO_LIST')
|
||||
}
|
||||
|
||||
|
||||
// Separate closure because we need to wait for the results of the user search if users were entered in the
|
||||
// task create request. Because _that_ happens in a promise, we'll need something to call when it resolves.
|
||||
const createTask = () => {
|
||||
@ -54,6 +60,7 @@ export default {
|
||||
priority: parsedTask.priority,
|
||||
assignees: assignees,
|
||||
bucketId: bucketId,
|
||||
position: position,
|
||||
})
|
||||
return this.taskService.create(task)
|
||||
.then(task => {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import TaskCollectionService from '../../../services/taskCollection'
|
||||
import cloneDeep from 'lodash/cloneDeep'
|
||||
import {calculateItemPosition} from '../../../helpers/calculateItemPosition'
|
||||
|
||||
/**
|
||||
* This mixin provides a base set of methods and properties to get tasks on a list.
|
||||
@ -20,7 +21,7 @@ export default {
|
||||
|
||||
showTaskFilter: false,
|
||||
params: {
|
||||
sort_by: ['done', 'id'],
|
||||
sort_by: ['position', 'id'],
|
||||
order_by: ['asc', 'desc'],
|
||||
filter_by: ['done'],
|
||||
filter_value: ['false'],
|
||||
@ -148,9 +149,9 @@ export default {
|
||||
if (a.done > b.done)
|
||||
return 1
|
||||
|
||||
if (a.id > b.id)
|
||||
if (a.position < b.position)
|
||||
return -1
|
||||
if (a.id < b.id)
|
||||
if (a.position > b.position)
|
||||
return 1
|
||||
return 0
|
||||
})
|
||||
@ -187,5 +188,22 @@ export default {
|
||||
},
|
||||
}
|
||||
},
|
||||
saveTaskPosition(e) {
|
||||
this.drag = false
|
||||
|
||||
const task = this.tasks[e.newIndex]
|
||||
const taskBefore = this.tasks[e.newIndex - 1] ?? null
|
||||
const taskAfter = this.tasks[e.newIndex + 1] ?? null
|
||||
|
||||
task.position = calculateItemPosition(taskBefore !== null ? taskBefore.position : null, taskAfter !== null ? taskAfter.position : null)
|
||||
|
||||
this.$store.dispatch('tasks/update', task)
|
||||
.then(r => {
|
||||
this.$set(this.tasks, e.newIndex, r)
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
111
src/components/tasks/partials/kanban-card.vue
Normal file
111
src/components/tasks/partials/kanban-card.vue
Normal file
@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'is-loading': loadingInternal || loading,
|
||||
'draggable': !(loadingInternal || loading),
|
||||
'has-light-text': !colorIsDark(task.hexColor) && task.hexColor !== `#${task.defaultColor}` && task.hexColor !== task.defaultColor,
|
||||
}"
|
||||
:style="{'background-color': task.hexColor !== '#' && task.hexColor !== `#${task.defaultColor}` ? task.hexColor : false}"
|
||||
@click.ctrl="() => markTaskAsDone(task)"
|
||||
@click.exact="() => $router.push({ name: 'task.kanban.detail', params: { id: task.id } })"
|
||||
@click.meta="() => markTaskAsDone(task)"
|
||||
class="task loader-container draggable"
|
||||
>
|
||||
<span class="task-id">
|
||||
<span class="is-done" v-if="task.done">Done</span>
|
||||
<template v-if="task.identifier === ''">
|
||||
#{{ task.index }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ task.identifier }}
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
|
||||
class="due-date"
|
||||
v-if="task.dueDate > 0"
|
||||
v-tooltip="formatDate(task.dueDate)">
|
||||
<span class="icon">
|
||||
<icon :icon="['far', 'calendar-alt']"/>
|
||||
</span>
|
||||
<span>
|
||||
{{ formatDateSince(task.dueDate) }}
|
||||
</span>
|
||||
</span>
|
||||
<h3>{{ task.title }}</h3>
|
||||
<progress
|
||||
class="progress is-small"
|
||||
v-if="task.percentDone > 0"
|
||||
:value="task.percentDone * 100" max="100">
|
||||
{{ task.percentDone * 100 }}%
|
||||
</progress>
|
||||
<div class="footer">
|
||||
<labels :labels="task.labels"/>
|
||||
<priority-label :priority="task.priority"/>
|
||||
<div class="assignees" v-if="task.assignees.length > 0">
|
||||
<user
|
||||
:avatar-size="24"
|
||||
:key="task.id + 'assignee' + u.id"
|
||||
:show-username="false"
|
||||
:user="u"
|
||||
v-for="u in task.assignees"
|
||||
/>
|
||||
</div>
|
||||
<span class="icon" v-if="task.attachments.length > 0">
|
||||
<icon icon="paperclip"/>
|
||||
</span>
|
||||
<span v-if="task.description" class="icon">
|
||||
<icon icon="align-left"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {playPop} from '../../../helpers/playPop'
|
||||
import PriorityLabel from '../../../components/tasks/partials/priorityLabel'
|
||||
import User from '../../../components/misc/user'
|
||||
import Labels from '../../../components/tasks/partials/labels'
|
||||
|
||||
export default {
|
||||
name: 'kanban-card',
|
||||
components: {
|
||||
PriorityLabel,
|
||||
User,
|
||||
Labels,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loadingInternal: false,
|
||||
}
|
||||
},
|
||||
props: {
|
||||
task: {
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
markTaskAsDone(task) {
|
||||
this.loadingInternal = true
|
||||
task.done = !task.done
|
||||
this.$store.dispatch('tasks/update', task)
|
||||
.then(() => {
|
||||
if (task.done) {
|
||||
playPop()
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingInternal = false
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
Reference in New Issue
Block a user