Add limits for kanban boards (#234)

Prevent dropping a task onto a bucket which has its limit reached

Fix closing the dropdown

Add notice to show the limit

Add input to change kanban bucket limit

Add menu item to save bucket limit

Fix parsing dates from the api

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/234
This commit is contained in:
konrad 2020-09-04 20:01:02 +00:00
parent 89c602416c
commit cac8b09263
25 changed files with 124 additions and 43 deletions

View file

@ -1,15 +1,18 @@
export const colorIsDark = color => {
if (color === '#' || color === '') {
return true // Defaults to dark
}
let rgb = parseInt(color.substring(1, 7), 16); // convert rrggbb to decimal
let r = (rgb >> 16) & 0xff; // extract red
let g = (rgb >> 8) & 0xff; // extract green
let b = (rgb >> 0) & 0xff; // extract blue
if (color.substring(0, 1) !== '#') {
color = '#' + color
}
let rgb = parseInt(color.substring(1, 7), 16) // convert rrggbb to decimal
let r = (rgb >> 16) & 0xff // extract red
let g = (rgb >> 8) & 0xff // extract green
let b = (rgb >> 0) & 0xff // extract blue
// luma will be a value 0..255 where 0 indicates the darkest, and 255 the brightest
let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; // per ITU-R BT.709
let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b // per ITU-R BT.709
return luma > 128
}

View file

@ -70,6 +70,7 @@ import { faFilter } from '@fortawesome/free-solid-svg-icons'
import { faFillDrip } from '@fortawesome/free-solid-svg-icons'
import { faKeyboard } from '@fortawesome/free-solid-svg-icons'
import { faComments } from '@fortawesome/free-regular-svg-icons'
import { faSave } from '@fortawesome/free-regular-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
library.add(faSignOutAlt)
@ -117,6 +118,7 @@ library.add(faEllipsisV)
library.add(faFilter)
library.add(faFillDrip)
library.add(faKeyboard)
library.add(faSave)
Vue.component('icon', FontAwesomeIcon)

View file

@ -1,6 +1,6 @@
import AbstractModel from './abstractModel'
import UserModel from './user'
import TaskModel from "./task";
import TaskModel from './task'
export default class BucketModel extends AbstractModel {
constructor(bucket) {
@ -18,6 +18,7 @@ export default class BucketModel extends AbstractModel {
id: 0,
title: '',
listId: 0,
limit: 0,
tasks: [],
createdBy: null,

View file

@ -75,7 +75,7 @@ export default class TaskModel extends AbstractModel {
defaults() {
return {
id: 0,
text: '',
title: '',
description: '',
done: false,
priority: 0,

View file

@ -12,7 +12,7 @@ export default class AttachmentService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.created = formatISO(new Date(model.created))
return model
}

View file

@ -1,5 +1,6 @@
import AbstractService from './abstractService'
import BucketModel from "../models/bucket";
import BucketModel from '../models/bucket'
import TaskService from '@/services/task'
export default class BucketService extends AbstractService {
constructor() {
@ -14,4 +15,10 @@ export default class BucketService extends AbstractService {
modelFactory(data) {
return new BucketModel(data)
}
beforeUpdate(model) {
const taskService = new TaskService()
model.tasks = model.tasks.map(t => taskService.processModel(t))
return model
}
}

View file

@ -14,8 +14,8 @@ export default class LabelService extends AbstractService {
}
processModel(label) {
label.created = formatISO(label.created)
label.updated = formatISO(label.updated)
label.created = formatISO(new Date(label.created))
label.updated = formatISO(new Date(label.updated))
label.hexColor = label.hexColor.substring(1, 7)
return label
}

View file

@ -13,8 +13,8 @@ export default class ListService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.updated = formatISO(model.updated)
model.created = formatISO(new Date(model.created))
model.updated = formatISO(new Date(model.updated))
return model
}

View file

@ -15,8 +15,8 @@ export default class ListService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.updated = formatISO(model.updated)
model.created = formatISO(new Date(model.created))
model.updated = formatISO(new Date(model.updated))
return model
}

View file

@ -10,8 +10,8 @@ export default class ListUserService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.updated = formatISO(model.updated)
model.created = formatISO(new Date(model.created))
model.updated = formatISO(new Date(model.updated))
return model
}

View file

@ -14,8 +14,8 @@ export default class NamespaceService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.updated = formatISO(model.updated)
model.created = formatISO(new Date(model.created))
model.updated = formatISO(new Date(model.updated))
return model
}

View file

@ -37,8 +37,8 @@ export default class TaskService extends AbstractService {
model.dueDate = !model.dueDate ? null : formatISO(new Date(model.dueDate))
model.startDate = !model.startDate ? null : formatISO(new Date(model.startDate))
model.endDate = !model.endDate ? null : formatISO(new Date(model.endDate))
model.created = formatISO(model.created)
model.updated = formatISO(model.updated)
model.created = formatISO(new Date(model.created))
model.updated = formatISO(new Date(model.updated))
// remove all nulls, these would create empty reminders
for (const index in model.reminderDates) {

View file

@ -11,7 +11,7 @@ export default class TaskAssigneeService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.created = formatISO(new Date(model.created))
return model
}

View file

@ -10,8 +10,8 @@ export default class TaskCollectionService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.updated = formatISO(model.updated)
model.created = formatISO(new Date(model.created))
model.updated = formatISO(new Date(model.updated))
return model
}

View file

@ -14,8 +14,8 @@ export default class TaskCommentService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.updated = formatISO(model.updated)
model.created = formatISO(new Date(model.created))
model.updated = formatISO(new Date(model.updated))
return model
}

View file

@ -11,7 +11,7 @@ export default class TaskRelationService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.created = formatISO(new Date(model.created))
return model
}

View file

@ -14,8 +14,8 @@ export default class TeamService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.updated = formatISO(model.updated)
model.created = formatISO(new Date(model.created))
model.updated = formatISO(new Date(model.updated))
return model
}

View file

@ -14,8 +14,8 @@ export default class TeamListService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.updated = formatISO(model.updated)
model.created = formatISO(new Date(model.created))
model.updated = formatISO(new Date(model.updated))
return model
}

