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:
parent
39ef4b48f2
commit
3c7f8d7aa2
23 changed files with 1524 additions and 1266 deletions
|
@ -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', () => {
|
cy.get('.kanban .bucket:nth-child(2) .tasks')
|
||||||
// const tasks = TaskFactory.create(2, {
|
.should('contain', tasks[0].title)
|
||||||
// list_id: 1,
|
cy.get('.kanban .bucket:nth-child(1) .tasks')
|
||||||
// bucket_id: 1,
|
.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, {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
|
||||||
import './commands'
|
import './commands'
|
||||||
import 'cypress-file-upload'
|
import 'cypress-file-upload'
|
||||||
|
import '@4tw/cypress-drag-drop'
|
||||||
|
|
|
@ -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",
|
||||||
|
|
20
src/App.vue
20
src/App.vue
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,11 +39,14 @@ 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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 => {
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
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>
|
|
@ -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
|
|
||||||
}
|
|
19
src/helpers/calculateItemPosition.ts
Normal file
19
src/helpers/calculateItemPosition.ts
Normal 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
|
||||||
|
}
|
18
src/helpers/calculateTaskPosition.test.ts
Normal file
18
src/helpers/calculateTaskPosition.test.ts
Normal 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)
|
||||||
|
})
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -105,6 +105,9 @@ export default class TaskModel extends AbstractModel {
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
subscription: null,
|
subscription: null,
|
||||||
|
|
||||||
|
position: 0,
|
||||||
|
kanbanPosition: 0,
|
||||||
|
|
||||||
createdBy: UserModel,
|
createdBy: UserModel,
|
||||||
created: null,
|
created: null,
|
||||||
updated: null,
|
updated: null,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
a {
|
||||||
color: $text;
|
color: $text;
|
||||||
transition: color ease $transition-duration;
|
transition: color ease $transition-duration;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $grey-900;
|
color: $grey-900;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.favorite {
|
.favorite {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 27px;
|
width: 27px;
|
||||||
transition: opacity $transition, color $transition;
|
transition: opacity $transition, color $transition;
|
||||||
|
|
||||||
&: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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fancycheckbox {
|
.handle {
|
||||||
height: 18px;
|
opacity: 0;
|
||||||
padding-top: 0;
|
transition: opacity $transition;
|
||||||
padding-right: .5rem;
|
margin-right: .25rem;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
span {
|
&:hover .handle {
|
||||||
display: none;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.tasktext.done {
|
.fancycheckbox {
|
||||||
text-decoration: line-through;
|
height: 18px;
|
||||||
color: $grey-500;
|
padding-top: 0;
|
||||||
}
|
padding-right: .5rem;
|
||||||
|
|
||||||
span.parent-tasks {
|
span {
|
||||||
color: $grey-500;
|
display: none;
|
||||||
width: auto;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove {
|
.tasktext.done {
|
||||||
color: $red;
|
text-decoration: line-through;
|
||||||
}
|
color: $grey-500;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="checkbox"] {
|
span.parent-tasks {
|
||||||
vertical-align: middle;
|
color: $grey-500;
|
||||||
}
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.settings {
|
.remove {
|
||||||
float: right;
|
color: $red;
|
||||||
width: 24px;
|
}
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.loader-container.is-loading:after {
|
input[type="checkbox"] {
|
||||||
top: calc(50% - 1rem);
|
vertical-align: middle;
|
||||||
left: calc(50% - 1rem);
|
}
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border-left-color: $grey-300;
|
|
||||||
border-bottom-color: $grey-300;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress {
|
.settings {
|
||||||
width: 50px;
|
float: right;
|
||||||
margin: 0 0.5rem 0 0;
|
width: 24px;
|
||||||
flex: 3 1 auto;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (max-width: $tablet) {
|
&.loader-container.is-loading:after {
|
||||||
margin: 0.5rem 0 0 0;
|
top: calc(50% - 1rem);
|
||||||
order: 1;
|
left: calc(50% - 1rem);
|
||||||
width: 100%;
|
width: 2rem;
|
||||||
}
|
height: 2rem;
|
||||||
}
|
border-left-color: $grey-300;
|
||||||
|
border-bottom-color: $grey-300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.task:last-child {
|
.progress {
|
||||||
border-bottom: none;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,391 +1,426 @@
|
||||||
@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;
|
||||||
|
|
||||||
@media screen and (max-width: $tablet) {
|
&.handle {
|
||||||
display: block;
|
opacity: 0;
|
||||||
}
|
transition: opacity $transition;
|
||||||
}
|
margin-right: .25rem;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
&.namespaces-lists {
|
}
|
||||||
padding-top: math.div($navbar-padding, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.loader-container.is-loading:after {
|
&:hover .icon.handle {
|
||||||
width: 1.5rem;
|
opacity: 1;
|
||||||
height: 1.5rem;
|
}
|
||||||
top: calc(50% - .75rem);
|
|
||||||
left: calc(50% - .75rem);
|
|
||||||
border-width: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
&.router-link-exact-active {
|
||||||
color: $grey-400 !important;
|
color: $primary;
|
||||||
}
|
border-left: $vikunja-nav-selected-width solid $primary;
|
||||||
}
|
|
||||||
|
|
||||||
.top-menu {
|
.icon {
|
||||||
margin-top: math.div($navbar-padding, 2);
|
color: $primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.menu-list {
|
&:hover {
|
||||||
li {
|
border-left: $vikunja-nav-selected-width solid $primary;
|
||||||
font-weight: 500;
|
}
|
||||||
font-family: $vikunja-font;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
span.list-menu-link, li > a {
|
.logo {
|
||||||
padding-left: 2rem;
|
display: none;
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
@media screen and (max-width: $tablet) {
|
||||||
}
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.namespaces-lists {
|
||||||
|
padding-top: math.div($navbar-padding, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.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 {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -153,3 +153,7 @@ button.table {
|
||||||
.is-strikethrough {
|
.is-strikethrough {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.is-touch .handle {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
|
|
34
yarn.lock
34
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue