Kanban (#118)
Add error message when trying to create an invalid new task in a bucket Prevent creation of new buckets if the bucket title is empty Disable deleting a bucket if it's the last one Disable dragging tasks when they are being updated Fix transition when opening tasks Send the user to list view by default Show loading spinner when updating multiple tasks Add loading spinner when moving tasks Add loading animation when bucket is loading / updating etc Add bucket title edit Fix creating new buckets Add loading animation Add removing buckets Fix creating a new bucket after tasks were moved Fix warning about labels on tasks Fix labels on tasks not updating after retrieval from api Fix property width Add closing and mobile design Make the task detail popup look good Move list views Move task detail view in a popup Add link to tasks Add saving the new task position after it was moved Fix creating new bucket Fix creating a new task Cleanup Disable user selection for task cards Fix drag placeholder Add dragging style to task Add placeholder + change animation duration More cleanup Cleanup / docs Working of dragging and dropping tasks Adjust markup and styling for new library Change kanban library to something that works Add basic calculation of new positions Don't try to create empty tasks Add indicator if a task is done Add moving tasks between buckets Make empty buckets a little smaller Add gimmick for button description Fix color Fix scrolling bucket layout Add creating a new bucket Add hiding the task input field Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/118
This commit is contained in:
parent
ea6fda8a9d
commit
c7845bb9c1
37 changed files with 1140 additions and 213 deletions
|
@ -19,7 +19,8 @@
|
|||
"verte": "0.0.12",
|
||||
"vue": "2.6.11",
|
||||
"vue-drag-resize": "1.3.2",
|
||||
"vue-easymde": "1.2.0"
|
||||
"vue-easymde": "1.2.0",
|
||||
"vue-smooth-dnd": "^0.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.28",
|
||||
|
|
|
@ -141,7 +141,7 @@
|
|||
<div class="more-container" :key="n.id + 'child'">
|
||||
<ul class="menu-list can-be-hidden" >
|
||||
<li v-for="l in n.lists" :key="l.id">
|
||||
<router-link :to="{ name: 'showList', params: { id: l.id} }">
|
||||
<router-link :to="{ name: 'showList', params: { listId: l.id} }">
|
||||
<span class="name">
|
||||
<span class="color-bubble" v-if="l.hexColor !== ''" :style="{ backgroundColor: l.hexColor }"></span>
|
||||
{{l.title}}
|
||||
|
@ -296,7 +296,7 @@
|
|||
},
|
||||
watch: {
|
||||
// call the method again if the route changes
|
||||
'$route': 'doStuffAfterRoute'
|
||||
'$route': 'doStuffAfterRoute',
|
||||
},
|
||||
computed: {
|
||||
userInfo() {
|
||||
|
|
|
@ -38,6 +38,11 @@
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.vue-notification {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: .5em;
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
default: 50,
|
||||
},
|
||||
isInline: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
|
|
@ -70,7 +70,7 @@
|
|||
.then(response => {
|
||||
this.$parent.loadNamespaces()
|
||||
this.success({message: 'The list was successfully created.'}, this)
|
||||
router.push({name: 'showList', params: {id: response.id}})
|
||||
router.push({name: 'showList', params: {listId: response.id}})
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e, this)
|
||||
|
|
|
@ -10,15 +10,14 @@
|
|||
It is not possible to create new or edit tasks or it.
|
||||
</div>
|
||||
<div class="switch-view">
|
||||
<router-link :to="{ name: 'showList', params: { id: list.id } }" :class="{'is-active': $route.params.type !== 'gantt' && $route.params.type !== 'table'}">List</router-link>
|
||||
<router-link :to="{ name: 'showListWithType', params: { id: list.id, type: 'gantt' } }" :class="{'is-active': $route.params.type === 'gantt'}">Gantt</router-link>
|
||||
<router-link :to="{ name: 'showListWithType', params: { id: list.id, type: 'table' } }" :class="{'is-active': $route.params.type === 'table'}">Table</router-link>
|
||||
<router-link :to="{ name: 'list.list', params: { id: $route.params.listId } }" :class="{'is-active': $route.name === 'list.list'}">List</router-link>
|
||||
<router-link :to="{ name: 'list.gantt', params: { id: $route.params.listId } }" :class="{'is-active': $route.name === 'list.gantt'}">Gantt</router-link>
|
||||
<router-link :to="{ name: 'list.table', params: { id: $route.params.listId } }" :class="{'is-active': $route.name === 'list.table'}">Table</router-link>
|
||||
<router-link :to="{ name: 'list.kanban', params: { id: $route.params.listId } }" :class="{'is-active': $route.name === 'list.kanban'}">Kanban</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<gantt :list="list" v-if="$route.params.type === 'gantt'"/>
|
||||
<table-view :list="list" v-else-if="$route.params.type === 'table'"/>
|
||||
<show-list-task :the-list="list" v-else/>
|
||||
<router-view/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -26,58 +25,56 @@
|
|||
import auth from '../../auth'
|
||||
import router from '../../router'
|
||||
|
||||
import ShowListTask from '../tasks/ShowListTasks'
|
||||
import Gantt from '../tasks/Gantt'
|
||||
|
||||
import ListModel from '../../models/list'
|
||||
import ListService from '../../services/list'
|
||||
import authType from '../../models/authTypes'
|
||||
import TableView from '../tasks/TableView'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
listId: this.$route.params.id,
|
||||
listService: ListService,
|
||||
list: ListModel,
|
||||
listLoaded: 0,
|
||||
}
|
||||
},
|
||||
components: {
|
||||
TableView,
|
||||
Gantt,
|
||||
ShowListTask,
|
||||
},
|
||||
beforeMount() {
|
||||
// Check if the user is already logged in, if so, redirect him to the homepage
|
||||
if (!auth.user.authenticated && auth.user.infos.type !== authType.LINK_SHARE) {
|
||||
router.push({name: 'home'})
|
||||
}
|
||||
|
||||
// If the type is invalid, redirect the user
|
||||
if (
|
||||
auth.user.authenticated &&
|
||||
auth.user.infos.type !== authType.LINK_SHARE &&
|
||||
this.$route.params.type !== 'gantt' &&
|
||||
this.$route.params.type !== 'table' &&
|
||||
this.$route.params.type !== 'list' &&
|
||||
this.$route.params.type !== ''
|
||||
) {
|
||||
router.push({name: 'showList', params: { id: this.$route.params.id }})
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.listService = new ListService()
|
||||
this.list = new ListModel()
|
||||
},
|
||||
mounted() {
|
||||
this.loadList()
|
||||
},
|
||||
watch: {
|
||||
// call again the method if the route changes
|
||||
'$route.path': 'loadList'
|
||||
'$route.path': 'loadList',
|
||||
},
|
||||
methods: {
|
||||
loadList() {
|
||||
|
||||
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently
|
||||
if(this.$route.params.listId === this.listLoaded || typeof this.$route.params.listId === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect the user to list view by default
|
||||
if (
|
||||
this.$route.name !== 'list.list' &&
|
||||
this.$route.name !== 'list.gantt' &&
|
||||
this.$route.name !== 'list.table' &&
|
||||
this.$route.name !== 'list.kanban'
|
||||
) {
|
||||
router.push({name: 'list.list', params: {id: this.$route.params.listId}})
|
||||
return
|
||||
}
|
||||
|
||||
// We create an extra list object instead of creating it in this.list because that would trigger a ui update which would result in bad ux.
|
||||
let list = new ListModel({id: this.$route.params.id})
|
||||
let list = new ListModel({id: this.$route.params.listId})
|
||||
this.listService.get(list)
|
||||
.then(r => {
|
||||
this.$set(this, 'list', r)
|
||||
|
@ -85,6 +82,9 @@
|
|||
.catch(e => {
|
||||
this.error(e, this)
|
||||
})
|
||||
.finally(() => {
|
||||
this.listLoaded = this.$route.params.listId
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,20 +44,25 @@
|
|||
</div>
|
||||
</div>
|
||||
<gantt-chart
|
||||
:list="list"
|
||||
:list-id="Number($route.params.listId)"
|
||||
:show-taskswithout-dates="showTaskswithoutDates"
|
||||
:date-from="dateFrom"
|
||||
:date-to="dateTo"
|
||||
:day-width="dayWidth"
|
||||
/>
|
||||
|
||||
<!-- This router view is used to show the task popup while keeping the gantt chart itself -->
|
||||
<transition name="modal">
|
||||
<router-view/>
|
||||
</transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import GanttChart from './gantt-component'
|
||||
import GanttChart from '../../tasks/gantt-component'
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import ListModel from '../../models/list'
|
||||
import Fancycheckbox from '../global/fancycheckbox'
|
||||
import Fancycheckbox from '../../global/fancycheckbox'
|
||||
|
||||
export default {
|
||||
name: 'Gantt',
|
||||
|
@ -84,11 +89,5 @@
|
|||
this.dateFrom = new Date((new Date()).setDate((new Date()).getDate() - 15))
|
||||
this.dateTo = new Date((new Date()).setDate((new Date()).getDate() + 30))
|
||||
},
|
||||
props: {
|
||||
list: {
|
||||
type: ListModel,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
447
src/components/lists/views/Kanban.vue
Normal file
447
src/components/lists/views/Kanban.vue
Normal file
|
@ -0,0 +1,447 @@
|
|||
<template>
|
||||
<div class="kanban loader-container" :class="{ 'is-loading': bucketService.loading}">
|
||||
<div v-for="bucket in buckets" :key="`bucket${bucket.id}`" class="bucket">
|
||||
<div class="bucket-header">
|
||||
<h2
|
||||
class="title input"
|
||||
contenteditable="true"
|
||||
@focusout="() => saveBucketTitle(bucket.id)"
|
||||
:ref="`bucket${bucket.id}title`"
|
||||
@keyup.ctrl.enter="() => saveBucketTitle(bucket.id)">{{ bucket.title }}</h2>
|
||||
<div class="dropdown is-right options" :class="{ 'is-active': bucketOptionsDropDownActive[bucket.id] }">
|
||||
<div class="dropdown-trigger" @click.stop="toggleBucketDropdown(bucket.id)">
|
||||
<span class="icon">
|
||||
<icon icon="ellipsis-v"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="dropdown-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
<a
|
||||
class="dropdown-item has-text-danger"
|
||||
@click="() => deleteBucketModal(bucket.id)"
|
||||
:class="{'is-disabled': buckets.length <= 1}"
|
||||
v-tooltip="buckets.length <= 1 ? 'You cannot remove the last bucket.' : ''"
|
||||
>
|
||||
<span class="icon is-small"><icon icon="trash-alt"/></span>
|
||||
Delete
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tasks">
|
||||
<Container
|
||||
@drop="e => onDrop(bucket.id, e)"
|
||||
group-name="buckets"
|
||||
:get-child-payload="getTaskPayload(bucket.id)"
|
||||
:drop-placeholder="dropPlaceholderOptions"
|
||||
:animation-duration="150"
|
||||
drag-class="ghost-task"
|
||||
drag-class-drop="ghost-task-drop"
|
||||
drag-handle-selector=".task.draggable"
|
||||
>
|
||||
<Draggable v-for="task in bucket.tasks" :key="`bucket${bucket.id}-task${task.id}`">
|
||||
<router-link
|
||||
:to="{ name: 'task.kanban.detail', params: { id: task.id } }"
|
||||
class="task loader-container draggable"
|
||||
tag="div"
|
||||
:class="{
|
||||
'is-loading': taskService.loading && taskUpdating[task.id],
|
||||
'draggable': !taskService.loading || !taskUpdating[task.id]
|
||||
}"
|
||||
>
|
||||
<span
|
||||
class="color"
|
||||
:style="{ 'background-color': task.hexColor }"
|
||||
v-if="task.hexColor !== '#' + task.defaultColor">
|
||||
</span>
|
||||
<span class="task-id">
|
||||
<span class="is-done" v-if="task.done">Done</span>
|
||||
#{{ task.id }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.dueDate > 0"
|
||||
class="due-date"
|
||||
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
|
||||
v-tooltip="formatDate(task.dueDate)">
|
||||
<span class="icon">
|
||||
<icon :icon="['far', 'calendar-alt']"/>
|
||||
</span>
|
||||
<span>
|
||||
{{ formatDateSince(task.dueDate) }}
|
||||
</span>
|
||||
</span>
|
||||
<h3>{{ task.text }}</h3>
|
||||
<labels :labels="task.labels"/>
|
||||
<div class="footer">
|
||||
<div class="items">
|
||||
<priority-label :priority="task.priority" class="priority-label"/>
|
||||
<div class="assignees" v-if="task.assignees.length > 0">
|
||||
<user
|
||||
v-for="u in task.assignees"
|
||||
:key="task.id + 'assignee' + u.id"
|
||||
:user="u"
|
||||
:show-username="false"
|
||||
:avatar-size="24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="icon" v-if="task.attachments.length > 0">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect fill="none" rx="0" ry="0"></rect>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M19.86 8.29994C19.8823 8.27664 19.9026 8.25201 19.9207 8.22634C20.5666 7.53541 20.93 6.63567 20.93 5.68001C20.93 4.69001 20.55 3.76001 19.85 3.06001C18.45 1.66001 16.02 1.66001 14.62 3.06001L9.88002 7.80001C9.86705 7.81355 9.85481 7.82753 9.8433 7.8419L4.58 13.1C3.6 14.09 3.06 15.39 3.06 16.78C3.06 18.17 3.6 19.48 4.58 20.46C5.6 21.47 6.93 21.98 8.26 21.98C9.59 21.98 10.92 21.47 11.94 20.46L17.74 14.66C17.97 14.42 17.98 14.04 17.74 13.81C17.5 13.58 17.12 13.58 16.89 13.81L11.09 19.61C10.33 20.36 9.33 20.78 8.26 20.78C7.19 20.78 6.19 20.37 5.43 19.61C4.68 18.85 4.26 17.85 4.26 16.78C4.26 15.72 4.68 14.71 5.43 13.96L15.47 3.91996C15.4962 3.89262 15.5195 3.86346 15.54 3.83292C16.4992 2.95103 18.0927 2.98269 19.01 3.90001C19.48 4.37001 19.74 5.00001 19.74 5.67001C19.74 6.34001 19.48 6.97001 19.01 7.44001L14.27 12.18C14.2571 12.1935 14.2448 12.2075 14.2334 12.2218L8.96 17.4899C8.59 17.8699 7.93 17.8699 7.55 17.4899C7.36 17.2999 7.26 17.0399 7.26 16.7799C7.26 16.5199 7.36 16.2699 7.55 16.0699L15.47 8.14994C15.7 7.90994 15.71 7.52994 15.47 7.29994C15.23 7.06994 14.85 7.06994 14.62 7.29994L6.7 15.2199C6.29 15.6399 6.06 16.1899 6.06 16.7799C6.06 17.3699 6.29 17.9199 6.7 18.3399C7.12 18.7499 7.67 18.9799 8.26 18.9799C8.85 18.9799 9.4 18.7599 9.82 18.3399L19.86 8.29994Z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</Draggable>
|
||||
</Container>
|
||||
</div>
|
||||
<div class="bucket-footer">
|
||||
<div class="field" v-if="showNewTaskInput[bucket.id]">
|
||||
<div class="control">
|
||||
<input
|
||||
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="Enter the new task text..."
|
||||
v-focus
|
||||
@focusout="toggleShowNewTaskInput(bucket.id)"
|
||||
@keyup.esc="toggleShowNewTaskInput(bucket.id)"
|
||||
@keyup.enter="addTaskToBucket(bucket.id)"
|
||||
v-model="newTaskText"
|
||||
:disabled="taskService.loading"
|
||||
:class="{'is-loading': taskService.loading}"
|
||||
/>
|
||||
</div>
|
||||
<p class="help is-danger" v-if="newTaskError[bucket.id] && newTaskText.length < 3">
|
||||
Please specify at least three characters.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
class="button noshadow is-transparent is-fullwidth has-text-centered"
|
||||
@click="toggleShowNewTaskInput(bucket.id)"
|
||||
v-if="!showNewTaskInput[bucket.id]">
|
||||
<span class="icon is-small">
|
||||
<icon icon="plus"/>
|
||||
</span>
|
||||
<span v-if="bucket.tasks.length === 0">
|
||||
Add a task
|
||||
</span>
|
||||
<span v-else>
|
||||
Add another task
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bucket new-bucket" v-if="!bucketService.loading">
|
||||
<input
|
||||
v-if="showNewBucketInput"
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="Enter the new bucket title..."
|
||||
v-focus
|
||||
@focusout="() => showNewBucketInput = false"
|
||||
@keyup.esc="() => showNewBucketInput = false"
|
||||
@keyup.enter="createNewBucket"
|
||||
v-model="newBucketTitle"
|
||||
:disabled="bucketService.loading"
|
||||
:class="{'is-loading': bucketService.loading}"
|
||||
/>
|
||||
<a
|
||||
class="button noshadow is-transparent is-fullwidth has-text-centered"
|
||||
@click="() => showNewBucketInput = true" v-if="!showNewBucketInput">
|
||||
<span class="icon is-small">
|
||||
<icon icon="plus"/>
|
||||
</span>
|
||||
<span>
|
||||
Create a new bucket
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- This router view is used to show the task popup while keeping the kanban board itself -->
|
||||
<transition name="modal">
|
||||
<router-view/>
|
||||
</transition>
|
||||
|
||||
<modal
|
||||
v-if="showBucketDeleteModal"
|
||||
@close="showBucketDeleteModal = false"
|
||||
@submit="deleteBucket()">
|
||||
<span slot="header">Delete the bucket</span>
|
||||
<p slot="text">
|
||||
Are you sure you want to delete this bucket?<br/>
|
||||
This will not delete any tasks but move them into the default bucket.
|
||||
</p>
|
||||
</modal>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BucketService from '../../../services/bucket'
|
||||
import TaskService from '../../../services/task'
|
||||
import TaskModel from '../../../models/task'
|
||||
import BucketModel from '../../../models/bucket'
|
||||
|
||||
import {Container, Draggable} from 'vue-smooth-dnd'
|
||||
import PriorityLabel from '../../tasks/reusable/priorityLabel'
|
||||
import User from '../../global/user'
|
||||
import Labels from '../../tasks/reusable/labels'
|
||||
|
||||
import {filterObject} from '../../../helpers/filterObject'
|
||||
import {applyDrag} from '../../../helpers/applyDrag'
|
||||
|
||||
export default {
|
||||
name: 'Kanban',
|
||||
components: {
|
||||
Container,
|
||||
Draggable,
|
||||
Labels,
|
||||
User,
|
||||
PriorityLabel,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
bucketService: BucketService,
|
||||
buckets: [],
|
||||
taskService: TaskService,
|
||||
|
||||
// We're directly using the list id from the route since that one is always available
|
||||
listId: this.$route.params.listId,
|
||||
|
||||
dropPlaceholderOptions: {
|
||||
className: 'drop-preview',
|
||||
animationDuration: 150,
|
||||
showOnTop: true,
|
||||
},
|
||||
bucketOptionsDropDownActive: {},
|
||||
|
||||
showBucketDeleteModal: false,
|
||||
bucketToDelete: 0,
|
||||
|
||||
newTaskText: '',
|
||||
showNewTaskInput: {},
|
||||
newBucketTitle: '',
|
||||
showNewBucketInput: false,
|
||||
newTaskError: {},
|
||||
|
||||
// We're using this to show the loading animation only at the task when updating it
|
||||
taskUpdating: {},
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.bucketService = new BucketService()
|
||||
this.taskService = new TaskService()
|
||||
this.loadBuckets()
|
||||
setTimeout(() => document.addEventListener('click', this.closeBucketDropdowns), 0)
|
||||
},
|
||||
methods: {
|
||||
loadBuckets() {
|
||||
this.bucketService.getAll({listId: this.listId})
|
||||
.then(r => {
|
||||
this.buckets = r
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e, this)
|
||||
})
|
||||
},
|
||||
onDrop(bucketId, dropResult) {
|
||||
|
||||
// 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
|
||||
|
||||
const bucketIndex = filterObject(this.buckets, b => b.id === bucketId)
|
||||
|
||||
if (dropResult.removedIndex !== null || dropResult.addedIndex !== null) {
|
||||
|
||||
// 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
|
||||
this.buckets = 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)
|
||||
|
||||
// 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.taskService.update(task)
|
||||
.then(t => {
|
||||
// Update the block with the new task details
|
||||
this.$set(this.buckets[bucketIndex].tasks, taskIndex, t)
|
||||
this.success({message: 'The task was moved successfully!'}, this)
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e, this)
|
||||
})
|
||||
.finally(() => {
|
||||
this.$set(this.taskUpdating, task.id, false)
|
||||
})
|
||||
}
|
||||
},
|
||||
getTaskPayload(bucketId) {
|
||||
return index => {
|
||||
const bucket = this.buckets[filterObject(this.buckets, b => b.id === bucketId)]
|
||||
return bucket.tasks[index]
|
||||
}
|
||||
},
|
||||
getBlockFromTask(task) {
|
||||
return {
|
||||
id: task.id,
|
||||
status: 'bucket' + task.bucketId,
|
||||
// We're putting the task in an extra property so we won't have to maintin this whole thing because of basically recreating the task model.
|
||||
task: task,
|
||||
}
|
||||
},
|
||||
toggleShowNewTaskInput(bucket) {
|
||||
this.$set(this.showNewTaskInput, bucket, !this.showNewTaskInput[bucket])
|
||||
},
|
||||
toggleBucketDropdown(bucketId) {
|
||||
this.$set(this.bucketOptionsDropDownActive, bucketId, !this.bucketOptionsDropDownActive[bucketId])
|
||||
},
|
||||
closeBucketDropdowns() {
|
||||
for (const bucketId in this.bucketOptionsDropDownActive) {
|
||||
this.bucketOptionsDropDownActive[bucketId] = false
|
||||
}
|
||||
},
|
||||
addTaskToBucket(bucketId) {
|
||||
|
||||
if (this.newTaskText.length < 3) {
|
||||
this.$set(this.newTaskError, bucketId, true)
|
||||
return
|
||||
}
|
||||
this.$set(this.newTaskError, bucketId, false)
|
||||
|
||||
// We need the actual bucket index so we put that in a seperate function
|
||||
const bucketIndex = () => {
|
||||
for (const t in this.buckets) {
|
||||
if (this.buckets[t].id === bucketId) {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bi = bucketIndex()
|
||||
|
||||
const task = new TaskModel({text: this.newTaskText, bucketId: this.buckets[bi].id, listId: this.listId})
|
||||
|
||||
this.taskService.create(task)
|
||||
.then(r => {
|
||||
this.newTaskText = ''
|
||||
this.buckets[bi].tasks.push(r)
|
||||
this.success({message: 'The task was created successfully!'}, this)
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e, this)
|
||||
})
|
||||
},
|
||||
createNewBucket() {
|
||||
if (this.newBucketTitle === '') {
|
||||
return
|
||||
}
|
||||
|
||||
const newBucket = new BucketModel({title: this.newBucketTitle, listId: parseInt(this.listId)})
|
||||
|
||||
this.bucketService.create(newBucket)
|
||||
.then(r => {
|
||||
this.newBucketTitle = ''
|
||||
this.showNewBucketInput = false
|
||||
if (Array.isArray(this.buckets)) {
|
||||
this.buckets.push(r)
|
||||
} else {
|
||||
this.buckets[r.id] = r
|
||||
}
|
||||
this.success({message: 'The bucket was created successfully!'}, this)
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e, this)
|
||||
})
|
||||
},
|
||||
deleteBucketModal(bucketId) {
|
||||
if (this.buckets.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
this.bucketToDelete = bucketId
|
||||
this.showBucketDeleteModal = true
|
||||
},
|
||||
deleteBucket() {
|
||||
const bucket = new BucketModel({
|
||||
id: this.bucketToDelete,
|
||||
listId: this.listId,
|
||||
})
|
||||
this.bucketService.delete(bucket)
|
||||
.then(r => {
|
||||
this.loadBuckets()
|
||||
this.success(r, this)
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e, this)
|
||||
})
|
||||
.finally(() => {
|
||||
this.showBucketDeleteModal = false
|
||||
})
|
||||
},
|
||||
saveBucketTitle(bucketId) {
|
||||
const bucketTitle = this.$refs[`bucket${bucketId}title`][0].textContent
|
||||
const bucket = new BucketModel({
|
||||
id: bucketId,
|
||||
title: bucketTitle,
|
||||
listId: Number(this.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 = this.buckets[filterObject(this.buckets, b => b.id === bucketId)]
|
||||
if (realBucket.title === bucketTitle) {
|
||||
return
|
||||
}
|
||||
|
||||
this.bucketService.update(bucket)
|
||||
.then(r => {
|
||||
this.success({message: 'The bucket title was updated successfully!'}, this)
|
||||
realBucket.title = r.title
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e, this)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -98,19 +98,24 @@
|
|||
</template>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- This router view is used to show the task popup while keeping the kanban board itself -->
|
||||
<transition name="modal">
|
||||
<router-view/>
|
||||
</transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TaskService from '../../services/task'
|
||||
import ListModel from '../../models/list'
|
||||
import EditTask from './edit-task'
|
||||
import TaskModel from '../../models/task'
|
||||
import SingleTaskInList from './reusable/singleTaskInList'
|
||||
import taskList from './helpers/taskList'
|
||||
import TaskService from '../../../services/task'
|
||||
import EditTask from '../../tasks/edit-task'
|
||||
import TaskModel from '../../../models/task'
|
||||
import SingleTaskInList from '../../tasks/reusable/singleTaskInList'
|
||||
import taskList from '../../tasks/helpers/taskList'
|
||||
|
||||
export default {
|
||||
name: 'ListView',
|
||||
name: 'List',
|
||||
data() {
|
||||
return {
|
||||
listId: this.$route.params.id,
|
||||
|
@ -130,17 +135,6 @@
|
|||
SingleTaskInList,
|
||||
EditTask,
|
||||
},
|
||||
props: {
|
||||
theList: {
|
||||
type: ListModel,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
theList() {
|
||||
this.list = this.theList
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.taskService = new TaskService()
|
||||
},
|
|
@ -85,13 +85,13 @@
|
|||
<tbody>
|
||||
<tr v-for="t in tasks" :key="t.id">
|
||||
<td v-if="activeColumns.id">
|
||||
<router-link :to="{name: 'taskDetailView', params: { id: t.id }}">{{ t.id }}</router-link>
|
||||
<router-link :to="{name: 'task.table.detail', params: { id: t.id }}">{{ t.id }}</router-link>
|
||||
</td>
|
||||
<td v-if="activeColumns.done">
|
||||
<div class="is-done" v-if="t.done">Done</div>
|
||||
</td>
|
||||
<td v-if="activeColumns.text">
|
||||
<router-link :to="{name: 'taskDetailView', params: { id: t.id }}">{{ t.text }}</router-link>
|
||||
<router-link :to="{name: 'task.table.detail', params: { id: t.id }}">{{ t.text }}</router-link>
|
||||
</td>
|
||||
<td v-if="activeColumns.priority">
|
||||
<priority-label :priority="t.priority" :show-all="true"/>
|
||||
|
@ -137,21 +137,26 @@
|
|||
</template>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- This router view is used to show the task popup while keeping the table view itself -->
|
||||
<transition name="modal">
|
||||
<router-view/>
|
||||
</transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ListModel from '../../models/list'
|
||||
import taskList from './helpers/taskList'
|
||||
import User from '../global/user'
|
||||
import PriorityLabel from './reusable/priorityLabel'
|
||||
import Labels from './reusable/labels'
|
||||
import DateTableCell from './reusable/date-table-cell'
|
||||
import Fancycheckbox from '../global/fancycheckbox'
|
||||
import Sort from './reusable/sort'
|
||||
import taskList from '../../tasks/helpers/taskList'
|
||||
import User from '../../global/user'
|
||||
import PriorityLabel from '../../tasks/reusable/priorityLabel'
|
||||
import Labels from '../../tasks/reusable/labels'
|
||||
import DateTableCell from '../../tasks/reusable/date-table-cell'
|
||||
import Fancycheckbox from '../../global/fancycheckbox'
|
||||
import Sort from '../../tasks/reusable/sort'
|
||||
|
||||
export default {
|
||||
name: 'TableView',
|
||||
name: 'Table',
|
||||
components: {
|
||||
Sort,
|
||||
Fancycheckbox,
|
||||
|
@ -186,12 +191,6 @@
|
|||
},
|
||||
}
|
||||
},
|
||||
props: {
|
||||
list: {
|
||||
type: ListModel,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
const savedShowColumns = localStorage.getItem('tableViewColumns')
|
||||
if (savedShowColumns !== null) {
|
|
@ -32,58 +32,3 @@
|
|||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
z-index: 9998;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, .8);
|
||||
transition: opacity .15s ease;
|
||||
color: #fff;
|
||||
|
||||
.modal-container {
|
||||
transition: all .15s ease;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.modal-content {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
|
||||
.header {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
|
||||
.modal-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-leave-active {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter .modal-container,
|
||||
.modal-leave-active .modal-container {
|
||||
-webkit-transform: scale(0.9);
|
||||
transform: scale(0.9);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
auth.linkShareAuth(this.$route.params.share)
|
||||
.then((r) => {
|
||||
this.loading = false
|
||||
router.push({name: 'showList', params: {id: r.listId}})
|
||||
router.push({name: 'showList', params: {listId: r.listId}})
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e, this)
|
||||
|
|
|
@ -8,12 +8,14 @@
|
|||
<div class="is-done" v-if="task.done">Done</div>
|
||||
<h1 class="title input" contenteditable="true" @focusout="saveTaskOnChange()" ref="taskTitle" @keyup.ctrl.enter="saveTaskOnChange()">{{ task.text }}</h1>
|
||||
</div>
|
||||
<h6 class="subtitle">
|
||||
{{ namespace.name }} >
|
||||
<router-link :to="{ name: 'showList', params: { id: list.id } }">
|
||||
{{ list.title }}
|
||||
</router-link>
|
||||
</h6>
|
||||
<!-- FIXME: Throw this away once we have vuex -->
|
||||
<!-- Commented out because it is a) not working and b) not working -->
|
||||
<!-- <h6 class="subtitle">-->
|
||||
<!-- {{ namespace.name }} >-->
|
||||
<!-- <router-link :to="{ name: 'showList', params: { id: list.id } }">-->
|
||||
<!-- {{ list.title }}-->
|
||||
<!-- </router-link>-->
|
||||
<!-- </h6>-->
|
||||
|
||||
<!-- Content and buttons -->
|
||||
<div class="columns">
|
||||
|
@ -218,7 +220,7 @@
|
|||
<!-- Comments -->
|
||||
<comments :task-id="taskId"/>
|
||||
</div>
|
||||
<div class="column is-one-fifth action-buttons">
|
||||
<div class="column is-one-third action-buttons">
|
||||
<a class="button is-outlined noshadow has-no-border" :class="{'is-success': !task.done}" @click="toggleTaskDone()">
|
||||
<span class="icon is-small"><icon icon="check-double"/></span>
|
||||
<template v-if="task.done">
|
||||
|
@ -464,15 +466,15 @@
|
|||
},
|
||||
setListAndNamespaceTitleFromParent() {
|
||||
// FIXME: Throw this away once we have vuex
|
||||
this.$parent.namespaces.forEach(n => {
|
||||
n.lists.forEach(l => {
|
||||
if (l.id === this.task.listId) {
|
||||
this.list = l
|
||||
this.namespace = n
|
||||
return
|
||||
}
|
||||
})
|
||||
})
|
||||
// this.$parent.namespaces.forEach(n => {
|
||||
// n.lists.forEach(l => {
|
||||
// if (l.id === this.task.listId) {
|
||||
// this.list = l
|
||||
// this.namespace = n
|
||||
// return
|
||||
// }
|
||||
// })
|
||||
// })
|
||||
},
|
||||
setFieldActive(fieldName) {
|
||||
this.activeFields[fieldName] = true
|
||||
|
@ -482,7 +484,7 @@
|
|||
this.taskService.delete(this.task)
|
||||
.then(() => {
|
||||
this.success({message: 'The task been deleted successfully.'}, this)
|
||||
router.push({name: 'showList', params: {id: this.list.id}})
|
||||
router.push({name: 'showList', params: {listId: this.list.id}})
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e, this)
|
||||
|
|
39
src/components/tasks/TaskDetailViewModal.vue
Normal file
39
src/components/tasks/TaskDetailViewModal.vue
Normal file
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<div class="modal-mask">
|
||||
<div class="modal-container" @click.self="close()">
|
||||
<div class="scrolling-content">
|
||||
<a @click="close()" class="close">
|
||||
<icon icon="times"/>
|
||||
</a>
|
||||
<task-detail-view :parent-list="list" :parent-namespace="namespace"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TaskDetailView from './TaskDetailView'
|
||||
import router from '../../router'
|
||||
|
||||
export default {
|
||||
name: 'TaskDetailViewModal',
|
||||
data() {
|
||||
return {
|
||||
list: null,
|
||||
namespace: null,
|
||||
}
|
||||
},
|
||||
components: {
|
||||
TaskDetailView,
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
router.back()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -108,7 +108,7 @@
|
|||
<p class="card-header-title">
|
||||
Edit Task
|
||||
</p>
|
||||
<a class="card-header-icon" @click="isTaskEdit = false;taskToEdit = null">
|
||||
<a class="card-header-icon" @click="() => {isTaskEdit = false; taskToEdit = null}">
|
||||
<span class="icon">
|
||||
<icon icon="times"/>
|
||||
</span>
|
||||
|
@ -130,7 +130,6 @@
|
|||
|
||||
import TaskService from '../../services/task'
|
||||
import TaskModel from '../../models/task'
|
||||
import ListModel from '../../models/list'
|
||||
import priorities from '../../models/priorities'
|
||||
import PriorityLabel from './reusable/priorityLabel'
|
||||
import TaskCollectionService from '../../services/taskCollection'
|
||||
|
@ -143,8 +142,8 @@
|
|||
VueDragResize,
|
||||
},
|
||||
props: {
|
||||
list: {
|
||||
type: ListModel,
|
||||
listId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
showTaskswithoutDates: {
|
||||
|
@ -235,7 +234,7 @@
|
|||
prepareTasks() {
|
||||
|
||||
const getAllTasks = (page = 1) => {
|
||||
return this.taskCollectionService.getAll({listId: this.$route.params.id}, {}, page)
|
||||
return this.taskCollectionService.getAll({listId: this.listId}, {}, page)
|
||||
.then(tasks => {
|
||||
if(page < this.taskCollectionService.totalPages) {
|
||||
return getAllTasks(page + 1)
|
||||
|
@ -367,7 +366,7 @@
|
|||
if (!this.newTaskFieldActive) {
|
||||
return
|
||||
}
|
||||
let task = new TaskModel({text: this.newTaskTitle, listId: this.list.id})
|
||||
let task = new TaskModel({text: this.newTaskTitle, listId: this.listId})
|
||||
this.taskService.create(task)
|
||||
.then(r => {
|
||||
this.tasksWithoutDates.push(this.addGantAttributes(r))
|
||||
|
|
|
@ -29,10 +29,20 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
loadTasks(page, search = '', params = {sort_by: ['done', 'id'], order_by: ['asc', 'desc']}) {
|
||||
|
||||
// Because this function is triggered every time on navigation, we're putting a condition here to only load it when we actually want to show tasks
|
||||
// FIXME: This is a bit hacky -> Cleanup.
|
||||
if (
|
||||
this.$route.name !== 'list.list' &&
|
||||
this.$route.name !== 'list.table'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (search !== '') {
|
||||
params.s = search
|
||||
}
|
||||
this.taskCollectionService.getAll({listId: this.$route.params.id}, params, page)
|
||||
this.taskCollectionService.getAll({listId: this.$route.params.listId}, params, page)
|
||||
.then(r => {
|
||||
this.$set(this, 'tasks', r)
|
||||
this.$set(this, 'pages', [])
|
||||
|
@ -104,7 +114,7 @@ export default {
|
|||
return
|
||||
}
|
||||
this.$router.push({
|
||||
name: 'showList',
|
||||
name: 'list.list',
|
||||
query: {search: this.searchTerm}
|
||||
})
|
||||
},
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
name: 'labels',
|
||||
props: {
|
||||
labels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@
|
|||
<span class="title">{{ relationKindTitle(kind, rts.length) }}</span>
|
||||
<div class="tasks noborder">
|
||||
<div class="task" v-for="t in rts" :key="t.id">
|
||||
<router-link :to="{ name: 'taskDetailView', params: { id: t.id } }">
|
||||
<router-link :to="{ name: 'task.kanban.detail', params: { id: t.id } }">
|
||||
<span class="tasktext" :class="{ 'done': t.done}">
|
||||
{{t.text}}
|
||||
</span>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<span>
|
||||
<fancycheckbox v-model="task.done" @change="markAsDone" :disabled="isArchived"/>
|
||||
<router-link :to="{ name: 'taskDetailView', params: { id: task.id } }" class="tasktext" :class="{ 'done': task.done}">
|
||||
<router-link :to="{ name: 'task.list.detail', params: { id: task.id } }" class="tasktext" :class="{ 'done': task.done}">
|
||||
<!-- Show any parent tasks to make it clear this task is a sub task of something -->
|
||||
<span class="parent-tasks" v-if="typeof task.relatedTasks.parenttask !== 'undefined'">
|
||||
<template v-for="(pt, i) in task.relatedTasks.parenttask">
|
||||
|
|
19
src/helpers/applyDrag.js
Normal file
19
src/helpers/applyDrag.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
|
||||
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
|
||||
}
|
|
@ -10,6 +10,13 @@ export function objectToCamelCase(object) {
|
|||
let parsedObject = {}
|
||||
for (const m in object) {
|
||||
parsedObject[camelCase(m)] = object[m]
|
||||
// Call it again for nested objects
|
||||
if(
|
||||
typeof object[m] === 'object' &&
|
||||
object[m] !== null
|
||||
) {
|
||||
object[m] = objectToCamelCase(object[m])
|
||||
}
|
||||
}
|
||||
return parsedObject
|
||||
}
|
||||
|
@ -23,6 +30,14 @@ export function objectToSnakeCase(object) {
|
|||
let parsedObject = {}
|
||||
for (const m in object) {
|
||||
parsedObject[snakeCase(m)] = object[m]
|
||||
// Call it again for nested objects
|
||||
if(
|
||||
typeof object[m] === 'object' &&
|
||||
object[m] !== null &&
|
||||
!(object[m] instanceof Date)
|
||||
) {
|
||||
object[m] = objectToSnakeCase(object[m])
|
||||
}
|
||||
}
|
||||
return parsedObject
|
||||
}
|
||||
|
|
11
src/helpers/filterObject.js
Normal file
11
src/helpers/filterObject.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
|
||||
export const filterObject = (obj, fn) => {
|
||||
let key
|
||||
|
||||
for (key in obj) {
|
||||
if (fn(obj[key])) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
|
@ -68,6 +68,7 @@ import { faTh } from '@fortawesome/free-solid-svg-icons'
|
|||
import { faSort } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSortUp } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faList } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faEllipsisV } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faComments } from '@fortawesome/free-regular-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
|
@ -112,6 +113,7 @@ library.add(faTh)
|
|||
library.add(faSort)
|
||||
library.add(faSortUp)
|
||||
library.add(faList)
|
||||
library.add(faEllipsisV)
|
||||
|
||||
Vue.component('icon', FontAwesomeIcon)
|
||||
|
||||
|
|
28
src/models/bucket.js
Normal file
28
src/models/bucket.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
import AbstractModel from './abstractModel'
|
||||
import UserModel from './user'
|
||||
import TaskModel from "./task";
|
||||
|
||||
export default class BucketModel extends AbstractModel {
|
||||
constructor(bucket) {
|
||||
super(bucket)
|
||||
|
||||
this.tasks = this.tasks.map(t => new TaskModel(t))
|
||||
|
||||
this.createdBy = new UserModel(this.createdBy)
|
||||
this.created = new Date(this.created)
|
||||
this.updated = new Date(this.updated)
|
||||
}
|
||||
|
||||
defaults() {
|
||||
return {
|
||||
id: 0,
|
||||
title: '',
|
||||
listId: 0,
|
||||
tasks: [],
|
||||
|
||||
createdBy: null,
|
||||
created: null,
|
||||
updated: null,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,6 +5,8 @@ import AttachmentModel from './attachment'
|
|||
|
||||
export default class TaskModel extends AbstractModel {
|
||||
|
||||
defaultColor = '198CFF'
|
||||
|
||||
constructor(data) {
|
||||
super(data)
|
||||
|
||||
|
@ -43,7 +45,7 @@ export default class TaskModel extends AbstractModel {
|
|||
|
||||
// Set the default color
|
||||
if (this.hexColor === '') {
|
||||
this.hexColor = '198CFF'
|
||||
this.hexColor = this.defaultColor
|
||||
}
|
||||
if (this.hexColor.substring(0, 1) !== '#') {
|
||||
this.hexColor = '#' + this.hexColor
|
||||
|
|
|
@ -10,12 +10,11 @@ import PasswordResetComponent from '@/components/user/PasswordReset'
|
|||
import GetPasswordResetComponent from '@/components/user/RequestPasswordReset'
|
||||
import UserSettingsComponent from '@/components/user/Settings'
|
||||
// List Handling
|
||||
import ShowListComponent from '@/components/lists/ShowList'
|
||||
import NewListComponent from '@/components/lists/NewList'
|
||||
import EditListComponent from '@/components/lists/EditList'
|
||||
import ShowTasksInRangeComponent from '@/components/tasks/ShowTasksInRange'
|
||||
import LinkShareAuthComponent from '@/components/sharing/linkSharingAuth'
|
||||
import TaskDetailViewComponent from '@/components/tasks/TaskDetailView'
|
||||
import TaskDetailViewModal from '../components/tasks/TaskDetailViewModal'
|
||||
// Namespace Handling
|
||||
import NewNamespaceComponent from '@/components/namespaces/NewNamespace'
|
||||
import EditNamespaceComponent from '@/components/namespaces/EditNamespace'
|
||||
|
@ -28,6 +27,12 @@ import ListLabelsComponent from '@/components/labels/ListLabels'
|
|||
// Migration
|
||||
import MigrationComponent from '../components/migrator/migrate'
|
||||
import WunderlistMigrationComponent from '../components/migrator/wunderlist'
|
||||
// List Views
|
||||
import ShowListComponent from '../components/lists/ShowList'
|
||||
import Kanban from '../components/lists/views/Kanban'
|
||||
import List from '../components/lists/views/List'
|
||||
import Gantt from '../components/lists/views/Gantt'
|
||||
import Table from '../components/lists/views/Table'
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
|
@ -80,20 +85,65 @@ export default new Router({
|
|||
name: 'register',
|
||||
component: RegisterComponent
|
||||
},
|
||||
{
|
||||
path: '/lists/:id',
|
||||
name: 'showList',
|
||||
component: ShowListComponent
|
||||
},
|
||||
{
|
||||
path: '/lists/:id/edit',
|
||||
name: 'editList',
|
||||
component: EditListComponent
|
||||
},
|
||||
{
|
||||
path: '/lists/:id/:type',
|
||||
name: 'showListWithType',
|
||||
path: '/lists/:listId',
|
||||
name: 'showList',
|
||||
component: ShowListComponent,
|
||||
children: [
|
||||
{
|
||||
path: '/lists/:listId/list',
|
||||
name: 'list.list',
|
||||
component: List,
|
||||
children: [
|
||||
{
|
||||
path: '/tasks/:id',
|
||||
name: 'task.list.detail',
|
||||
component: TaskDetailViewModal,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/lists/:listId/gantt',
|
||||
name: 'list.gantt',
|
||||
component: Gantt,
|
||||
children: [
|
||||
{
|
||||
path: '/tasks/:id',
|
||||
name: 'task.gantt.detail',
|
||||
component: TaskDetailViewModal,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/lists/:listId/table',
|
||||
name: 'list.table',
|
||||
component: Table,
|
||||
children: [
|
||||
{
|
||||
path: '/tasks/:id',
|
||||
name: 'task.table.detail',
|
||||
component: TaskDetailViewModal,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/lists/:listId/kanban',
|
||||
name: 'list.kanban',
|
||||
component: Kanban,
|
||||
children: [
|
||||
{
|
||||
path: '/tasks/:id',
|
||||
name: 'task.kanban.detail',
|
||||
component: TaskDetailViewModal,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/namespaces/:id/list',
|
||||
|
@ -128,12 +178,7 @@ export default new Router({
|
|||
{
|
||||
path: '/tasks/by/:type',
|
||||
name: 'showTasksInRange',
|
||||
component: ShowTasksInRangeComponent
|
||||
},
|
||||
{
|
||||
path: '/tasks/:id',
|
||||
name: 'taskDetailView',
|
||||
component: TaskDetailViewComponent,
|
||||
component: ShowTasksInRangeComponent,
|
||||
},
|
||||
{
|
||||
path: '/labels',
|
||||
|
|
17
src/services/bucket.js
Normal file
17
src/services/bucket.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
import AbstractService from './abstractService'
|
||||
import BucketModel from "../models/bucket";
|
||||
|
||||
export default class BucketService extends AbstractService {
|
||||
constructor() {
|
||||
super({
|
||||
getAll: '/lists/{listId}/buckets',
|
||||
create: '/lists/{listId}/buckets',
|
||||
update: '/lists/{listId}/buckets/{id}',
|
||||
delete: '/lists/{listId}/buckets/{id}',
|
||||
})
|
||||
}
|
||||
|
||||
modelFactory(data) {
|
||||
return new BucketModel(data)
|
||||
}
|
||||
}
|
|
@ -13,10 +13,11 @@ export default class LabelService extends AbstractService {
|
|||
})
|
||||
}
|
||||
|
||||
processModel(model) {
|
||||
model.created = formatISO(model.created)
|
||||
model.updated = formatISO(model.updated)
|
||||
return model
|
||||
processModel(label) {
|
||||
label.created = formatISO(label.created)
|
||||
label.updated = formatISO(label.updated)
|
||||
label.hexColor = label.hexColor.substring(1, 7)
|
||||
return label
|
||||
}
|
||||
|
||||
modelFactory(data) {
|
||||
|
@ -24,12 +25,10 @@ export default class LabelService extends AbstractService {
|
|||
}
|
||||
|
||||
beforeUpdate(label) {
|
||||
label.hexColor = label.hexColor.substring(1, 7)
|
||||
return label
|
||||
return this.processModel(label)
|
||||
}
|
||||
|
||||
beforeCreate(label) {
|
||||
label.hexColor = label.hexColor.substring(1, 7)
|
||||
return label
|
||||
return this.processModel(label)
|
||||
}
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
import AbstractService from './abstractService'
|
||||
import TaskModel from '../models/task'
|
||||
import AttachmentService from './attachment'
|
||||
import LabelService from './label'
|
||||
|
||||
import {formatISO} from 'date-fns'
|
||||
|
||||
export default class TaskService extends AbstractService {
|
||||
|
@ -94,6 +96,12 @@ export default class TaskService extends AbstractService {
|
|||
})
|
||||
}
|
||||
|
||||
// Preprocess all labels
|
||||
if(model.labels.length > 0) {
|
||||
const labelService = new LabelService()
|
||||
model.labels = model.labels.map(l => labelService.processModel(l))
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,3 +14,5 @@
|
|||
@import 'migrator';
|
||||
@import 'comments';
|
||||
@import 'table-view';
|
||||
@import 'kanban';
|
||||
@import 'modal';
|
||||
|
|
226
src/styles/components/kanban.scss
Normal file
226
src/styles/components/kanban.scss
Normal file
|
@ -0,0 +1,226 @@
|
|||
$bucket-background: #e8f0f5;
|
||||
$task-background: $white;
|
||||
$ease-out: all .3s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
|
||||
$crazy-height-calculation: '100vh - 4.5rem - 1.5rem - 116px - 1em - 1.5em';
|
||||
|
||||
.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.5em;
|
||||
|
||||
.bucket {
|
||||
|
||||
background-color: $bucket-background;
|
||||
border-radius: $radius;
|
||||
position: relative;
|
||||
|
||||
flex: 0 0 300px;
|
||||
margin: 0 1em 0 0;
|
||||
max-height: 100%;
|
||||
min-height: 20px;
|
||||
|
||||
.tasks {
|
||||
max-height: calc(#{$crazy-height-calculation} - 1rem - 1.5rem - 1em - #{$button-height} - 1em);
|
||||
overflow: auto;
|
||||
|
||||
.task {
|
||||
|
||||
&:first-child {
|
||||
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
|
||||
|
||||
transition: $ease-out;
|
||||
cursor: pointer;
|
||||
box-shadow: 2px 2px 2px darken($white, 12%);
|
||||
|
||||
font-size: .9em;
|
||||
padding: .5em;
|
||||
margin: .5em;
|
||||
border-radius: $radius;
|
||||
background: $task-background;
|
||||
|
||||
&.loader-container.is-loading:after {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
top: calc(50% - .75em);
|
||||
left: calc(50% - .75em);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.color {
|
||||
width: 4px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
float: left;
|
||||
border-radius: 0 2px 2px 0;
|
||||
margin-left: -.5em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: $family-sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.due-date {
|
||||
float: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
&.overdue {
|
||||
color: $red;
|
||||
}
|
||||
}
|
||||
|
||||
.label-wrapper .tag {
|
||||
margin: .5em .5em 0 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> :not(:last-child) {
|
||||
margin-right: .5em;
|
||||
}
|
||||
}
|
||||
|
||||
.assignees {
|
||||
display: flex;
|
||||
|
||||
.user {
|
||||
display: inline;
|
||||
margin: 0 -12px 0 0;
|
||||
|
||||
img {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
svg {
|
||||
margin: 4px;
|
||||
fill: $dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer .icon,
|
||||
.due-date,
|
||||
.priority-label {
|
||||
background: darken($task-background, 5%);
|
||||
border-radius: $radius;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.priority-label {
|
||||
margin: .5em 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.task-id {
|
||||
color: $grey;
|
||||
}
|
||||
|
||||
.is-done {
|
||||
margin: 0;
|
||||
font-size: .8em;
|
||||
padding: .25em .5em;
|
||||
}
|
||||
|
||||
&.is-moving {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
span {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.drop-preview {
|
||||
border-radius: $radius;
|
||||
margin: 0 .5em .5em;
|
||||
background: transparent;
|
||||
border: 3px dashed darken($bucket-background, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
&.new-bucket {
|
||||
|
||||
background: $bucket-background;
|
||||
display: block;
|
||||
|
||||
.button {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bucket-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: .5em;
|
||||
|
||||
.dropdown-trigger {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title.input {
|
||||
height: auto;
|
||||
padding: .4em .5em;
|
||||
}
|
||||
}
|
||||
|
||||
.bucket-footer {
|
||||
padding: .5em;
|
||||
|
||||
.button {
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ghost-task {
|
||||
transition: transform 0.18s ease;
|
||||
transform: rotateZ(3deg)
|
||||
}
|
||||
|
||||
.ghost-task-drop {
|
||||
transition: transform 0.18s ease-in-out;
|
||||
transform: rotateZ(0deg)
|
||||
}
|
73
src/styles/components/modal.scss
Normal file
73
src/styles/components/modal.scss
Normal file
|
@ -0,0 +1,73 @@
|
|||
.modal-mask {
|
||||
position: fixed;
|
||||
z-index: 4000;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, .8);
|
||||
transition: opacity 150ms ease;
|
||||
color: #fff;
|
||||
|
||||
.modal-container {
|
||||
transition: all 150ms ease;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
overflow: auto;
|
||||
|
||||
.scrolling-content {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: 4rem auto;
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
margin: 0;
|
||||
|
||||
.close {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
|
||||
.header {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
right: 26px;
|
||||
color: $white;
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
|
||||
.modal-enter,
|
||||
.modal-leave-active {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter .modal-container,
|
||||
.modal-leave-active .modal-container {
|
||||
-webkit-transform: scale(0.9);
|
||||
transform: scale(0.9);
|
||||
}
|
|
@ -39,22 +39,6 @@
|
|||
color: lighten($grey, 25%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.input.title{
|
||||
font-size: 1.8rem;
|
||||
font-family: $vikunja-font;
|
||||
font-weight: 400 !important;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
margin: 0 .3em;
|
||||
height: 1.5em;
|
||||
padding: 0 .3em;
|
||||
}
|
||||
|
||||
h1.input.title {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.date-input {
|
||||
|
@ -82,8 +66,8 @@
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
// Break after the 4th element
|
||||
.column:nth-child(4n) {
|
||||
// Break after the 2nd element
|
||||
.column:nth-child(2n) {
|
||||
page-break-after: always; // CSS 2.1 syntax
|
||||
break-after: always; // New syntax
|
||||
}
|
||||
|
@ -183,3 +167,14 @@
|
|||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-container .task-view {
|
||||
border-radius: $radius;
|
||||
padding: 1em;
|
||||
color: $text;
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
border-radius: 0;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
height: 2.648em;
|
||||
height: $button-height;
|
||||
box-shadow: 0.3em 0.3em 1em lighten($dark, 75);
|
||||
|
||||
&.is-hovered,
|
||||
|
@ -155,3 +155,26 @@
|
|||
.control.has-icons-left .icon, .control.has-icons-right .icon {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
// Contenteditable form
|
||||
.input.title{
|
||||
font-size: 1.8rem;
|
||||
font-family: $vikunja-font;
|
||||
font-weight: 400 !important;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
margin: 0 .3em;
|
||||
height: 1.5em;
|
||||
padding: 0 .3em;
|
||||
|
||||
&:focus {
|
||||
background: $input-background-color;
|
||||
border-color: $input-focus-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
h1, h2, h3{
|
||||
.input.title {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,3 +42,11 @@ h1,h2,h3,h4,h5,h6{
|
|||
button.table {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.dropdown-item.is-disabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,3 +42,5 @@ $scrollbar-height: 8px;
|
|||
$scrollbar-track-color: lighten($dark, 65);
|
||||
$scrollbar-thumb-color: lighten($dark, 40);
|
||||
$scrollbar-hover-color: lighten($dark, 30);
|
||||
|
||||
$button-height: 2.648em;
|
12
yarn.lock
12
yarn.lock
|
@ -10891,6 +10891,11 @@ slice-ansi@^2.1.0:
|
|||
astral-regex "^1.0.0"
|
||||
is-fullwidth-code-point "^2.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.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.3.tgz#c598b822ab443fcbb145ae8a82c5e43526d5bbee"
|
||||
|
@ -12389,6 +12394,13 @@ vue-sfc-descriptor-to-string@^1.0.0:
|
|||
dependencies:
|
||||
indent-string "^3.2.0"
|
||||
|
||||
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-style-loader@^4.1.0, vue-style-loader@^4.1.2:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/vue-style-loader/-/vue-style-loader-4.1.2.tgz#dedf349806f25ceb4e64f3ad7c0a44fba735fcf8"
|
||||
|
|
Loading…
Reference in a new issue