View file

@ -12,8 +12,8 @@ export default class TeamMemberService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.updated = formatISO(model.updated)
model.created = formatISO(new Date(model.created))
model.updated = formatISO(new Date(model.updated))
return model
}

View file

@ -14,8 +14,8 @@ export default class TeamNamespaceService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.updated = formatISO(model.updated)
model.created = formatISO(new Date(model.created))
model.updated = formatISO(new Date(model.updated))
return model
}

View file

@ -10,8 +10,8 @@ export default class UserService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.updated = formatISO(model.updated)
model.created = formatISO(new Date(model.created))
model.updated = formatISO(new Date(model.updated))
return model
}

View file

@ -14,8 +14,8 @@ export default class UserListService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.updated = formatISO(model.updated)
model.created = formatISO(new Date(model.created))
model.updated = formatISO(new Date(model.updated))
return model
}

View file

@ -14,8 +14,8 @@ export default class UserNamespaceService extends AbstractService {
}
processModel(model) {
model.created = formatISO(model.created)
model.updated = formatISO(model.updated)
model.created = formatISO(new Date(model.created))
model.updated = formatISO(new Date(model.updated))
return model
}

View file

@ -213,6 +213,14 @@ $crazy-height-calculation: '100vh - 4.5rem - 1.5rem - 1em - 1.5em - 8px';
width: 100%;
}
}
a.dropdown-item {
padding-right: 1rem;
.input {
height: 2.25em;
}
}
}
.bucket-header {
@ -221,6 +229,15 @@ $crazy-height-calculation: '100vh - 4.5rem - 1.5rem - 1em - 1.5em - 8px';
justify-content: space-between;
padding: .5em;
.limit {
padding-left: .5rem;
font-weight: bold;
&.is-max {
color: $red;
}
}
.dropdown-trigger {
cursor: pointer;
}

View file

@ -9,6 +9,12 @@
@focusout="() => saveBucketTitle(bucket.id)"
:ref="`bucket${bucket.id}title`"
@keyup.ctrl.enter="() => saveBucketTitle(bucket.id)">{{ bucket.title }}</h2>
<span
class="limit"
:class="{'is-max': bucket.tasks.length >= bucket.limit}"
v-if="bucket.limit > 0">
{{ bucket.tasks.length }}/{{ bucket.limit }}
</span>
<div
class="dropdown is-right options"
:class="{ 'is-active': bucketOptionsDropDownActive[bucket.id] }"
@ -21,6 +27,33 @@
</div>
<div class="dropdown-menu" role="menu">
<div class="dropdown-content">
<a
class="dropdown-item"
@click.stop="showSetLimitInput = true"
>
<div class="field has-addons" v-if="showSetLimitInput">
<div class="control">
<input
type="number"
class="input"
v-focus.always
v-model="bucket.limit"
@keyup.enter="() => updateBucket(bucket)"
@change="() => updateBucket(bucket)"
/>
</div>
<div class="control">
<a class="button is-primary has-no-shadow">
<span class="icon">
<icon :icon="['far', 'save']"/>
</span>
</a>
</div>
</div>
<template v-else>
Limit: {{ bucket.limit > 0 ? bucket.limit : 'Not set' }}
</template>
</a>
<a
class="dropdown-item has-text-danger"
@click="() => deleteBucketModal(bucket.id)"
@ -43,6 +76,7 @@
:get-child-payload="getTaskPayload(bucket.id)"
:drop-placeholder="dropPlaceholderOptions"
:animation-duration="150"
:should-accept-drop="() => shouldAcceptDrop(bucket)"
drag-class="ghost-task"
drag-class-drop="ghost-task-drop"
drag-handle-selector=".task.draggable"
@ -58,7 +92,7 @@
:class="{
'is-loading': taskService.loading && taskUpdating[task.id],
'draggable': !taskService.loading || !taskUpdating[task.id],
'has-light-text': !colorIsDark(task.hexColor) && task.hexColor !== `#${task.defaultColor}`,
'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)"
@ -235,6 +269,7 @@
animationDuration: 150,
showOnTop: true,
},
sourceBucket: 0,
bucketOptionsDropDownActive: {},
showBucketDeleteModal: false,
@ -245,6 +280,7 @@
newBucketTitle: '',
showNewBucketInput: false,
newTaskError: {},
showSetLimitInput: false,
// We're using this to show the loading animation only at the task when updating it
taskUpdating: {},
@ -362,6 +398,7 @@
getTaskPayload(bucketId) {
return index => {
const bucket = this.buckets[filterObject(this.buckets, b => b.id === bucketId)]
this.sourceBucket = bucket.id
return bucket.tasks[index]
}
},
@ -369,9 +406,11 @@
this.$set(this.showNewTaskInput, bucket, !this.showNewTaskInput[bucket])
},
toggleBucketDropdown(bucketId) {
this.closeBucketDropdowns() // Close all eventually open dropdowns
this.$set(this.bucketOptionsDropDownActive, bucketId, !this.bucketOptionsDropDownActive[bucketId])
},
closeBucketDropdowns() {
this.showSetLimitInput = false
for (const bucketId in this.bucketOptionsDropDownActive) {
this.bucketOptionsDropDownActive[bucketId] = false
}
@ -481,6 +520,18 @@
this.error(e, this)
})
},
updateBucket(bucket) {
bucket.limit = parseInt(bucket.limit)
this.$store.dispatch('kanban/updateBucket', bucket)
.catch(e => {
this.error(e, this)
})
},
shouldAcceptDrop(bucket) {
return bucket.id === this.sourceBucket || // When dragging from a bucket who has its limit reached, dragging should still be possible
bucket.limit === 0 || // If there is no limit set, dragging & dropping should always work
bucket.tasks.length < bucket.limit // Disallow dropping to buckets which have their limit reached
},
},
}
</script>