feat: improve kanban implementation

This commit is contained in:
Dominik Pschenitschni 2021-09-11 17:53:03 +02:00
parent 43b22360a5
commit d66ad12f5c
No known key found for this signature in database
GPG key ID: B257AC0149F43A77
11 changed files with 230 additions and 127 deletions

View file

@ -25,6 +25,7 @@
"easymde": "^2.15.0",
"highlight.js": "11.2.0",
"is-touch-device": "1.0.1",
"lodash.clonedeep": "^4.5.0",
"marked": "3.0.4",
"register-service-worker": "1.7.2",
"snake-case": "3.0.4",

View file

@ -93,7 +93,11 @@ app.mixin({
})
app.config.errorHandler = (err, vm, info) => {
error(err)
// if (import.meta.env.PROD) {
// error(err)
// } else {
console.error(err, vm, info)
// }
}
app.config.globalProperties.$message = {

View file

@ -18,7 +18,7 @@ export default class BucketService extends AbstractService {
beforeUpdate(model) {
const taskService = new TaskService()
model.tasks = model.tasks.map(t => taskService.processModel(t))
model.tasks = model.tasks?.map(t => taskService.processModel(t))
return model
}
}

View file

@ -37,7 +37,8 @@ export default class TaskService extends AbstractService {
return this.processModel(model)
}
processModel(model) {
processModel(updatedModel) {
const model = { ...updatedModel }
model.title = model.title?.trim()

View file

@ -56,7 +56,10 @@ export const store = createStore({
state.errorMessage = error
},
[ONLINE](state, online) {
state.online = import.meta.env.VITE_IS_ONLINE || online
if (import.meta.env.VITE_IS_ONLINE) {
console.log('Setting fake online state', import.meta.env.VITE_IS_ONLINE)
}
state.online = !!import.meta.env.VITE_IS_ONLINE || online
},
[CURRENT_LIST](state, currentList) {

View file

@ -1,11 +1,16 @@
import cloneDeep from 'lodash.clonedeep'
import {findById, findIndexById} from '@/helpers/utils'
import {i18n} from '@/i18n'
import {success} from '@/message'
import BucketService from '../../services/bucket'
import {setLoading} from '../helper'
import TaskCollectionService from '@/services/taskCollection'
const TASKS_PER_BUCKET = 25
function getTaskPosition(state, task) {
function getTaskIndices(state, task) {
const bucketIndex = findIndexById(state.buckets, task.bucketId)
if (!bucketIndex) {
@ -167,7 +172,7 @@ export default {
return
}
const { bucketIndex, taskIndex } = getTaskPosition(state, task)
const { bucketIndex, taskIndex } = getTaskIndices(state, task)
state.buckets[bucketIndex].tasks.splice(taskIndex, 1)
},
@ -189,23 +194,21 @@ export default {
getBucketById(state) {
return (bucketId) => findById(state.buckets, bucketId)
},
getTaskById: state => id => {
for (const b in state.buckets) {
for (const t in state.buckets[b].tasks) {
if (state.buckets[b].tasks[t].id === id) {
return {
bucketIndex: b,
taskIndex: t,
task: state.buckets[b].tasks[t],
}
}
getTaskById(state) {
return (id) => {
let taskIndex
const bucketIndex = state.buckets.findIndex(({ tasks }) => {
taskIndex = findIndexById(tasks, id)
return taskIndex !== undefined
})
return {
bucketIndex: taskIndex || null,
taskIndex: taskIndex || null,
task: state.buckets?.[bucketIndex].tasks?.[taskIndex] || null,
}
}
return {
bucketIndex: null,
taskIndex: null,
task: null,
}
},
},
@ -256,6 +259,15 @@ export default {
params.sort_by = 'kanban_position'
params.order_by = 'asc'
// const hasBucketFilter = Object.entries(params.filter_by).some(([key, value]) => {
// const condition = value === 'bucket_id'
// if (condition) {
// if (value !== bucketId) {
// params.filter_value[key] = bucketId
// }
// }
// return condition
// })
let hasBucketFilter = false
for (const f in params.filter_by) {
if (params.filter_by[f] === 'bucket_id') {
@ -293,6 +305,7 @@ export default {
ctx.commit('setBucketLoading', {bucketId: bucketId, loading: false})
})
},
createBucket(ctx, bucket) {
const cancel = setLoading(ctx, 'kanban')
@ -309,6 +322,7 @@ export default {
cancel()
})
},
deleteBucket(ctx, {bucket, params}) {
const cancel = setLoading(ctx, 'kanban')
@ -327,24 +341,96 @@ export default {
cancel()
})
},
updateBucket(ctx, bucket) {
updateBucket(ctx, updatedBucketData) {
const cancel = setLoading(ctx, 'kanban')
const bucketIndex = findIndexById(ctx.state.buckets, updatedBucketData.id)
const oldBucket = cloneDeep(ctx.state.buckets[bucketIndex])
const bucket = ctx.state.buckets[bucketIndex]
const requestData = {
id: updatedBucketData.id,
listId: updatedBucketData.listId || oldBucket.listId,
title: oldBucket.title, // can't be empty in request
// ...bucket,
...updatedBucketData,
}
const updatedBucket = {
...bucket,
...requestData,
}
ctx.commit('setBucketByIndex', {bucketIndex, bucket: updatedBucket})
const bucketService = new BucketService()
return bucketService.update(bucket)
return bucketService.update(updatedBucket)
.then(r => {
const bi = findById(ctx.state.buckets, r.id)
const bucket = r
bucket.tasks = ctx.state.buckets[bi].tasks
ctx.commit('setBucketByIndex', {bucketIndex: bi, bucket})
return Promise.resolve(r)
Promise.resolve(r)
})
.catch(e => {
// restore original state
ctx.commit('setBucketByIndex', {bucketIndex, bucket: oldBucket})
return Promise.reject(e)
})
.finally(() => {
cancel()
.finally(() => cancel())
},
updateBuckets(ctx, updatedBucketsData) {
const cancel = setLoading(ctx, 'kanban')
const oldBuckets = []
const updatedBuckets = updatedBucketsData.map((updatedBucketData) => {
const bucketIndex = findIndexById(ctx.state.buckets, updatedBucketData.id)
const bucket = ctx.state.buckets[bucketIndex]
const oldBucket = cloneDeep(bucket)
oldBuckets.push(oldBucket)
const newBucket = {
// FIXME: maybe optional to set the original value as well
...bucket,
id: updatedBucketData.id,
listId: updatedBucketData.listId || oldBucket.listId,
...updatedBucketData,
}
ctx.commit('setBucketByIndex', {bucketIndex, bucket: newBucket})
const bucketService = new BucketService()
return bucketService.update(newBucket)
})
return Promise.all(updatedBuckets)
.then(r => {
Promise.resolve(r)
})
.catch(e => {
// restore original state
Object.values(updatedBuckets).forEach((oldBucket) => ctx.commit('setBucketById', oldBucket))
return Promise.reject(e)
})
.finally(() => cancel())
},
updateBucketTitle(ctx, { id, title }) {
const bucket = findById(ctx.state.buckets, id)
if (bucket.title === title) {
// bucket title has not changed
return
}
const updatedBucketData = {
id,
title,
}
ctx.dispatch('updateBucket', updatedBucketData).then(() => {
success({message: i18n.global.t('list.kanban.bucketTitleSavedSuccess')})
})
},
},
}

View file

@ -117,14 +117,24 @@ export default {
addTaskAttachment(ctx, {taskId, attachment}) {
const t = ctx.rootGetters['kanban/getTaskById'](taskId)
if (t.task !== null) {
const newTask = { ...t }
newTask.task.attachments.push(attachment)
const attachments = [
...t.task.attachments,
attachment,
]
const newTask = {
...t,
task: {
...t.task,
attachments,
},
}
ctx.commit('kanban/setTaskInBucketByIndex', newTask, {root: true})
}
ctx.commit('attachments/add', attachment, {root: true})
},
addAssignee(ctx, {user, taskId}) {
addAssignee(ctx, {user, taskId}) {
const taskAssignee = new TaskAssigneeModel({userId: user.id, taskId: taskId})
const taskAssigneeService = new TaskAssigneeService()

View file

@ -1,9 +1,9 @@
<template>
<div class="kanban-view">
<div class="filter-container" v-if="list.isSavedFilter && !list.isSavedFilter()">
<div class="filter-container" v-if="isSavedFilter">
<div class="items">
<x-button
@click.prevent.stop="showFilters = !showFilters"
@click.prevent.stop="toggleFilterPopup"
icon="filter"
type="secondary"
>
@ -11,19 +11,20 @@
</x-button>
</div>
<filter-popup
@update:modelValue="() => {filtersChanged = true; loadBuckets()}"
:visible="showFilters"
v-model="params"
/>
</div>
<div
:class="{ 'is-loading': loading && !oneTaskUpdating}"
class="kanban kanban-bucket-container loader-container">
class="kanban kanban-bucket-container loader-container"
>
<!-- @end="updateBucketPosition" -->
<draggable
v-bind="dragOptions"
v-model="buckets"
:modelValue="buckets"
@update:modelValue="updateBucketPositions"
@start="() => dragBucket = true"
@end="updateBucketPosition"
group="buckets"
:disabled="!canWrite"
tag="transition-group"
@ -70,14 +71,14 @@
<div class="field has-addons" v-if="showSetLimitInput">
<div class="control">
<input
@change="() => setBucketLimit(bucket)"
@keyup.enter="() => setBucketLimit(bucket)"
@keyup.esc="() => showSetLimitInput = false"
@keyup.enter="() => showSetLimitInput = false"
:value="bucket.limit"
@input="(event) => setBucketLimit(bucket.id, parseInt(event.target.value))"
class="input"
type="number"
min="0"
v-focus.always
v-model="bucket.limit"
/>
</div>
<div class="control">
@ -130,7 +131,8 @@
>
<draggable
v-bind="dragOptions"
v-model="bucket.tasks"
:modelValue="bucket.tasks"
@update:modelValue="(tasks) => updateTasks(bucket.id, tasks)"
@start="() => dragstart(bucket)"
@end="updateTaskPosition"
:group="{name: 'tasks', put: shouldAcceptDrop(bucket) && !dragBucket}"
@ -236,7 +238,6 @@
import draggable from 'vuedraggable'
import BucketModel from '../../../models/bucket'
import {findById} from '@/helpers/utils'
import {mapState} from 'vuex'
import {saveListView} from '@/helpers/saveListView'
import Rights from '../../../models/constants/rights.json'
@ -245,7 +246,7 @@ import FilterPopup from '@/components/list/partials/filter-popup.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState'
import {calculateItemPosition} from '../../../helpers/calculateItemPosition'
import KanbanCard from '../../../components/tasks/partials/kanban-card'
import KanbanCard from '@/components/tasks/partials/kanban-card'
const DRAG_OPTIONS = {
// sortable options
@ -299,20 +300,29 @@ export default {
filter_concat: 'and',
},
showFilters: false,
filtersChanged: false, // To trigger a reload of the board
}
},
created() {
this.loadBuckets()
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
saveListView(this.$route.params.listId, this.$route.name)
},
watch: {
'$route.params.listId': 'loadBuckets',
loadBucketParameter: {
handler: 'loadBuckets',
immediate: true,
},
},
computed: {
isSavedFilter() {
return this.list.isSavedFilter && !this.list.isSavedFilter()
},
loadBucketParameter() {
return {
listId: this.$route.params.listId,
params: this.params,
}
},
bucketDraggableComponentData() {
return {
type: 'transition',
@ -340,6 +350,7 @@ export default {
return this.$store.state.kanban.buckets
},
set(value) {
console.log('should not set buckets', value)
this.$store.commit('kanban/setBuckets', value)
},
},
@ -351,29 +362,25 @@ export default {
list: state => state.currentList,
}),
},
methods: {
loadBuckets() {
toggleFilterPopup() {
this.showFilters = !this.showFilters
},
loadBuckets() {
// Prevent trying to load buckets if the task popup view is active
if (this.$route.name !== 'list.kanban') {
return
}
// Only load buckets if we don't already loaded them
if (
!this.filtersChanged && (
this.loadedListId === this.$route.params.listId ||
this.loadedListId === parseInt(this.$route.params.listId))
) {
return
}
const { listId, params } = this.loadBucketParameter
this.collapsedBuckets = getCollapsedBucketState(this.$route.params.listId)
this.collapsedBuckets = getCollapsedBucketState(listId)
console.debug(`Loading buckets, loadedListId = ${this.loadedListId}, $route.params =`, this.$route.params)
this.filtersChanged = false
this.$store.dispatch('kanban/loadBucketsForList', {listId: this.$route.params.listId, params: this.params})
this.$store.dispatch('kanban/loadBucketsForList', {listId, params})
},
setTaskContainerRef(id, el) {
@ -394,6 +401,16 @@ export default {
bucketId: id,
})
},
updateTasks(bucketId, tasks) {
const newBucket = {
...this.$store.getters['kanban/getBucketById'](bucketId),
tasks,
}
this.$store.dispatch('kanban/updateBucket', newBucket)
},
updateTaskPosition(e) {
this.drag = false
@ -407,20 +424,23 @@ export default {
const taskBefore = newBucket.tasks[e.newIndex - 1] ?? null
const taskAfter = newBucket.tasks[e.newIndex + 1] ?? null
task.kanbanPosition = calculateItemPosition(taskBefore !== null ? taskBefore.kanbanPosition : null, taskAfter !== null ? taskAfter.kanbanPosition : null)
task.bucketId = newBucket.id
// task.kanbanPosition = calculateItemPosition(taskBefore !== null ? taskBefore.kanbanPosition : null, taskAfter !== null ? taskAfter.kanbanPosition : null)
// task.bucketId = newBucket.id
this.$store.dispatch('tasks/update', task)
.catch(e => {
this.$message.error(e)
})
.finally(() => {
const newTask = {
...task,
bucketId: newBucket.id,
kanbanPosition: calculateItemPosition(taskBefore !== null ? taskBefore.kanbanPosition : null, taskAfter !== null ? taskAfter.kanbanPosition : null),
}
this.$store.dispatch('tasks/update', newTask)
// .finally(() => {
this.taskUpdating[task.id] = false
this.oneTaskUpdating = false
})
// })
},
toggleShowNewTaskInput(bucket) {
this.showNewTaskInput[bucket] = !this.showNewTaskInput[bucket]
toggleShowNewTaskInput(bucketId) {
this.showNewTaskInput[bucketId] = !this.showNewTaskInput[bucketId]
},
addTaskToBucket(bucketId) {
@ -463,9 +483,6 @@ export default {
this.newBucketTitle = ''
this.showNewBucketInput = false
})
.catch(e => {
this.$message.error(e)
})
},
deleteBucketModal(bucketId) {
if (this.buckets.length <= 1) {
@ -478,16 +495,10 @@ export default {
deleteBucket() {
const bucket = new BucketModel({
id: this.bucketToDelete,
listId: this.$route.params.listId,
})
this.$store.dispatch('kanban/deleteBucket', {bucket: bucket, params: this.params})
.then(() => {
this.$message.success({message: this.$t('list.kanban.deleteBucketSuccess')})
})
.catch(e => {
this.$message.error(e)
})
.then(() => this.$message.success({message: this.$t('list.kanban.deleteBucketSuccess')}))
.finally(() => {
this.showBucketDeleteModal = false
})
@ -497,40 +508,22 @@ export default {
this.bucketTitleEditable = true
this.$nextTick(() => e.target.focus())
},
saveBucketTitle(bucketId, bucketTitle) {
this.bucketTitleEditable = false
const bucket = new BucketModel({
const updatedBucketData = {
id: bucketId,
title: bucketTitle,
listId: Number(this.$route.params.listId),
})
// Because the contenteditable does not have a change event,
// we're building it ourselves here and only updating the bucket
// if the title changed.
const realBucket = findById(this.buckets, bucketId)
if (realBucket.title === bucketTitle) {
return
}
this.$store.dispatch('kanban/updateBucket', bucket)
.then(r => {
realBucket.title = r.title
this.$store.dispatch('kanban/updateBucketTitle', updatedBucketData)
.then(() => {
this.bucketTitleEditable = false
this.$message.success({message: this.$t('list.kanban.bucketTitleSavedSuccess')})
})
.catch(e => {
this.$message.error(e)
})
},
updateBucket(bucket) {
bucket.limit = parseInt(bucket.limit)
this.$store.dispatch('kanban/updateBucket', bucket)
.then(() => {
this.$message.success({message: this.$t('list.kanban.bucketLimitSavedSuccess')})
})
.catch(e => {
this.$message.error(e)
})
updateBucketPositions(buckets) {
this.$store.dispatch('kanban/updateBuckets', buckets)
},
updateBucketPosition(e) {
this.dragBucket = false
@ -539,19 +532,26 @@ export default {
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)
const updatedData = {
id: bucket.id,
position: calculateItemPosition(bucketBefore !== null ? bucketBefore.position : null, bucketAfter !== null ? bucketAfter.position : null),
}
this.$store.dispatch('kanban/updateBucket', bucket)
.catch(e => {
this.$message.error(e)
})
this.$store.dispatch('kanban/updateBucket', updatedData)
},
setBucketLimit(bucket) {
if (bucket.limit < 0) {
setBucketLimit(bucketId, limit) {
if (limit < 0) {
return
}
this.updateBucket(bucket)
const newBucket = {
...this.$store.getters['kanban/getBucketById'](bucketId),
limit,
}
this.$store.dispatch('kanban/updateBucket', newBucket)
.then(() => this.$message.success({message: this.$t('list.kanban.bucketLimitSavedSuccess')}))
},
shouldAcceptDrop(bucket) {
return bucket.id === this.sourceBucket || // When dragging from a bucket who has its limit reached, dragging should still be possible
@ -563,15 +563,13 @@ export default {
this.sourceBucket = bucket.id
},
toggleDoneBucket(bucket) {
bucket.isDoneBucket = !bucket.isDoneBucket
this.$store.dispatch('kanban/updateBucket', bucket)
.then(() => {
this.$message.success({message: this.$t('list.kanban.doneBucketSavedSuccess')})
})
.catch(e => {
this.$message.error(e)
bucket.isDoneBucket = !bucket.isDoneBucket
})
const newBucket = {
...bucket,
isDoneBucket: !bucket.isDoneBucket,
}
this.$store.dispatch('kanban/updateBucket', newBucket)
.then(() => this.$message.success({message: this.$t('list.kanban.doneBucketSavedSuccess')}))
.catch(e => this.$message.error(e))
},
collapseBucket(bucket) {
this.collapsedBuckets[bucket.id] = true

View file

@ -52,7 +52,7 @@
<filter-popup
:visible="showTaskFilter"
v-model="params"
@update:modelValue="loadTasks(1)"
@update:modelValue="loadTasks()"
/>
</div>

View file

@ -61,7 +61,7 @@
<filter-popup
:visible="showTaskFilter"
v-model="params"
@update:modelValue="loadTasks(1)"
@update:modelValue="loadTasks()"
/>
</div>

View file

@ -20,7 +20,7 @@ module.exports = {
template: {
compilerOptions: {
compatConfig: {
MODE: 2,
MODE: 3,
},
},
},