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:
konrad 2021-07-28 19:56:29 +00:00
parent 39ef4b48f2
commit 3c7f8d7aa2
23 changed files with 1524 additions and 1266 deletions

View file

@ -436,26 +436,23 @@ describe('Lists', () => {
.should('exist') .should('exist')
}) })
it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
// The following test does not work. It seems like vue-smooth-dnd does not use either mousemove or dragstart cy.get('.kanban .bucket .tasks .task')
// (not sure why this actually works at all?) and as I'm planning to swap that out for vuedraggable/sortable.js .contains(tasks[0].title)
// anyway, I figured it wouldn't be worth the hassle right now. .first()
.drag('.kanban .bucket:nth-child(2) .tasks .dropper div')
// it('Can drag tasks around', () => {
// const tasks = TaskFactory.create(2, { cy.get('.kanban .bucket:nth-child(2) .tasks')
// list_id: 1, .should('contain', tasks[0].title)
// bucket_id: 1, cy.get('.kanban .bucket:nth-child(1) .tasks')
// }) .should('not.contain', tasks[0].title)
// cy.visit('/lists/1/kanban') })
//
// cy.get('.kanban .bucket .tasks .task')
// .contains(tasks[0].title)
// .first()
// .drag('.kanban .bucket:nth-child(2) .tasks .smooth-dnd-container.vertical')
// .trigger('mousedown', {which: 1})
// .trigger('mousemove', {clientX: 500, clientY: 0})
// .trigger('mouseup', {force: true})
// })
it('Should navigate to the task when the task card is clicked', () => { it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, { const tasks = TaskFactory.create(5, {

View file

@ -1,3 +1,4 @@
import './commands' import './commands'
import 'cypress-file-upload' import 'cypress-file-upload'
import '@4tw/cypress-drag-drop'

View file

@ -21,6 +21,7 @@
"date-fns": "2.23.0", "date-fns": "2.23.0",
"dompurify": "2.3.0", "dompurify": "2.3.0",
"highlight.js": "11.1.0", "highlight.js": "11.1.0",
"is-touch-device": "^1.0.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"marked": "2.1.3", "marked": "2.1.3",
"register-service-worker": "1.7.2", "register-service-worker": "1.7.2",
@ -32,11 +33,12 @@
"vue-easymde": "1.4.0", "vue-easymde": "1.4.0",
"vue-i18n": "8.25.0", "vue-i18n": "8.25.0",
"vue-shortkey": "3.1.7", "vue-shortkey": "3.1.7",
"vue-smooth-dnd": "0.8.1", "vuedraggable": "^2.24.3",
"vuex": "3.6.2", "vuex": "3.6.2",
"workbox-precaching": "6.1.5" "workbox-precaching": "6.1.5"
}, },
"devDependencies": { "devDependencies": {
"@4tw/cypress-drag-drop": "^1.8.0",
"@fortawesome/fontawesome-svg-core": "1.2.35", "@fortawesome/fontawesome-svg-core": "1.2.35",
"@fortawesome/free-regular-svg-icons": "5.15.3", "@fortawesome/free-regular-svg-icons": "5.15.3",
"@fortawesome/free-solid-svg-icons": "5.15.3", "@fortawesome/free-solid-svg-icons": "5.15.3",

View file

@ -1,5 +1,5 @@
<template> <template>
<div> <div :class="{'is-touch': isTouch}">
<div :class="{'is-hidden': !online}"> <div :class="{'is-hidden': !online}">
<!-- This is a workaround to get the sw to "see" the to-be-cached version of the offline background image --> <!-- This is a workaround to get the sw to "see" the to-be-cached version of the offline background image -->
<div class="offline" style="height: 0;width: 0;"></div> <div class="offline" style="height: 0;width: 0;"></div>
@ -24,6 +24,7 @@
<script> <script>
import {mapState} from 'vuex' import {mapState} from 'vuex'
import isTouchDevice from 'is-touch-device'
import authTypes from './models/authTypes' import authTypes from './models/authTypes'
@ -63,12 +64,17 @@ export default {
this.$router.push({name: 'home'}) this.$router.push({name: 'home'})
} }
}, },
computed: mapState({ computed: {
authUser: state => state.auth.authenticated && (state.auth.info && state.auth.info.type === authTypes.USER), isTouch() {
authLinkShare: state => state.auth.authenticated && (state.auth.info && state.auth.info.type === authTypes.LINK_SHARE), return isTouchDevice()
online: ONLINE, },
keyboardShortcutsActive: KEYBOARD_SHORTCUTS_ACTIVE, ...mapState({
}), authUser: state => state.auth.authenticated && (state.auth.info && state.auth.info.type === authTypes.USER),
authLinkShare: state => state.auth.authenticated && (state.auth.info && state.auth.info.type === authTypes.LINK_SHARE),
online: ONLINE,
keyboardShortcutsActive: KEYBOARD_SHORTCUTS_ACTIVE,
}),
},
methods: { methods: {
setupOnlineStatus() { setupOnlineStatus() {
this.$store.commit(ONLINE, navigator.onLine) this.$store.commit(ONLINE, navigator.onLine)

View file

@ -49,7 +49,7 @@
</div> </div>
<aside class="menu namespaces-lists loader-container" :class="{'is-loading': loading}"> <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}"> <div :key="n.id" class="namespace-title" :class="{'has-menu': n.id > 0}">
<span <span
@click="toggleLists(n.id)" @click="toggleLists(n.id)"
@ -73,38 +73,59 @@
</a> </a>
<namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/> <namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/>
</div> </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"> <ul class="menu-list can-be-hidden">
<template v-for="l in n.lists"> <draggable
<!-- This is a bit ugly but vue wouldn't want to let me filter this - probably because the lists v-model="n.lists"
are nested inside of the namespaces makes it a lot harder.--> :group="`namespace-${n.id}-lists`"
<li :key="l.id" v-if="!l.isArchived"> @start="() => drag = true"
<router-link @end="e => saveListPosition(e, nk)"
class="list-menu-link" v-bind="dragOptions"
:class="{'router-link-exact-active': currentList.id === l.id}" handle=".handle"
:to="{ name: 'list.index', params: { listId: l.id} }" >
tag="span" <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 <router-link
:style="{ backgroundColor: l.hexColor }" class="list-menu-link"
class="color-bubble" :class="{'router-link-exact-active': currentList.id === l.id}"
v-if="l.hexColor !== ''"> :to="{ name: 'list.index', params: { listId: l.id} }"
</span> tag="span"
<span class="list-menu-title"> >
{{ getListTitle(l) }} <span class="icon handle">
</span> <icon icon="grip-lines"/>
<span </span>
:class="{'is-favorite': l.isFavorite}" <span
@click.stop="toggleFavoriteList(l)" :style="{ backgroundColor: l.hexColor }"
class="favorite"> class="color-bubble"
<icon icon="star" v-if="l.isFavorite"/> v-if="l.hexColor !== ''">
<icon :icon="['far', 'star']" v-else/> </span>
</span> <span class="list-menu-title">
</router-link> {{ getListTitle(l) }}
<list-settings-dropdown :list="l" v-if="l.id > 0"/> </span>
<span class="list-setting-spacer" v-else></span> <span
</li> :class="{'is-favorite': l.isFavorite}"
</template> @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> </ul>
</div> </div>
</template> </template>
@ -120,17 +141,26 @@ import {mapState} from 'vuex'
import {CURRENT_LIST, MENU_ACTIVE, LOADING, LOADING_MODULE} from '@/store/mutation-types' import {CURRENT_LIST, MENU_ACTIVE, LOADING, LOADING_MODULE} from '@/store/mutation-types'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue' import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue' import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
import draggable from 'vuedraggable'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
export default { export default {
name: 'navigation', name: 'navigation',
data() { data() {
return { return {
listsVisible: {}, listsVisible: {},
drag: false,
dragOptions: {
animation: 100,
ghostClass: 'ghost',
},
listUpdating: {},
} }
}, },
components: { components: {
ListSettingsDropdown, ListSettingsDropdown,
NamespaceSettingsDropdown, NamespaceSettingsDropdown,
draggable,
}, },
computed: mapState({ computed: mapState({
namespaces: state => state.namespaces.namespaces.filter(n => !n.isArchived), namespaces: state => state.namespaces.namespaces.filter(n => !n.isArchived),
@ -176,6 +206,23 @@ export default {
toggleLists(namespaceId) { toggleLists(namespaceId) {
this.$set(this.listsVisible, namespaceId, !this.listsVisible[namespaceId] ?? false) 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> </script>

View file

@ -66,6 +66,12 @@ export default {
this.labelService = new LabelService() this.labelService = new LabelService()
this.labelTaskService = new LabelTaskService() this.labelTaskService = new LabelTaskService()
}, },
props: {
defaultPosition: {
type: Number,
required: false,
},
},
methods: { methods: {
addTask() { addTask() {
if (this.newTaskTitle === '') { if (this.newTaskTitle === '') {
@ -74,7 +80,7 @@ export default {
} }
this.errorMessage = '' 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 => { .then(task => {
this.newTaskTitle = '' this.newTaskTitle = ''
this.$emit('taskAdded', task) this.$emit('taskAdded', task)

View file

@ -6,10 +6,12 @@ import LabelModel from '@/models/label'
import LabelTaskService from '@/services/labelTask' import LabelTaskService from '@/services/labelTask'
import {mapState} from 'vuex' import {mapState} from 'vuex'
import UserService from '@/services/user' import UserService from '@/services/user'
import TaskService from '@/services/task'
export default { export default {
data() { data() {
return { return {
taskService: TaskService,
labelTaskService: LabelTaskService, labelTaskService: LabelTaskService,
userService: UserService, userService: UserService,
} }
@ -17,12 +19,13 @@ export default {
created() { created() {
this.labelTaskService = new LabelTaskService() this.labelTaskService = new LabelTaskService()
this.userService = new UserService() this.userService = new UserService()
this.taskService = new TaskService()
}, },
computed: mapState({ computed: mapState({
labels: state => state.labels.labels, labels: state => state.labels.labels,
}), }),
methods: { methods: {
createNewTask(newTaskTitle, bucketId = 0, lId = 0) { createNewTask(newTaskTitle, bucketId = 0, lId = 0, position = 0) {
const parsedTask = parseTaskText(newTaskTitle) const parsedTask = parseTaskText(newTaskTitle)
const assignees = [] const assignees = []
@ -36,14 +39,17 @@ export default {
const list = this.$store.getters['lists/findListByExactname'](parsedTask.list) const list = this.$store.getters['lists/findListByExactname'](parsedTask.list)
listId = list === null ? null : list.id listId = list === null ? null : list.id
} }
if (listId === null) { if (lId !== 0) {
listId = lId !== 0 ? lId : this.$route.params.listId 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') 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 // 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. // task create request. Because _that_ happens in a promise, we'll need something to call when it resolves.
const createTask = () => { const createTask = () => {
@ -54,6 +60,7 @@ export default {
priority: parsedTask.priority, priority: parsedTask.priority,
assignees: assignees, assignees: assignees,
bucketId: bucketId, bucketId: bucketId,
position: position,
}) })
return this.taskService.create(task) return this.taskService.create(task)
.then(task => { .then(task => {

View file

@ -1,5 +1,6 @@
import TaskCollectionService from '../../../services/taskCollection' import TaskCollectionService from '../../../services/taskCollection'
import cloneDeep from 'lodash/cloneDeep' 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. * This mixin provides a base set of methods and properties to get tasks on a list.
@ -20,7 +21,7 @@ export default {
showTaskFilter: false, showTaskFilter: false,
params: { params: {
sort_by: ['done', 'id'], sort_by: ['position', 'id'],
order_by: ['asc', 'desc'], order_by: ['asc', 'desc'],
filter_by: ['done'], filter_by: ['done'],
filter_value: ['false'], filter_value: ['false'],
@ -148,9 +149,9 @@ export default {
if (a.done > b.done) if (a.done > b.done)
return 1 return 1
if (a.id > b.id) if (a.position < b.position)
return -1 return -1
if (a.id < b.id) if (a.position > b.position)
return 1 return 1
return 0 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)
})
},
}, },
} }

View 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>

View file

@ -1,18 +0,0 @@
export const applyDrag = (arr, dragResult) => {
const {removedIndex, addedIndex, payload} = dragResult
if (removedIndex === null && addedIndex === null) return arr
const result = [...arr]
// The payload comes from the task itself
let itemToAdd = payload
if (removedIndex !== null) {
itemToAdd = result.splice(removedIndex, 1)[0]
}
if (addedIndex !== null) {
result.splice(addedIndex, 0, itemToAdd)
}
return result
}

View file

@ -0,0 +1,19 @@
export const calculateItemPosition = (positionBefore: number | null, positionAfter: number | null): number => {
if (positionBefore === null && positionAfter === null) {
return 0
}
// 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 (positionBefore === null && positionAfter !== null) {
return positionAfter / 2
}
// If there is no task after it, we just add 2^16 to the last position to have enough room in the future
if (positionBefore !== null && positionAfter === null) {
return positionBefore + Math.pow(2, 16)
}
// If we have both a task before and after it, we acually calculate the position
// @ts-ignore - can never be null but TS does not seem to understand that
return positionBefore + (positionAfter - positionBefore) / 2
}

View file

@ -0,0 +1,18 @@
import {calculateItemPosition} from './calculateItemPosition'
it('should calculate the task position', () => {
const result = calculateItemPosition(10, 100)
expect(result).toBe(55)
})
it('should return 0 if no position was provided', () => {
const result = calculateItemPosition(null, null)
expect(result).toBe(0)
})
it('should calculate the task position for the first task', () => {
const result = calculateItemPosition(null, 100)
expect(result).toBe(50)
})
it('should calculate the task position for the last task', () => {
const result = calculateItemPosition(10, null)
expect(result).toBe(65546)
})

View file

@ -72,6 +72,7 @@ import {
faShareAlt, faShareAlt,
faImage, faImage,
faBell, faBell,
faGripLines,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { import {
faCalendarAlt, faCalendarAlt,
@ -179,6 +180,7 @@ library.add(faShareAlt)
library.add(faImage) library.add(faImage)
library.add(faBell) library.add(faBell)
library.add(faBellSlash) library.add(faBellSlash)
library.add(faGripLines)
Vue.component('icon', FontAwesomeIcon) Vue.component('icon', FontAwesomeIcon)

View file

@ -104,6 +104,9 @@ export default class TaskModel extends AbstractModel {
index: 0, index: 0,
isFavorite: false, isFavorite: false,
subscription: null, subscription: null,
position: 0,
kanbanPosition: 0,
createdBy: UserModel, createdBy: UserModel,
created: null, created: null,

View file

@ -11,7 +11,7 @@ const tasksPerBucket = 25
const addTaskToBucketAndSort = (state, task) => { const addTaskToBucketAndSort = (state, task) => {
const bi = filterObject(state.buckets, b => b.id === task.bucketId) const bi = filterObject(state.buckets, b => b.id === task.bucketId)
state.buckets[bi].tasks.push(task) state.buckets[bi].tasks.push(task)
state.buckets[bi].tasks.sort((a, b) => a.position > b.position ? 1 : -1) state.buckets[bi].tasks.sort((a, b) => a.kanbanPosition > b.kanbanPosition ? 1 : -1)
} }
/** /**
@ -208,7 +208,7 @@ export default {
const params = cloneDeep(ps) const params = cloneDeep(ps)
params.sort_by = 'position' params.sort_by = 'kanban_position'
params.order_by = 'asc' params.order_by = 'asc'
let hasBucketFilter = false let hasBucketFilter = false

View file

@ -12,292 +12,314 @@ $crazy-height-calculation-tasks: '#{$crazy-height-calculation} - 1rem - 2.5rem -
$filter-container-height: '1rem - #{$switch-view-height}'; $filter-container-height: '1rem - #{$switch-view-height}';
.app-content.list\.kanban { .app-content.list\.kanban {
padding-bottom: 0; padding-bottom: 0;
} }
.kanban { .kanban {
display: flex; overflow-x: auto;
align-items: flex-start; overflow-y: hidden;
overflow-x: auto; height: calc(#{$crazy-height-calculation});
overflow-y: hidden; margin: 0 -1.5rem;
height: calc(#{$crazy-height-calculation}); padding: 0 1.5rem;
margin: 0 -1.5rem;
padding: 0 1.5rem;
@media screen and (max-width: $tablet) { @media screen and (max-width: $tablet) {
height: calc(#{$crazy-height-calculation} - #{$filter-container-height}); height: calc(#{$crazy-height-calculation} - #{$filter-container-height});
} }
.bucket { &-bucket-container {
background-color: $bucket-background; display: flex;
border-radius: $radius; align-items: flex-start;
position: relative; }
flex: 0 0 $bucket-width; .ghost {
margin: 0 $bucket-right-margin 0 0; background: transparent !important;
max-height: 100%; border: 3px dashed $grey-300 !important;
min-height: 20px; box-shadow: none !important;
max-width: $bucket-width;
.tasks { * {
max-height: calc(#{$crazy-height-calculation-tasks}); opacity: 0;
overflow: auto; }
}
@media screen and (max-width: $tablet) { .bucket {
max-height: calc(#{$crazy-height-calculation-tasks} - #{$filter-container-height}); background-color: $bucket-background;
} border-radius: $radius;
position: relative;
.task { margin: 0 $bucket-right-margin 0 0;
max-height: 100%;
min-height: 20px;
width: $bucket-width;
&:first-child { .tasks {
margin-top: 0; max-height: calc(#{$crazy-height-calculation-tasks});
} overflow: auto;
margin-top: 0;
-webkit-touch-callout: none; // iOS Safari @media screen and (max-width: $tablet) {
-webkit-user-select: none; // Safari max-height: calc(#{$crazy-height-calculation-tasks} - #{$filter-container-height});
-khtml-user-select: none; // Konqueror HTML }
-moz-user-select: none; // Old versions of Firefox
-ms-user-select: none; // Internet Explorer/Edge
user-select: none; // Non-prefixed version, currently supported by Chrome, Opera and Firefox
transition: $ease-out; .task {
cursor: pointer; -webkit-touch-callout: none; // iOS Safari
box-shadow: $shadow-xs; -webkit-user-select: none; // Safari
display: block; -khtml-user-select: none; // Konqueror HTML
-moz-user-select: none; // Old versions of Firefox
-ms-user-select: none; // Internet Explorer/Edge
user-select: none; // Non-prefixed version, currently supported by Chrome, Opera and Firefox
font-size: .9rem; //transition: $ease-out;
padding: .5rem; cursor: pointer;
margin: .5rem; box-shadow: $shadow-xs;
border-radius: $radius; display: block;
background: $task-background; border: 3px solid transparent;
&.loader-container.is-loading:after { font-size: .9rem;
width: 1.5rem; padding: .5rem;
height: 1.5rem; margin: .5rem;
top: calc(50% - .75rem); border-radius: $radius;
left: calc(50% - .75rem); background: $task-background;
border-width: 2px;
}
h3 { &.loader-container.is-loading:after {
font-family: $family-sans-serif; width: 1.5rem;
font-size: .85rem; height: 1.5rem;
word-break: break-word; top: calc(50% - .75rem);
} left: calc(50% - .75rem);
border-width: 2px;
}
.progress { h3 {
margin: 8px 0 0 0; font-family: $family-sans-serif;
width: 100%; font-size: .85rem;
height: 0.5rem; word-break: break-word;
} }
.due-date { .progress {
float: right; margin: 8px 0 0 0;
display: flex; width: 100%;
align-items: center; height: 0.5rem;
}
.icon { .due-date {
margin-right: .25rem; float: right;
} display: flex;
align-items: center;
&.overdue { .icon {
color: $red; margin-right: .25rem;
} }
}
.label-wrapper .tag { &.overdue {
margin: .5rem .5rem 0 0; color: $red;
} }
}
.footer { .label-wrapper .tag {
background: transparent; margin: .5rem .5rem 0 0;
padding: 0; }
display: flex;
flex-wrap: wrap;
align-items: center;
.tag, .assignees, .icon, .priority-label { .footer {
margin-top: .25rem; background: transparent;
margin-right: .25rem; padding: 0;
} display: flex;
flex-wrap: wrap;
align-items: center;
.assignees { .tag, .assignees, .icon, .priority-label {
display: flex; margin-top: .25rem;
margin-right: .25rem;
}
.user { .assignees {
display: inline; display: flex;
margin: 0;
img { .user {
margin: 0; display: inline;
} margin: 0;
}
}
.tag { img {
margin-left: 0; margin: 0;
} }
}
}
.priority-label { .tag {
font-size: .75rem; margin-left: 0;
height: 2rem; }
.icon { .priority-label {
height: 1rem; font-size: .75rem;
padding: 0 .25rem; height: 2rem;
margin-top: 0;
}
}
}
.footer .icon, .icon {
.due-date, height: 1rem;
.priority-label { padding: 0 .25rem;
background: $grey-100; margin-top: 0;
border-radius: $radius; }
padding: 0 .5rem; }
} }
.due-date { .footer .icon,
padding: 0 .25rem; .due-date,
} .priority-label {
background: $grey-100;
border-radius: $radius;
padding: 0 .5rem;
}
.task-id { .due-date {
color: $grey-500; padding: 0 .25rem;
font-size: .8rem; }
margin-bottom: .25rem;
display: flex;
}
.is-done { .task-id {
font-size: .75rem; color: $grey-500;
padding: .2rem .3rem; font-size: .8rem;
margin: 0 .25rem 0 0; margin-bottom: .25rem;
} display: flex;
}
&.is-moving { .is-done {
opacity: .5; font-size: .75rem;
} padding: .2rem .3rem;
margin: 0 .25rem 0 0;
}
span { &.is-moving {
width: auto; opacity: .5;
} }
&.has-light-text { span {
color: $white; width: auto;
}
.task-id { &.has-light-text {
color: $grey-200; color: $white;
}
.footer .icon, .task-id {
.due-date, color: $grey-200;
.priority-label { }
background: $grey-800;
}
.footer { .footer .icon,
.icon svg { .due-date,
fill: $white; .priority-label {
} background: $grey-800;
} }
}
}
.drop-preview { .footer {
border-radius: $radius; .icon svg {
margin: 0 .5rem .5rem; fill: $white;
background: transparent; }
border: 3px dashed $grey-300; }
} }
}
h2 { &.v-leave, &.v-leave-to, &.v-leave-active
font-size: 1rem; &.move-card-leave, &.move-card-leave-to, &.move-card-leave-active {
margin: 0; display: none;
font-weight: 600 !important; }
} }
&.new-bucket { .dropper {
// Because of reasons, this button ignores the margin we gave it to the right. &, > div {
// To make it still look like it has some, we modify the container to have a padding of 1rem, min-height: 40px;
// which is the same as the margin it should have. Then we make the container itself bigger }
// to hide the fact we just made the button smaller. }
min-width: calc(#{$bucket-width} + 1rem); }
background: transparent;
padding-right: 1rem;
.button { .move-card-move {
background: $bucket-background; transition: transform $transition-duration;
width: 100%; }
}
}
a.dropdown-item { .no-move {
padding-right: 1rem; transition: transform 0s;
} }
&.is-collapsed { h2 {
transform: rotate(90deg) translateX(math.div($bucket-width, 2) - math.div($bucket-header-height, 2)); font-size: 1rem;
// Using negative margins instead of translateY here to make all other buckets fill the empty space margin: 0;
margin-left: (math.div($bucket-width, 2) - math.div($bucket-header-height, 2)) * -1; font-weight: 600 !important;
margin-right: calc(#{(math.div($bucket-width, 2) - math.div($bucket-header-height, 2)) * -1} + #{$bucket-right-margin}); }
cursor: pointer;
.tasks, .bucket-footer { &.new-bucket {
display: none; // Because of reasons, this button ignores the margin we gave it to the right.
} // To make it still look like it has some, we modify the container to have a padding of 1rem,
} // which is the same as the margin it should have. Then we make the container itself bigger
} // to hide the fact we just made the button smaller.
min-width: calc(#{$bucket-width} + 1rem);
background: transparent;
padding-right: 1rem;
.bucket-header { .button {
display: flex; background: $bucket-background;
align-items: center; width: 100%;
justify-content: space-between; }
padding: .5rem; }
height: $bucket-header-height;
.limit { a.dropdown-item {
padding-left: .5rem; padding-right: 1rem;
font-weight: bold; }
&.is-max { &.is-collapsed {
color: $red; transform: rotate(90deg) translateX(math.div($bucket-width, 2) - math.div($bucket-header-height, 2));
} // Using negative margins instead of translateY here to make all other buckets fill the empty space
} margin-left: (math.div($bucket-width, 2) - math.div($bucket-header-height, 2)) * -1;
margin-right: calc(#{(math.div($bucket-width, 2) - math.div($bucket-header-height, 2)) * -1} + #{$bucket-right-margin});
cursor: pointer;
.title.input { .tasks, .bucket-footer {
height: auto; display: none;
padding: .4rem .5rem; }
display: inline-block; }
} }
}
.dropdown-trigger { .bucket-header {
cursor: pointer; display: flex;
padding: .5rem; align-items: center;
} justify-content: space-between;
padding: .5rem;
height: $bucket-header-height;
.bucket-footer { .limit {
padding: .5rem; padding-left: .5rem;
font-weight: bold;
.button { &.is-max {
background-color: transparent; color: $red;
}
}
&:hover { .title.input {
background-color: $white; height: auto;
} padding: .4rem .5rem;
} display: inline-block;
} cursor: pointer;
}
}
.dropdown-trigger {
cursor: pointer;
padding: .5rem;
}
.bucket-footer {
padding: .5rem;
.button {
background-color: transparent;
&:hover {
background-color: $white;
}
}
}
} }
.ghost-task { .task-dragging {
transition: transform 0.18s ease; transition: transform 0.18s ease;
transform: rotateZ(3deg) transform: rotateZ(3deg)
} }
.ghost-task-drop { .ghost-task-drop {
transition: transform 0.18s ease-in-out; transition: transform 0.18s ease-in-out;
transform: rotateZ(0deg) transform: rotateZ(0deg)
} }

View file

@ -1,341 +1,363 @@
.tasks-container { .tasks-container {
display: flex; display: flex;
.tasks { .tasks {
width: 100%; width: 100%;
}
.taskedit { .ghost {
width: 50%; border-radius: $radius;
} background: $grey-100;
border: 2px dashed $grey-300;
* {
opacity: 0;
}
}
}
.taskedit {
width: 50%;
}
} }
.tasks { .tasks {
margin-top: 1rem; margin-top: 1rem;
padding: 0; padding: 0;
text-align: left; text-align: left;
@media screen and (min-width: $tablet) { @media screen and (min-width: $tablet) {
&.short { &.short {
max-width: 53vw; max-width: 53vw;
} }
} }
@media screen and (max-width: $tablet) { @media screen and (max-width: $tablet) {
max-width: 100%; max-width: 100%;
} }
&.noborder { &.noborder {
margin: 1rem -0.5rem; margin: 1rem -0.5rem;
} }
.task { .task {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
padding: 0.5rem; padding: .4rem;
transition: background-color $transition; transition: background-color $transition;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
margin: 0 .5rem; margin: 0 .5rem;
border-radius: $radius; border-radius: $radius;
border: 2px solid transparent;
&:first-child { &:first-child {
margin-top: .5rem; margin-top: .5rem;
} }
&:last-child { &:last-child {
margin-bottom: .5rem; margin-bottom: .5rem;
} }
&:hover { &:hover {
background-color: $grey-100; background-color: $grey-100;
} }
.tasktext, .tasktext,
&.tasktext { &.tasktext {
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
display: inline-block; display: inline-block;
flex: 1 0 50%; flex: 1 0 50%;
.overdue { .overdue {
color: $red; color: $red;
} }
} }
.task-list { .task-list {
width: auto; width: auto;
color: $grey-400; color: $grey-400;
font-size: .9rem; font-size: .9rem;
white-space: nowrap; white-space: nowrap;
} }
.fancycheckbox span { .fancycheckbox span {
display: none; display: none;
} }
.color-bubble { .color-bubble {
height: 10px; height: 10px;
flex: 0 0 10px; flex: 0 0 10px;
} }
.tag { .tag {
margin: 0 0.5rem; margin: 0 0.5rem;
} }
.avatar { .avatar {
border-radius: 50%; border-radius: 50%;
vertical-align: bottom; vertical-align: bottom;
margin-left: 5px; margin-left: 5px;
height: 27px; height: 27px;
width: 27px; width: 27px;
} }
.list-task-icon { .list-task-icon {
margin-left: 6px; margin-left: 6px;
&:not(:first-of-type) { &:not(:first-of-type) {
margin-left: 8px; margin-left: 8px;
} }
}
a { }
color: $text;
transition: color ease $transition-duration;
&:hover { a {
color: $grey-900; color: $text;
} transition: color ease $transition-duration;
}
.favorite { &:hover {
opacity: 0; color: $grey-900;
text-align: center; }
width: 27px; }
transition: opacity $transition, color $transition;
&:hover { .favorite {
color: $orange; opacity: 0;
} text-align: center;
width: 27px;
transition: opacity $transition, color $transition;
&.is-favorite { &:hover {
opacity: 1; color: $orange;
color: $orange; }
}
}
&:hover .favorite { &.is-favorite {
opacity: 1; opacity: 1;
} color: $orange;
}
}
.fancycheckbox { &:hover .favorite {
height: 18px; opacity: 1;
padding-top: 0; }
padding-right: .5rem;
span { .handle {
display: none; opacity: 0;
} transition: opacity $transition;
} margin-right: .25rem;
cursor: grab;
}
.tasktext.done { &:hover .handle {
text-decoration: line-through; opacity: 1;
color: $grey-500; }
}
span.parent-tasks { .fancycheckbox {
color: $grey-500; height: 18px;
width: auto; padding-top: 0;
} padding-right: .5rem;
.remove { span {
color: $red; display: none;
} }
}
input[type="checkbox"] { .tasktext.done {
vertical-align: middle; text-decoration: line-through;
} color: $grey-500;
}
.settings { span.parent-tasks {
float: right; color: $grey-500;
width: 24px; width: auto;
cursor: pointer; }
}
&.loader-container.is-loading:after { .remove {
top: calc(50% - 1rem); color: $red;
left: calc(50% - 1rem); }
width: 2rem;
height: 2rem;
border-left-color: $grey-300;
border-bottom-color: $grey-300;
}
}
.progress { input[type="checkbox"] {
width: 50px; vertical-align: middle;
margin: 0 0.5rem 0 0; }
flex: 3 1 auto;
@media screen and (max-width: $tablet) { .settings {
margin: 0.5rem 0 0 0; float: right;
order: 1; width: 24px;
width: 100%; cursor: pointer;
} }
}
.task:last-child { &.loader-container.is-loading:after {
border-bottom: none; top: calc(50% - 1rem);
} left: calc(50% - 1rem);
width: 2rem;
height: 2rem;
border-left-color: $grey-300;
border-bottom-color: $grey-300;
}
}
.progress {
width: 50px;
margin: 0 0.5rem 0 0;
flex: 3 1 auto;
@media screen and (max-width: $tablet) {
margin: 0.5rem 0 0 0;
order: 1;
width: 100%;
}
}
.task:last-child {
border-bottom: none;
}
} }
.is-menu-enabled .tasks .task { .is-menu-enabled .tasks .task {
span:not(.tag), a { span:not(.tag), a {
.tasktext, &.tasktext { .tasktext, &.tasktext {
@media screen and (max-width: $desktop) { @media screen and (max-width: $desktop) {
max-width: calc(100vw - 27px - 2rem - 1.5rem - 3rem - #{$navbar-width}); // 1.5rem is the padding of the tasks container, 3rem is the padding of .app-container max-width: calc(100vw - 27px - 2rem - 1.5rem - 3rem - #{$navbar-width}); // 1.5rem is the padding of the tasks container, 3rem is the padding of .app-container
} }
// Duplicated rule to have it work properly in at least some browsers // Duplicated rule to have it work properly in at least some browsers
// This should be fine as the ui doesn't work in rare edge cases to begin with // This should be fine as the ui doesn't work in rare edge cases to begin with
@media screen and (max-width: calc(#{$desktop} + #{$navbar-width})) { @media screen and (max-width: calc(#{$desktop} + #{$navbar-width})) {
max-width: calc(100vw - 27px - 2rem - 1.5rem - 3rem - #{$navbar-width}); // 1.5rem is the padding of the tasks container, 3rem is the padding of .app-container max-width: calc(100vw - 27px - 2rem - 1.5rem - 3rem - #{$navbar-width}); // 1.5rem is the padding of the tasks container, 3rem is the padding of .app-container
} }
}
} }
}
} }
.taskedit { .taskedit {
min-height: calc(100% - 1rem); min-height: calc(100% - 1rem);
margin-top: 1rem; margin-top: 1rem;
.priority-select { .priority-select {
.select, select { .select, select {
width: 100%; width: 100%;
} }
} }
ul.assingees { ul.assingees {
list-style: none; list-style: none;
margin: 0; margin: 0;
li { li {
padding: 0.5rem 0.5rem 0; padding: 0.5rem 0.5rem 0;
a { a {
float: right; float: right;
color: $red; color: $red;
transition: all $transition; transition: all $transition;
} }
} }
} }
.tag { .tag {
margin-right: 0.5rem; margin-right: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
&:last-child { &:last-child {
margin-right: 0; margin-right: 0;
} }
} }
} }
.show-tasks { .show-tasks {
h3 { h3 {
text-align: left; text-align: left;
&.nothing { &.nothing {
text-align: center; text-align: center;
margin-top: 3rem; margin-top: 3rem;
} }
.input { .input {
width: 190px; width: 190px;
vertical-align: middle; vertical-align: middle;
margin: .5rem 0; margin: .5rem 0;
} }
} }
img { img {
margin-top: 2rem; margin-top: 2rem;
} }
.user img{ .user img {
margin: 0; margin: 0;
} }
.spinner.is-loading:after { .spinner.is-loading:after {
margin-left: calc(40% - 1rem); margin-left: calc(40% - 1rem);
} }
} }
.defer-task { .defer-task {
$defer-task-max-width: 350px; $defer-task-max-width: 350px;
position: absolute; position: absolute;
width: 100%; width: 100%;
max-width: $defer-task-max-width; max-width: $defer-task-max-width;
border-radius: $radius; border-radius: $radius;
border: 1px solid $grey-200; border: 1px solid $grey-200;
padding: 1rem; padding: 1rem;
margin: 1rem; margin: 1rem;
background: $white; background: $white;
color: $text; color: $text;
cursor: default; cursor: default;
z-index: 10; z-index: 10;
box-shadow: $shadow-lg; box-shadow: $shadow-lg;
input.input { input.input {
display: none; display: none;
} }
.flatpickr-calendar { .flatpickr-calendar {
margin: 0 auto; margin: 0 auto;
box-shadow: none; box-shadow: none;
span { span {
width: auto !important; width: auto !important;
} }
} }
.defer-days { .defer-days {
justify-content: space-between; justify-content: space-between;
display: flex; display: flex;
margin: .5rem 0; margin: .5rem 0;
} }
@media screen and (max-width: ($defer-task-max-width + 100px)) { // 100px is roughly the size the pane is pulled to the right @media screen and (max-width: ($defer-task-max-width + 100px)) { // 100px is roughly the size the pane is pulled to the right
left: .5rem; left: .5rem;
right: .5rem; right: .5rem;
max-width: 100%; max-width: 100%;
width: calc(100vw - 1rem - 2rem); width: calc(100vw - 1rem - 2rem);
.flatpickr-calendar { .flatpickr-calendar {
max-width: 100%; max-width: 100%;
.flatpickr-innerContainer { .flatpickr-innerContainer {
overflow: scroll; overflow: scroll;
} }
} }
} }
} }
.is-max-width-desktop .tasks .task { .is-max-width-desktop .tasks .task {
max-width: $desktop; max-width: $desktop;
} }
.tasktext { .tasktext {
:focus { :focus {
box-shadow: inset 0 0 0 2px rgba($primary, 0.5); box-shadow: inset 0 0 0 2px rgba($primary, 0.5);
} }
:focus:not(:focus-visible) { :focus:not(:focus-visible) {
outline: 0; outline: 0;
} }
:focus-visible, :-moz-focusring { :focus-visible, :-moz-focusring {
box-shadow: inset 0 0 0 2px rgba($primary, 0.5); box-shadow: inset 0 0 0 2px rgba($primary, 0.5);
} }
} }

View file

@ -1,397 +1,432 @@
@use "sass:math"; @use "sass:math";
.navbar { .navbar {
z-index: 4 !important; z-index: 4 !important;
.navbar-dropdown { .navbar-dropdown {
box-shadow: $navbar-dropdown-boxed-shadow; box-shadow: $navbar-dropdown-boxed-shadow;
top: 101%; top: 101%;
} }
.navbar-brand { .navbar-brand {
display: flex; display: flex;
align-items: center; align-items: center;
.logo img { .logo img {
width: $vikunja-nav-logo-full-width; width: $vikunja-nav-logo-full-width;
} }
} }
} }
.navbar.main-theme { .navbar.main-theme {
background: $light-background; background: $light-background;
z-index: 5 !important; z-index: 5 !important;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
.title { .title {
margin: 0; margin: 0;
font-size: 1.75rem; font-size: 1.75rem;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
} }
.navbar-end { .navbar-end {
margin-left: 0; margin-left: 0;
align-items: center; align-items: center;
display: flex; display: flex;
} }
@media screen and (max-width: $desktop) { @media screen and (max-width: $desktop) {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@media screen and (max-width: $tablet) { @media screen and (max-width: $tablet) {
.navbar-brand { .navbar-brand {
display: none; display: none;
} }
.user { .user {
width: $user-dropdown-width-mobile; width: $user-dropdown-width-mobile;
display: flex; display: flex;
align-items: center; align-items: center;
.dropdown-trigger { .dropdown-trigger {
line-height: 1; line-height: 1;
.button { .button {
padding: 0 0.25rem; padding: 0 0.25rem;
height: 1rem; height: 1rem;
.icon { .icon {
width: .5rem; width: .5rem;
} }
} }
} }
.username { .username {
display: none; display: none;
} }
} }
} }
} }
} }
.navbar-menu .navbar-item .icon { .navbar-menu .navbar-item .icon {
margin: 0 0.5rem; margin: 0 0.5rem;
} }
.app-container { .app-container {
.namespace-container { .namespace-container {
background: $vikunja-nav-background; background: $vikunja-nav-background;
z-index: 6; z-index: 6;
color: $vikunja-nav-color; color: $vikunja-nav-color;
padding: 0; padding: 0;
transition: all $transition; transition: all $transition;
position: fixed; position: fixed;
bottom: 0; bottom: 0;
top: $navbar-height; top: $navbar-height;
overflow-x: auto; overflow-x: auto;
width: $navbar-width; width: $navbar-width;
padding: 0 0 1rem; padding: 0 0 1rem;
left: -147vw; left: -147vw;
bottom: 0; bottom: 0;
@media screen and (max-width: $tablet) { @media screen and (max-width: $tablet) {
top: 0; top: 0;
width: 70vw; width: 70vw;
} }
&.is-active { &.is-active {
left: 0; left: 0;
} }
.menu { .menu {
.menu-label { .menu-label {
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
font-weight: bold; font-weight: bold;
font-family: $vikunja-font; font-family: $vikunja-font;
color: $vikunja-nav-color; color: $vikunja-nav-color;
font-weight: 500; font-weight: 500;
min-height: 2.5rem; min-height: 2.5rem;
padding-top: 0; padding-top: 0;
padding-left: $navbar-padding; padding-left: $navbar-padding;
overflow: hidden; overflow: hidden;
} }
.menu-label, .menu-list span.list-menu-link, .menu-list a { .menu-label, .menu-list span.list-menu-link, .menu-list a {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
cursor: pointer; cursor: pointer;
.list-menu-title { .list-menu-title {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
width: 100%; width: 100%;
} }
.color-bubble { .color-bubble {
height: 12px; height: 12px;
flex: 0 0 12px; flex: 0 0 12px;
} }
.favorite { .favorite {
margin-left: .25rem; margin-left: .25rem;
transition: opacity $transition, color $transition; transition: opacity $transition, color $transition;
opacity: 0; opacity: 0;
&:hover { &:hover {
color: $orange; color: $orange;
} }
&.is-favorite { &.is-favorite {
opacity: 1; opacity: 1;
color: $orange; color: $orange;
} }
} }
&:hover .favorite { &:hover .favorite {
opacity: 1; opacity: 1;
} }
} }
.menu-label { .menu-label {
.color-bubble { .color-bubble {
width: 14px !important; width: 14px !important;
height: 14px !important; height: 14px !important;
} }
.is-archived { .is-archived {
min-width: 85px; min-width: 85px;
} }
} }
.namespace-title { .namespace-title {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
.menu-label { .menu-label {
margin-bottom: 0; margin-bottom: 0;
flex: 1 1 auto; flex: 1 1 auto;
.name { .name {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
} }
a:not(.dropdown-item) { a:not(.dropdown-item) {
color: $vikunja-nav-color; color: $vikunja-nav-color;
padding: 0 .25rem; padding: 0 .25rem;
} }
.dropdown-trigger { .dropdown-trigger {
padding: .5rem; padding: .5rem;
cursor: pointer; cursor: pointer;
} }
.toggle-lists-icon { .toggle-lists-icon {
svg { svg {
transition: all $transition; transition: all $transition;
transform: rotate(90deg); transform: rotate(90deg);
opacity: 1; opacity: 1;
} }
&.active svg { &.active svg {
transform: rotate(0deg); transform: rotate(0deg);
opacity: 0; opacity: 0;
} }
} }
&:hover .toggle-lists-icon svg { &:hover .toggle-lists-icon svg {
opacity: 1; opacity: 1;
} }
&:not(.has-menu) .toggle-lists-icon { &:not(.has-menu) .toggle-lists-icon {
padding-right: 1rem; padding-right: 1rem;
} }
} }
.menu-label, .nsettings, .menu-list span.list-menu-link, .menu-list a { .menu-label, .nsettings, .menu-list span.list-menu-link, .menu-list a {
color: $vikunja-nav-color; color: $vikunja-nav-color;
} }
.menu-list { .menu-list {
li { li {
height: 44px; height: 44px;
display: flex; display: flex;
align-items: center; align-items: center;
.dropdown-trigger { .dropdown-trigger {
opacity: 0; opacity: 0;
padding: .5rem; padding: .5rem;
cursor: pointer; cursor: pointer;
transition: $transition; transition: $transition;
} }
&:hover { &:hover {
background: $white; background: $white;
.dropdown-trigger { .dropdown-trigger {
opacity: 1; opacity: 1;
} }
} }
}
a:hover { &.loader-container.is-loading:after {
background: transparent; width: 1.5rem;
} height: 1.5rem;
top: calc(50% - .75rem);
left: calc(50% - .75rem);
border-width: 2px;
}
}
span.list-menu-link, li > a { .flip-list-move {
padding: 0.75rem .5rem 0.75rem $navbar-padding * 1.5; transition: transform $transition-duration;
transition: all 0.2s ease; }
-webkit-border-radius: 0; .ghost {
-moz-border-radius: 0; background: $grey-200;
border-radius: 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: 100%;
border-left: $vikunja-nav-selected-width solid transparent;
.icon { * {
height: 1rem; opacity: 0;
vertical-align: middle; }
padding-bottom: 4px; }
padding-right: 0.5rem;
}
&.router-link-exact-active { a:hover {
color: $primary; background: transparent;
border-left: $vikunja-nav-selected-width solid $primary; }
.icon { span.list-menu-link, li > a {
color: $primary; padding: 0.75rem .5rem 0.75rem ($navbar-padding * 1.5 - 1.75rem);
} transition: all 0.2s ease;
}
&:hover { -webkit-border-radius: 0;
border-left: $vikunja-nav-selected-width solid $primary; -moz-border-radius: 0;
} border-radius: 0;
} white-space: nowrap;
} text-overflow: ellipsis;
overflow: hidden;
width: 100%;
border-left: $vikunja-nav-selected-width solid transparent;
.logo { .icon {
display: none; height: 1rem;
vertical-align: middle;
padding-right: 0.5rem;
&.handle {
opacity: 0;
transition: opacity $transition;
margin-right: .25rem;
cursor: grab;
}
@media screen and (max-width: $tablet) { }
display: block;
} &:hover .icon.handle {
} opacity: 1;
}
&.namespaces-lists { &.router-link-exact-active {
padding-top: math.div($navbar-padding, 2); color: $primary;
} border-left: $vikunja-nav-selected-width solid $primary;
&.loader-container.is-loading:after { .icon {
width: 1.5rem; color: $primary;
height: 1.5rem; }
top: calc(50% - .75rem); }
left: calc(50% - .75rem);
border-width: 2px;
}
.icon { &:hover {
color: $grey-400 !important; border-left: $vikunja-nav-selected-width solid $primary;
} }
} }
}
.top-menu { .logo {
margin-top: math.div($navbar-padding, 2); display: none;
.menu-list { @media screen and (max-width: $tablet) {
li { display: block;
font-weight: 500; }
font-family: $vikunja-font; }
}
span.list-menu-link, li > a { &.namespaces-lists {
padding-left: 2rem; padding-top: math.div($navbar-padding, 2);
display: inline-block; }
}
}
} &.loader-container.is-loading:after {
} width: 1.5rem;
height: 1.5rem;
top: calc(50% - .75rem);
left: calc(50% - .75rem);
border-width: 2px;
}
.icon {
color: $grey-400 !important;
}
}
.top-menu {
margin-top: math.div($navbar-padding, 2);
.menu-list {
li {
font-weight: 500;
font-family: $vikunja-font;
}
span.list-menu-link, li > a {
padding-left: 2rem;
display: inline-block;
.icon {
padding-bottom: .25rem;
}
}
}
}
}
} }
.navbar { .navbar {
.trigger-button { .trigger-button {
cursor: pointer; cursor: pointer;
color: $grey-400; color: $grey-400;
padding: .5rem; padding: .5rem;
font-size: 1.25rem; font-size: 1.25rem;
position: relative; position: relative;
} }
> * > .trigger-button { > * > .trigger-button {
width: $navbar-icon-width; width: $navbar-icon-width;
} }
.user { .user {
display: flex; display: flex;
align-items: center; align-items: center;
span { span {
font-family: $vikunja-font; font-family: $vikunja-font;
} }
.avatar { .avatar {
-webkit-border-radius: 100%; -webkit-border-radius: 100%;
-moz-border-radius: 100%; -moz-border-radius: 100%;
border-radius: 100%; border-radius: 100%;
vertical-align: middle; vertical-align: middle;
height: 40px; height: 40px;
} }
.logout-icon { .logout-icon {
color: $grey-900; color: $grey-900;
.icon { .icon {
vertical-align: middle; vertical-align: middle;
} }
} }
.dropdown-trigger .button { .dropdown-trigger .button {
background: none; background: none;
&:focus:not(:active), &:active { &:focus:not(:active), &:active {
outline: none !important; outline: none !important;
-webkit-box-shadow: none !important; -webkit-box-shadow: none !important;
-moz-box-shadow: none !important; -moz-box-shadow: none !important;
box-shadow: none !important; box-shadow: none !important;
} }
} }
} }
} }
.menu-hide-button, .menu-show-button { .menu-hide-button, .menu-show-button {
display: none; display: none;
z-index: 31; z-index: 31;
font-weight: bold; font-weight: bold;
font-size: 2rem; font-size: 2rem;
color: $grey-400; color: $grey-400;
line-height: 1; line-height: 1;
transition: all $transition; transition: all $transition;
&:hover, &:focus { &:hover, &:focus {
height: 1rem; height: 1rem;
color: $grey-600; color: $grey-600;
} }
} }
.menu-show-button { .menu-show-button {
height: .75rem; height: .75rem;
width: 2rem; width: 2rem;
&:before, &:after { &:before, &:after {
display: block; display: block;
content: ''; content: '';
@ -399,18 +434,18 @@
border-radius: $radius; border-radius: $radius;
transition: all $transition; transition: all $transition;
} }
&:before { &:before {
margin-bottom: .5rem; margin-bottom: .5rem;
} }
&:after { &:after {
margin-top: .5rem; margin-top: .5rem;
} }
&:hover, &:focus { &:hover, &:focus {
color: $grey-600; color: $grey-600;
&:before { &:before {
margin-bottom: .75rem; margin-bottom: .75rem;
} }
@ -422,61 +457,61 @@
} }
.menu-hide-button { .menu-hide-button {
position: fixed; position: fixed;
&:hover, &:focus { &:hover, &:focus {
color: $text; color: $text;
} }
} }
.navbar-brand .menu-show-button { .navbar-brand .menu-show-button {
display: block; display: block;
} }
.mobile-overlay { .mobile-overlay {
display: none; display: none;
position: fixed; position: fixed;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
background: rgba(250, 250, 250, 0.8); background: rgba(250, 250, 250, 0.8);
z-index: 5; z-index: 5;
opacity: 0; opacity: 0;
transition: all $transition; transition: all $transition;
} }
@media screen and (max-width: $tablet) { @media screen and (max-width: $tablet) {
.menu-hide-button { .menu-hide-button {
display: block; display: block;
top: $hamburger-menu-icon-spacing; top: $hamburger-menu-icon-spacing;
right: $hamburger-menu-icon-spacing; right: $hamburger-menu-icon-spacing;
} }
.menu-show-button { .menu-show-button {
display: block; display: block;
margin-left: $hamburger-menu-icon-spacing; margin-left: $hamburger-menu-icon-spacing;
} }
.mobile-overlay { .mobile-overlay {
display: block; display: block;
opacity: 1; opacity: 1;
} }
.navbar.is-dark .navbar-brand > .navbar-item { .navbar.is-dark .navbar-brand > .navbar-item {
margin: 0 auto; margin: 0 auto;
} }
} }
.logout-icon { .logout-icon {
margin-right: 0.85rem !important; margin-right: 0.85rem !important;
} }
.menu-bottom-link { .menu-bottom-link {
width: 100%; width: 100%;
color: $grey-300; color: $grey-300;
text-align: center; text-align: center;
display: block; display: block;
margin: 1rem 0; margin: 1rem 0;
font-size: .8rem; font-size: .8rem;
} }

View file

@ -153,3 +153,7 @@ button.table {
.is-strikethrough { .is-strikethrough {
text-decoration: line-through; text-decoration: line-through;
} }
.is-touch .handle {
opacity: 1 !important;
}

View file

@ -16,217 +16,165 @@
v-model="params" v-model="params"
/> />
</div> </div>
<div :class="{ 'is-loading': loading && !oneTaskUpdating}" class="kanban loader-container"> <div :class="{ 'is-loading': loading && !oneTaskUpdating}" class="kanban kanban-bucket-container loader-container">
<div <draggable
:key="`bucket${bucket.id}`" v-model="buckets"
class="bucket" @start="() => dragBucket = true"
:class="{'is-collapsed': collapsedBuckets[bucket.id]}" @end="updateBucketPosition"
v-for="bucket in buckets" group="buckets"
v-bind="dragOptions"
:disabled="!canWrite"
> >
<div class="bucket-header" @click="() => unCollapseBucket(bucket)"> <transition-group type="transition" :name="!dragBucket ? 'move-bucket': null" tag="div" class="kanban-bucket-container">
<span <div
v-if="bucket.isDoneBucket" :key="`bucket${bucket.id}`"
class="icon is-small has-text-success mr-2" class="bucket"
v-tooltip="$t('list.kanban.doneBucketHint')" :class="{'is-collapsed': collapsedBuckets[bucket.id]}"
v-for="(bucket, k) in buckets"
> >
<icon icon="check-double"/> <div class="bucket-header" @click="() => unCollapseBucket(bucket)">
</span> <span
<h2 v-if="bucket.isDoneBucket"
:ref="`bucket${bucket.id}title`" class="icon is-small has-text-success mr-2"
@focusout="() => saveBucketTitle(bucket.id)" v-tooltip="$t('list.kanban.doneBucketHint')"
@keydown.enter.prevent.stop="() => saveBucketTitle(bucket.id)"
class="title input"
:contenteditable="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">
<!-- Make the component either a div or a draggable component based on the user rights -->
<component
:animation-duration="150"
:drop-placeholder="dropPlaceholderOptions"
:get-child-payload="getTaskPayload(bucket.id)"
:is="canWrite ? 'Container' : 'div'"
:should-accept-drop="() => shouldAcceptDrop(bucket)"
@drop="e => onDrop(bucket.id, e)"
drag-class="ghost-task"
drag-class-drop="ghost-task-drop"
drag-handle-selector=".task.draggable"
group-name="buckets"
>
<!-- Make the component either a div or a draggable component based on the user rights -->
<component
:is="canWrite ? 'Draggable' : 'div'"
:key="`bucket${bucket.id}-task${task.id}`"
v-for="task in bucket.tasks"
>
<div
:class="{
'is-loading': (taskService.loading || taskLoading) && taskUpdating[task.id],
'draggable': !(taskService.loading || taskLoading) || !taskUpdating[task.id],
'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"> <icon icon="check-double"/>
<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>
<span <h2
:class="{'overdue': task.dueDate <= new Date() && !task.done}" :ref="`bucket${bucket.id}title`"
class="due-date" @focusout="() => saveBucketTitle(bucket.id)"
v-if="task.dueDate > 0" @keydown.enter.prevent.stop="() => saveBucketTitle(bucket.id)"
v-tooltip="formatDate(task.dueDate)"> @click="focusBucketTitle"
<span class="icon"> class="title input"
<icon :icon="['far', 'calendar-alt']"/> :contenteditable="bucketTitleEditable && canWrite && !collapsedBuckets[bucket.id]"
</span> spellcheck="false">{{ bucket.title }}</h2>
<span> <span
{{ formatDateSince(task.dueDate) }} :class="{'is-max': bucket.tasks.length >= bucket.limit}"
</span> class="limit"
</span> v-if="bucket.limit > 0">
<h3>{{ task.title }}</h3> {{ bucket.tasks.length }}/{{ bucket.limit }}
<progress </span>
class="progress is-small" <dropdown
v-if="task.percentDone > 0" class="is-right options"
:value="task.percentDone * 100" max="100"> v-if="canWrite && !collapsedBuckets[bucket.id]"
{{ task.percentDone * 100 }}% trigger-icon="ellipsis-v"
</progress> @close="() => showSetLimitInput = false"
<div class="footer"> >
<span <a
:key="label.id" @click.stop="showSetLimitInput = true"
:style="{'background': label.hexColor, 'color': label.textColor}" class="dropdown-item"
class="tag" >
v-for="label in task.labels"> <div class="field has-addons" v-if="showSetLimitInput">
<span>{{ label.title }}</span> <div class="control">
</span> <input
<priority-label :priority="task.priority"/> @change="() => setBucketLimit(bucket)"
<div class="assignees" v-if="task.assignees.length > 0"> @keyup.enter="() => setBucketLimit(bucket)"
<user @keyup.esc="() => showSetLimitInput = false"
:avatar-size="24" class="input"
:key="task.id + 'assignee' + u.id" type="number"
:show-username="false" min="0"
:user="u" v-focus.always
v-for="u in task.assignees" v-model="bucket.limit"
/> />
</div>
<div class="control">
<x-button
:disabled="bucket.limit < 0"
:icon="['far', 'save']"
:shadow="false"
/>
</div>
</div> </div>
<span class="icon" v-if="task.attachments.length > 0"> <template v-else>
<icon icon="paperclip"/> {{
$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> </span>
<span v-if="task.description" class="icon"> {{ $t('list.kanban.doneBucket') }}
<icon icon="align-left"/> </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> </span>
</div> {{ $t('misc.delete') }}
</div> </a>
</component> </dropdown>
</component> </div>
</div> <div :ref="`tasks-container${bucket.id}`" class="tasks">
<div class="bucket-footer" v-if="canWrite"> <draggable
<div class="field" v-if="showNewTaskInput[bucket.id]"> v-model="bucket.tasks"
<div class="control" :class="{'is-loading': taskService.loading || loading}"> @start="() => drag = true"
<input @end="updateTaskPosition"
class="input" :group="{name: 'tasks', put: shouldAcceptDrop(bucket) && !dragBucket}"
:disabled="taskService.loading || loading" v-bind="dragOptions"
@focusout="toggleShowNewTaskInput(bucket.id)" :disabled="!canWrite"
@keyup.enter="addTaskToBucket(bucket.id)" :data-bucket-index="k"
@keyup.esc="toggleShowNewTaskInput(bucket.id)" class="dropper"
:placeholder="$t('list.kanban.addTaskPlaceholder')" >
type="text" <transition-group type="transition" :name="!drag ? 'move-card': null" tag="div">
v-focus.always <kanban-card
v-model="newTaskText" :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>
<p class="help is-danger" v-if="newTaskError[bucket.id] && newTaskText === ''">
{{ $t('list.list.addTitleRequired') }}
</p>
</div> </div>
<x-button </transition-group>
@click="toggleShowNewTaskInput(bucket.id)" </draggable>
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>
<div class="bucket new-bucket" v-if="canWrite && !loading && buckets.length > 0"> <div class="bucket new-bucket" v-if="canWrite && !loading && buckets.length > 0">
<input <input
@ -276,50 +224,43 @@
</template> </template>
<script> <script>
import TaskService from '../../../services/task'
import BucketModel from '../../../models/bucket' import BucketModel from '../../../models/bucket'
import {Container, Draggable} from 'vue-smooth-dnd'
import PriorityLabel from '../../../components/tasks/partials/priorityLabel'
import User from '../../../components/misc/user'
import Labels from '../../../components/tasks/partials/labels'
import {filterObject} from '@/helpers/filterObject' import {filterObject} from '@/helpers/filterObject'
import {applyDrag} from '@/helpers/applyDrag'
import {mapState} from 'vuex' import {mapState} from 'vuex'
import {saveListView} from '@/helpers/saveListView' import {saveListView} from '@/helpers/saveListView'
import Rights from '../../../models/rights.json' import Rights from '../../../models/rights.json'
import {LOADING, LOADING_MODULE} from '@/store/mutation-types' import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
import FilterPopup from '@/components/list/partials/filter-popup.vue' import FilterPopup from '@/components/list/partials/filter-popup.vue'
import Dropdown from '@/components/misc/dropdown.vue' import Dropdown from '@/components/misc/dropdown.vue'
import {playPop} from '@/helpers/playPop' import createTask from '../../../components/tasks/mixins/createTask'
import createTask from '@/components/tasks/mixins/createTask'
import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState' import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState'
import {calculateItemPosition} from '../../../helpers/calculateItemPosition'
import draggable from 'vuedraggable'
import KanbanCard from '../../../components/tasks/partials/kanban-card'
export default { export default {
name: 'Kanban', name: 'Kanban',
components: { components: {
KanbanCard,
Dropdown, Dropdown,
FilterPopup, FilterPopup,
Container, draggable,
Draggable,
Labels,
User,
PriorityLabel,
}, },
data() { data() {
return { return {
taskService: TaskService, drag: false,
dragBucket: false,
dropPlaceholderOptions: { dragOptions: {
className: 'drop-preview', animation: 150,
animationDuration: 150, ghostClass: 'ghost',
showOnTop: true, dragClass: 'task-dragging',
}, },
sourceBucket: 0, sourceBucket: 0,
showBucketDeleteModal: false, showBucketDeleteModal: false,
bucketToDelete: 0, bucketToDelete: 0,
bucketTitleEditable: false,
newTaskText: '', newTaskText: '',
showNewTaskInput: {}, showNewTaskInput: {},
@ -347,7 +288,6 @@ export default {
createTask, createTask,
], ],
created() { created() {
this.taskService = new TaskService()
this.loadBuckets() this.loadBuckets()
// Save the current list view to local storage // Save the current list view to local storage
@ -357,14 +297,23 @@ export default {
watch: { watch: {
'$route.params.listId': 'loadBuckets', '$route.params.listId': 'loadBuckets',
}, },
computed: mapState({ computed: {
buckets: state => state.kanban.buckets, buckets: {
loadedListId: state => state.kanban.listId, get() {
loading: state => state[LOADING] && state[LOADING_MODULE] === 'kanban', return this.$store.state.kanban.buckets
taskLoading: state => state[LOADING] && state[LOADING_MODULE] === 'tasks', },
canWrite: state => state.currentList.maxRight > Rights.READ, set(value) {
list: state => state.currentList, this.$store.commit('kanban/setBuckets', value)
}), },
},
...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: { methods: {
loadBuckets() { loadBuckets() {
@ -412,76 +361,23 @@ export default {
this.error(e) this.error(e)
}) })
}, },
onDrop(bucketId, dropResult) { updateTaskPosition(e) {
this.drag = false
// 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 // 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 bucketIndex = filterObject(this.buckets, b => b.id === bucketId) 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
if (dropResult.removedIndex !== null || dropResult.addedIndex !== null) { task.kanbanPosition = calculateItemPosition(taskBefore !== null ? taskBefore.kanbanPosition : null, taskAfter !== null ? taskAfter.kanbanPosition : null)
task.bucketId = newBucket.id
// 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
// FIXME: This seems to set some task attributes (like due date) wrong. Commented out, but seems to still work?
// Not sure what to do about this.
// this.$store.commit('kanban/setBuckets', 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)
this.oneTaskUpdating = 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.$store.dispatch('tasks/update', task)
.catch(e => {
this.error(e)
})
.finally(() => {
this.$set(this.taskUpdating, task.id, false)
this.oneTaskUpdating = false
})
}
},
markTaskAsDone(task) {
this.oneTaskUpdating = true
this.$set(this.taskUpdating, task.id, true)
task.done = !task.done
this.$store.dispatch('tasks/update', task) this.$store.dispatch('tasks/update', task)
.then(() => {
if (task.done) {
playPop()
}
})
.catch(e => { .catch(e => {
this.error(e) this.error(e)
}) })
@ -490,13 +386,6 @@ export default {
this.oneTaskUpdating = false this.oneTaskUpdating = false
}) })
}, },
getTaskPayload(bucketId) {
return index => {
const bucket = this.buckets[filterObject(this.buckets, b => b.id === bucketId)]
this.sourceBucket = bucket.id
return bucket.tasks[index]
}
},
toggleShowNewTaskInput(bucket) { toggleShowNewTaskInput(bucket) {
this.$set(this.showNewTaskInput, bucket, !this.showNewTaskInput[bucket]) this.$set(this.showNewTaskInput, bucket, !this.showNewTaskInput[bucket])
}, },
@ -567,7 +456,13 @@ export default {
this.showBucketDeleteModal = false 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) { saveBucketTitle(bucketId) {
this.bucketTitleEditable = false
const bucketTitleElement = this.$refs[`bucket${bucketId}title`][0] const bucketTitleElement = this.$refs[`bucket${bucketId}title`][0]
const bucketTitle = bucketTitleElement.textContent const bucketTitle = bucketTitleElement.textContent
const bucket = new BucketModel({ const bucket = new BucketModel({
@ -604,6 +499,20 @@ export default {
this.error(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) { setBucketLimit(bucket) {
if (bucket.limit < 0) { if (bucket.limit < 0) {
return return

View file

@ -21,7 +21,7 @@
v-model="searchTerm" v-model="searchTerm"
/> />
<span class="icon is-left"> <span class="icon is-left">
<icon icon="search" /> <icon icon="search"/>
</span> </span>
</div> </div>
<div class="control"> <div class="control">
@ -63,16 +63,15 @@
<add-task <add-task
@taskAdded="updateTaskList" @taskAdded="updateTaskList"
ref="newTaskInput" ref="newTaskInput"
:default-position="firstNewPosition"
/> />
</template> </template>
<nothing v-if="ctaVisible && tasks.length === 0 && !taskCollectionService.loading"> <nothing v-if="ctaVisible && tasks.length === 0 && !taskCollectionService.loading">
{{ $t('list.list.empty') }} {{ $t('list.list.empty') }}
<a @click="focusNewTaskInput()"> <a @click="focusNewTaskInput()">
{{ $t('list.list.newTaskCta') }} {{ $t('list.list.newTaskCta') }}
</a> </a>
</nothing> </nothing>
<div class="tasks-container"> <div class="tasks-container">
@ -81,23 +80,35 @@
class="tasks mt-0" class="tasks mt-0"
v-if="tasks && tasks.length > 0" v-if="tasks && tasks.length > 0"
> >
<single-task-in-list <draggable
:show-list-color="false" v-model="tasks"
:disabled="!canWrite" group="tasks"
:key="t.id" @start="() => drag = true"
:the-task="t" @end="saveTaskPosition"
@taskUpdated="updateTasks" v-bind="dragOptions"
task-detail-route="task.detail" handle=".handle"
v-for="t in tasks"
> >
<div <single-task-in-list
@click="editTask(t.id)" :show-list-color="false"
class="icon settings" :disabled="!canWrite"
v-if="!list.isArchived && canWrite" :key="t.id"
:the-task="t"
@taskUpdated="updateTasks"
task-detail-route="task.detail"
v-for="t in tasks"
> >
<icon icon="pencil-alt" /> <span class="icon handle">
</div> <icon icon="grip-lines"/>
</single-task-in-list> </span>
<div
@click="editTask(t.id)"
class="icon settings"
v-if="!list.isArchived && canWrite"
>
<icon icon="pencil-alt"/>
</div>
</single-task-in-list>
</draggable>
</div> </div>
<card <card
v-if="isTaskEdit" v-if="isTaskEdit"
@ -150,7 +161,7 @@
<!-- This router view is used to show the task popup while keeping the kanban board itself --> <!-- This router view is used to show the task popup while keeping the kanban board itself -->
<transition name="modal"> <transition name="modal">
<router-view /> <router-view/>
</transition> </transition>
</div> </div>
</template> </template>
@ -163,14 +174,17 @@ import EditTask from '../../../components/tasks/edit-task'
import AddTask from '../../../components/tasks/add-task' import AddTask from '../../../components/tasks/add-task'
import SingleTaskInList from '../../../components/tasks/partials/singleTaskInList' import SingleTaskInList from '../../../components/tasks/partials/singleTaskInList'
import taskList from '../../../components/tasks/mixins/taskList' import taskList from '../../../components/tasks/mixins/taskList'
import { saveListView } from '@/helpers/saveListView' import {saveListView} from '@/helpers/saveListView'
import Rights from '../../../models/rights.json' import Rights from '../../../models/rights.json'
import { mapState } from 'vuex'
import FilterPopup from '@/components/list/partials/filter-popup.vue' import FilterPopup from '@/components/list/partials/filter-popup.vue'
import { HAS_TASKS } from '@/store/mutation-types' import {HAS_TASKS} from '@/store/mutation-types'
import Nothing from '@/components/misc/nothing.vue' import Nothing from '@/components/misc/nothing.vue'
import createTask from '@/components/tasks/mixins/createTask' import createTask from '@/components/tasks/mixins/createTask'
import {mapState} from 'vuex'
import draggable from 'vuedraggable'
import {calculateItemPosition} from '../../../helpers/calculateItemPosition'
export default { export default {
name: 'List', name: 'List',
data() { data() {
@ -179,6 +193,12 @@ export default {
isTaskEdit: false, isTaskEdit: false,
taskEditTask: TaskModel, taskEditTask: TaskModel,
ctaVisible: false, ctaVisible: false,
drag: false,
dragOptions: {
animation: 100,
ghostClass: 'ghost',
},
} }
}, },
mixins: [ mixins: [
@ -191,6 +211,7 @@ export default {
SingleTaskInList, SingleTaskInList,
EditTask, EditTask,
AddTask, AddTask,
draggable,
}, },
created() { created() {
this.taskService = new TaskService() this.taskService = new TaskService()
@ -199,10 +220,19 @@ export default {
// We use local storage and not vuex here to make it persistent across reloads. // We use local storage and not vuex here to make it persistent across reloads.
saveListView(this.$route.params.listId, this.$route.name) saveListView(this.$route.params.listId, this.$route.name)
}, },
computed: mapState({ computed: {
canWrite: state => state.currentList.maxRight > Rights.READ, firstNewPosition() {
list: state => state.currentList, if (this.tasks.length === 0) {
}), return 0
}
return calculateItemPosition(null, this.tasks[0].position)
},
...mapState({
canWrite: state => state.currentList.maxRight > Rights.READ,
list: state => state.currentList,
}),
},
mounted() { mounted() {
this.$nextTick(() => (this.ctaVisible = true)) this.$nextTick(() => (this.ctaVisible = true))
}, },
@ -217,8 +247,11 @@ export default {
this.$refs.newTaskInput.$refs.newTaskInput.focus() this.$refs.newTaskInput.$refs.newTaskInput.focus()
}, },
updateTaskList(task) { updateTaskList(task) {
this.tasks.push(task) const tasks = [
this.sortTasks() task,
...this.tasks,
]
this.tasks = tasks
this.$store.commit(HAS_TASKS, true) this.$store.commit(HAS_TASKS, true)
}, },
editTask(id) { editTask(id) {

View file

@ -90,6 +90,10 @@ export default {
computed: mapState({ computed: mapState({
namespaces(state) { namespaces(state) {
return state.namespaces.namespaces.filter(n => this.showArchived ? true : !n.isArchived) return state.namespaces.namespaces.filter(n => this.showArchived ? true : !n.isArchived)
// return state.namespaces.namespaces.filter(n => this.showArchived ? true : !n.isArchived).map(n => {
// n.lists = n.lists.filter(l => !l.isArchived)
// return n
// })
}, },
loading: LOADING, loading: LOADING,
}), }),

View file

@ -2,6 +2,11 @@
# yarn lockfile v1 # yarn lockfile v1
"@4tw/cypress-drag-drop@^1.8.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@4tw/cypress-drag-drop/-/cypress-drag-drop-1.8.0.tgz#f2990a414b18445cc117540ba6aba6994ecdd0e8"
integrity sha512-hPg9JvG3f+rzunVj6cZjgHaNtZ8JMbHBwD00PZjyl6/ysqyZzeR74b8yWUF0zohWcCG9bHVu666EhotOiIKhZw==
"@babel/code-frame@7.12.11": "@babel/code-frame@7.12.11":
version "7.12.11" version "7.12.11"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f"
@ -5013,6 +5018,11 @@ is-stream@^2.0.0:
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
is-touch-device@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-touch-device/-/is-touch-device-1.0.1.tgz#9a2fd59f689e9a9bf6ae9a86924c4ba805a42eab"
integrity sha512-LAYzo9kMT1b2p19L/1ATGt2XcSilnzNlyvq6c0pbPRVisLbAPpLqr53tIJS00kvrTkj0HtR8U7+u8X0yR8lPSw==
is-typedarray@^1.0.0, is-typedarray@~1.0.0: is-typedarray@^1.0.0, is-typedarray@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
@ -7202,11 +7212,6 @@ slice-ansi@^4.0.0:
astral-regex "^2.0.0" astral-regex "^2.0.0"
is-fullwidth-code-point "^3.0.0" is-fullwidth-code-point "^3.0.0"
smooth-dnd@0.12.1:
version "0.12.1"
resolved "https://registry.yarnpkg.com/smooth-dnd/-/smooth-dnd-0.12.1.tgz#cdb44c972355659e32770368b29b6a80e0ed96f1"
integrity sha512-Dndj/MOG7VP83mvzfGCLGzV2HuK1lWachMtWl/Iuk6zV7noDycIBnflwaPuDzoaapEl3Pc4+ybJArkkx9sxPZg==
snake-case@3.0.4: snake-case@3.0.4:
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c"
@ -7245,6 +7250,11 @@ snapdragon@^0.8.1:
source-map-resolve "^0.5.0" source-map-resolve "^0.5.0"
use "^3.1.0" use "^3.1.0"
sortablejs@1.10.2:
version "1.10.2"
resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.10.2.tgz#6e40364d913f98b85a14f6678f92b5c1221f5290"
integrity sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A==
source-map-js@^0.6.2: source-map-js@^0.6.2:
version "0.6.2" version "0.6.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e"
@ -8144,13 +8154,6 @@ vue-shortkey@3.1.7:
custom-event-polyfill "^1.0.7" custom-event-polyfill "^1.0.7"
element-matches "^0.1.2" element-matches "^0.1.2"
vue-smooth-dnd@0.8.1:
version "0.8.1"
resolved "https://registry.yarnpkg.com/vue-smooth-dnd/-/vue-smooth-dnd-0.8.1.tgz#b1c584cfe49b830a402548b4bf08f00f68f430e5"
integrity sha512-eZVVPTwz4A1cs0+CjXx/ihV+gAl3QBoWQnU6+23Gp59t0WBU99z7ducBQ4FvjBamqOlg8SDOE5eFHQedxwB4Wg==
dependencies:
smooth-dnd "0.12.1"
vue-template-compiler@2.6.14: vue-template-compiler@2.6.14:
version "2.6.14" version "2.6.14"
resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.6.14.tgz#a2f0e7d985670d42c9c9ee0d044fed7690f4f763" resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.6.14.tgz#a2f0e7d985670d42c9c9ee0d044fed7690f4f763"
@ -8169,6 +8172,13 @@ vue@2.6.14:
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.14.tgz#e51aa5250250d569a3fbad3a8a5a687d6036e235" resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.14.tgz#e51aa5250250d569a3fbad3a8a5a687d6036e235"
integrity sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ== integrity sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==
vuedraggable@^2.24.3:
version "2.24.3"
resolved "https://registry.yarnpkg.com/vuedraggable/-/vuedraggable-2.24.3.tgz#43c93849b746a24ce503e123d5b259c701ba0d19"
integrity sha512-6/HDXi92GzB+Hcs9fC6PAAozK1RLt1ewPTLjK0anTYguXLAeySDmcnqE8IC0xa7shvSzRjQXq3/+dsZ7ETGF3g==
dependencies:
sortablejs "1.10.2"
vuex@3.6.2: vuex@3.6.2:
version "3.6.2" version "3.6.2"
resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.6.2.tgz#236bc086a870c3ae79946f107f16de59d5895e71" resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.6.2.tgz#236bc086a870c3ae79946f107f16de59d5895e71"