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:
parent
89c602416c
commit
cac8b09263
25 changed files with 124 additions and 43 deletions
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -75,7 +75,7 @@ export default class TaskModel extends AbstractModel {
|
|||
defaults() {
|
||||
return {
|
||||
id: 0,
|
||||
text: '',
|
||||
title: '',
|
||||
description: '',
|
||||
done: false,
|
||||
priority: 0,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue