Reorder tasks, lists and kanban buckets (#620)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#620
Co-authored-by: konrad <konrad@kola-entertainments.de>
Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2021-07-28 19:56:29 +00:00
parent 39ef4b48f2
commit 3c7f8d7aa2
23 changed files with 1524 additions and 1266 deletions

View File

@ -436,26 +436,23 @@ describe('Lists', () => {
.should('exist')
})
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
// (not sure why this actually works at all?) and as I'm planning to swap that out for vuedraggable/sortable.js
// anyway, I figured it wouldn't be worth the hassle right now.
// it('Can drag tasks around', () => {
// const tasks = TaskFactory.create(2, {
// list_id: 1,
// bucket_id: 1,
// })
// 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})
// })
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks .dropper div')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)
cy.get('.kanban .bucket:nth-child(1) .tasks')
.should('not.contain', tasks[0].title)
})
it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, {

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<template>
<div>
<div :class="{'is-touch': isTouch}">
<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 -->
<div class="offline" style="height: 0;width: 0;"></div>
@ -24,6 +24,7 @@
<script>
import {mapState} from 'vuex'
import isTouchDevice from 'is-touch-device'
import authTypes from './models/authTypes'
@ -63,12 +64,17 @@ export default {
this.$router.push({name: 'home'})
}
},
computed: 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,
}),
computed: {
isTouch() {
return isTouchDevice()
},
...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: {
setupOnlineStatus() {
this.$store.commit(ONLINE, navigator.onLine)

View File

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

View File

@ -66,6 +66,12 @@ export default {
this.labelService = new LabelService()
this.labelTaskService = new LabelTaskService()
},
props: {
defaultPosition: {
type: Number,
required: false,
},
},
methods: {
addTask() {
if (this.newTaskTitle === '') {
@ -74,7 +80,7 @@ export default {
}
this.errorMessage = ''
this.createNewTask(this.newTaskTitle, 0, this.$store.state.auth.settings.defaultListId)
this.createNewTask(this.newTaskTitle, 0, this.$store.state.auth.settings.defaultListId, this.defaultPosition)
.then(task => {
this.newTaskTitle = ''
this.$emit('taskAdded', task)

View File

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

View File

@ -1,5 +1,6 @@
import TaskCollectionService from '../../../services/taskCollection'
import cloneDeep from 'lodash/cloneDeep'
import {calculateItemPosition} from '../../../helpers/calculateItemPosition'
/**
* This mixin provides a base set of methods and properties to get tasks on a list.
@ -20,7 +21,7 @@ export default {
showTaskFilter: false,
params: {
sort_by: ['done', 'id'],
sort_by: ['position', 'id'],
order_by: ['asc', 'desc'],
filter_by: ['done'],
filter_value: ['false'],
@ -148,9 +149,9 @@ export default {
if (a.done > b.done)
return 1
if (a.id > b.id)
if (a.position < b.position)
return -1
if (a.id < b.id)
if (a.position > b.position)
return 1
return 0
})
@ -187,5 +188,22 @@ export default {
},
}
},
saveTaskPosition(e) {
this.drag = false
const task = this.tasks[e.newIndex]
const taskBefore = this.tasks[e.newIndex - 1] ?? null
const taskAfter = this.tasks[e.newIndex + 1] ?? null
task.position = calculateItemPosition(taskBefore !== null ? taskBefore.position : null, taskAfter !== null ? taskAfter.position : null)
this.$store.dispatch('tasks/update', task)
.then(r => {
this.$set(this.tasks, e.newIndex, r)
})
.catch(e => {
this.error(e)
})
},
},
}

View File

@ -0,0 +1,111 @@
<template>
<div
:class="{
'is-loading': loadingInternal || loading,
'draggable': !(loadingInternal || loading),
'has-light-text': !colorIsDark(task.hexColor) && task.hexColor !== `#${task.defaultColor}` && task.hexColor !== task.defaultColor,
}"
:style="{'background-color': task.hexColor !== '#' && task.hexColor !== `#${task.defaultColor}` ? task.hexColor : false}"
@click.ctrl="() => markTaskAsDone(task)"
@click.exact="() => $router.push({ name: 'task.kanban.detail', params: { id: task.id } })"
@click.meta="() => markTaskAsDone(task)"
class="task loader-container draggable"
>
<span class="task-id">
<span class="is-done" v-if="task.done">Done</span>
<template v-if="task.identifier === ''">
#{{ task.index }}
</template>
<template v-else>
{{ task.identifier }}
</template>
</span>
<span
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="due-date"
v-if="task.dueDate > 0"
v-tooltip="formatDate(task.dueDate)">
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
<span>
{{ formatDateSince(task.dueDate) }}
</span>
</span>
<h3>{{ task.title }}</h3>
<progress
class="progress is-small"
v-if="task.percentDone > 0"
:value="task.percentDone * 100" max="100">
{{ task.percentDone * 100 }}%
</progress>
<div class="footer">
<labels :labels="task.labels"/>
<priority-label :priority="task.priority"/>
<div class="assignees" v-if="task.assignees.length > 0">
<user
:avatar-size="24"
:key="task.id + 'assignee' + u.id"
:show-username="false"
:user="u"
v-for="u in task.assignees"
/>
</div>
<span class="icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/>
</span>
<span v-if="task.description" class="icon">
<icon icon="align-left"/>
</span>
</div>
</div>
</template>
<script>
import {playPop} from '../../../helpers/playPop'
import PriorityLabel from '../../../components/tasks/partials/priorityLabel'
import User from '../../../components/misc/user'
import Labels from '../../../components/tasks/partials/labels'
export default {
name: 'kanban-card',
components: {
PriorityLabel,
User,
Labels,
},
data() {
return {
loadingInternal: false,
}
},
props: {
task: {
required: true,
},
loading: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
markTaskAsDone(task) {
this.loadingInternal = true
task.done = !task.done
this.$store.dispatch('tasks/update', task)
.then(() => {
if (task.done) {
playPop()
}
})
.catch(e => {
this.error(e)
})
.finally(() => {
this.loadingInternal = false
})
},
},
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ const tasksPerBucket = 25
const addTaskToBucketAndSort = (state, task) => {
const bi = filterObject(state.buckets, b => b.id === task.bucketId)
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)
params.sort_by = 'position'
params.sort_by = 'kanban_position'
params.order_by = 'asc'
let hasBucketFilter = false

View File

@ -12,292 +12,314 @@ $crazy-height-calculation-tasks: '#{$crazy-height-calculation} - 1rem - 2.5rem -
$filter-container-height: '1rem - #{$switch-view-height}';
.app-content.list\.kanban {
padding-bottom: 0;
padding-bottom: 0;
}
.kanban {
display: flex;
align-items: flex-start;
overflow-x: auto;
overflow-y: hidden;
height: calc(#{$crazy-height-calculation});
margin: 0 -1.5rem;
padding: 0 1.5rem;
overflow-x: auto;
overflow-y: hidden;
height: calc(#{$crazy-height-calculation});
margin: 0 -1.5rem;
padding: 0 1.5rem;
@media screen and (max-width: $tablet) {
height: calc(#{$crazy-height-calculation} - #{$filter-container-height});
}
@media screen and (max-width: $tablet) {
height: calc(#{$crazy-height-calculation} - #{$filter-container-height});
}
.bucket {
background-color: $bucket-background;
border-radius: $radius;
position: relative;
&-bucket-container {
display: flex;
align-items: flex-start;
}
flex: 0 0 $bucket-width;
margin: 0 $bucket-right-margin 0 0;
max-height: 100%;
min-height: 20px;
max-width: $bucket-width;
.ghost {
background: transparent !important;
border: 3px dashed $grey-300 !important;
box-shadow: none !important;
.tasks {
max-height: calc(#{$crazy-height-calculation-tasks});
overflow: auto;
* {
opacity: 0;
}
}
@media screen and (max-width: $tablet) {
max-height: calc(#{$crazy-height-calculation-tasks} - #{$filter-container-height});
}
.bucket {
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 {
margin-top: 0;
}
.tasks {
max-height: calc(#{$crazy-height-calculation-tasks});
overflow: auto;
margin-top: 0;
-webkit-touch-callout: none; // iOS Safari
-webkit-user-select: none; // Safari
-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
@media screen and (max-width: $tablet) {
max-height: calc(#{$crazy-height-calculation-tasks} - #{$filter-container-height});
}
transition: $ease-out;
cursor: pointer;
box-shadow: $shadow-xs;
display: block;
.task {
-webkit-touch-callout: none; // iOS Safari
-webkit-user-select: none; // Safari
-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;
padding: .5rem;
margin: .5rem;
border-radius: $radius;
background: $task-background;
//transition: $ease-out;
cursor: pointer;
box-shadow: $shadow-xs;
display: block;
border: 3px solid transparent;
&.loader-container.is-loading:after {
width: 1.5rem;
height: 1.5rem;
top: calc(50% - .75rem);
left: calc(50% - .75rem);
border-width: 2px;
}
font-size: .9rem;
padding: .5rem;
margin: .5rem;
border-radius: $radius;
background: $task-background;
h3 {
font-family: $family-sans-serif;
font-size: .85rem;
word-break: break-word;
}
&.loader-container.is-loading:after {
width: 1.5rem;
height: 1.5rem;
top: calc(50% - .75rem);
left: calc(50% - .75rem);
border-width: 2px;
}
.progress {
margin: 8px 0 0 0;
width: 100%;
height: 0.5rem;
}
h3 {
font-family: $family-sans-serif;
font-size: .85rem;
word-break: break-word;
}
.due-date {
float: right;
display: flex;
align-items: center;
.progress {
margin: 8px 0 0 0;
width: 100%;
height: 0.5rem;
}
.icon {
margin-right: .25rem;
}
.due-date {
float: right;
display: flex;
align-items: center;
&.overdue {
color: $red;
}
}
.icon {
margin-right: .25rem;
}
.label-wrapper .tag {
margin: .5rem .5rem 0 0;
}
&.overdue {
color: $red;
}
}
.footer {
background: transparent;
padding: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
.label-wrapper .tag {
margin: .5rem .5rem 0 0;
}
.tag, .assignees, .icon, .priority-label {
margin-top: .25rem;
margin-right: .25rem;
}
.footer {
background: transparent;
padding: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
.assignees {
display: flex;
.tag, .assignees, .icon, .priority-label {
margin-top: .25rem;
margin-right: .25rem;
}
.user {
display: inline;
margin: 0;
.assignees {
display: flex;
img {
margin: 0;
}
}
}
.user {
display: inline;
margin: 0;
.tag {
margin-left: 0;
}
img {
margin: 0;
}
}
}
.priority-label {
font-size: .75rem;
height: 2rem;
.tag {
margin-left: 0;
}
.icon {
height: 1rem;
padding: 0 .25rem;
margin-top: 0;
}
}
}
.priority-label {
font-size: .75rem;
height: 2rem;
.footer .icon,
.due-date,
.priority-label {
background: $grey-100;
border-radius: $radius;
padding: 0 .5rem;
}
.icon {
height: 1rem;
padding: 0 .25rem;
margin-top: 0;
}
}
}
.due-date {
padding: 0 .25rem;
}
.footer .icon,
.due-date,
.priority-label {
background: $grey-100;
border-radius: $radius;
padding: 0 .5rem;
}
.task-id {
color: $grey-500;
font-size: .8rem;
margin-bottom: .25rem;
display: flex;
}
.due-date {
padding: 0 .25rem;
}
.is-done {
font-size: .75rem;
padding: .2rem .3rem;
margin: 0 .25rem 0 0;
}
.task-id {
color: $grey-500;
font-size: .8rem;
margin-bottom: .25rem;
display: flex;
}
&.is-moving {
opacity: .5;
}
.is-done {
font-size: .75rem;
padding: .2rem .3rem;
margin: 0 .25rem 0 0;
}
span {
width: auto;
}
&.is-moving {
opacity: .5;
}
&.has-light-text {
color: $white;
span {
width: auto;
}
.task-id {
color: $grey-200;
}
&.has-light-text {
color: $white;
.footer .icon,
.due-date,
.priority-label {
background: $grey-800;
}
.task-id {
color: $grey-200;
}
.footer {
.icon svg {
fill: $white;
}
}
}
}
.footer .icon,
.due-date,
.priority-label {
background: $grey-800;
}
.drop-preview {
border-radius: $radius;
margin: 0 .5rem .5rem;
background: transparent;
border: 3px dashed $grey-300;
}
}
.footer {
.icon svg {
fill: $white;
}
}
}
h2 {
font-size: 1rem;
margin: 0;
font-weight: 600 !important;
}
&.v-leave, &.v-leave-to, &.v-leave-active
&.move-card-leave, &.move-card-leave-to, &.move-card-leave-active {
display: none;
}
}
&.new-bucket {
// 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;
.dropper {
&, > div {
min-height: 40px;
}
}
}
.button {
background: $bucket-background;
width: 100%;
}
}
.move-card-move {
transition: transform $transition-duration;
}
a.dropdown-item {
padding-right: 1rem;
}
.no-move {
transition: transform 0s;
}
&.is-collapsed {
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;
h2 {
font-size: 1rem;
margin: 0;
font-weight: 600 !important;
}
.tasks, .bucket-footer {
display: none;
}
}
}
&.new-bucket {
// 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 {
display: flex;
align-items: center;
justify-content: space-between;
padding: .5rem;
height: $bucket-header-height;
.button {
background: $bucket-background;
width: 100%;
}
}
.limit {
padding-left: .5rem;
font-weight: bold;
a.dropdown-item {
padding-right: 1rem;
}
&.is-max {
color: $red;
}
}
&.is-collapsed {
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 {
height: auto;
padding: .4rem .5rem;
display: inline-block;
}
}
.tasks, .bucket-footer {
display: none;
}
}
}
.dropdown-trigger {
cursor: pointer;
padding: .5rem;
}
.bucket-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: .5rem;
height: $bucket-header-height;
.bucket-footer {
padding: .5rem;
.limit {
padding-left: .5rem;
font-weight: bold;
.button {
background-color: transparent;
&.is-max {
color: $red;
}
}
&:hover {
background-color: $white;
}
}
}
.title.input {
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 {
transition: transform 0.18s ease;
transform: rotateZ(3deg)
.task-dragging {
transition: transform 0.18s ease;
transform: rotateZ(3deg)
}
.ghost-task-drop {
transition: transform 0.18s ease-in-out;
transform: rotateZ(0deg)
transition: transform 0.18s ease-in-out;
transform: rotateZ(0deg)
}

View File

@ -1,341 +1,363 @@
.tasks-container {
display: flex;
display: flex;
.tasks {
width: 100%;
}
.tasks {
width: 100%;
.taskedit {
width: 50%;
}
.ghost {
border-radius: $radius;
background: $grey-100;
border: 2px dashed $grey-300;
* {
opacity: 0;
}
}
}
.taskedit {
width: 50%;
}
}
.tasks {
margin-top: 1rem;
padding: 0;
text-align: left;
margin-top: 1rem;
padding: 0;
text-align: left;
@media screen and (min-width: $tablet) {
&.short {
max-width: 53vw;
}
}
@media screen and (min-width: $tablet) {
&.short {
max-width: 53vw;
}
}
@media screen and (max-width: $tablet) {
max-width: 100%;
}
@media screen and (max-width: $tablet) {
max-width: 100%;
}
&.noborder {
margin: 1rem -0.5rem;
}
&.noborder {
margin: 1rem -0.5rem;
}
.task {
display: flex;
flex-wrap: wrap;
padding: 0.5rem;
transition: background-color $transition;
align-items: center;
cursor: pointer;
margin: 0 .5rem;
border-radius: $radius;
.task {
display: flex;
flex-wrap: wrap;
padding: .4rem;
transition: background-color $transition;
align-items: center;
cursor: pointer;
margin: 0 .5rem;
border-radius: $radius;
border: 2px solid transparent;
&:first-child {
margin-top: .5rem;
}
&:first-child {
margin-top: .5rem;
}
&:last-child {
margin-bottom: .5rem;
}
&:last-child {
margin-bottom: .5rem;
}
&:hover {
background-color: $grey-100;
}
&:hover {
background-color: $grey-100;
}
.tasktext,
&.tasktext {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
display: inline-block;
flex: 1 0 50%;
.tasktext,
&.tasktext {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
display: inline-block;
flex: 1 0 50%;
.overdue {
color: $red;
}
}
.overdue {
color: $red;
}
}
.task-list {
width: auto;
color: $grey-400;
font-size: .9rem;
white-space: nowrap;
}
.task-list {
width: auto;
color: $grey-400;
font-size: .9rem;
white-space: nowrap;
}
.fancycheckbox span {
display: none;
}
.fancycheckbox span {
display: none;
}
.color-bubble {
height: 10px;
flex: 0 0 10px;
}
.color-bubble {
height: 10px;
flex: 0 0 10px;
}
.tag {
margin: 0 0.5rem;
}
.tag {
margin: 0 0.5rem;
}
.avatar {
border-radius: 50%;
vertical-align: bottom;
margin-left: 5px;
height: 27px;
width: 27px;
}
.avatar {
border-radius: 50%;
vertical-align: bottom;
margin-left: 5px;
height: 27px;
width: 27px;
}
.list-task-icon {
margin-left: 6px;
.list-task-icon {
margin-left: 6px;
&:not(:first-of-type) {
margin-left: 8px;
}
}
&:not(:first-of-type) {
margin-left: 8px;
}
a {
color: $text;
transition: color ease $transition-duration;
}
&:hover {
color: $grey-900;
}
}
a {
color: $text;
transition: color ease $transition-duration;
.favorite {
opacity: 0;
text-align: center;
width: 27px;
transition: opacity $transition, color $transition;
&:hover {
color: $grey-900;
}
}
&:hover {
color: $orange;
}
.favorite {
opacity: 0;
text-align: center;
width: 27px;
transition: opacity $transition, color $transition;
&.is-favorite {
opacity: 1;
color: $orange;