Table View for tasks (#76)
Save sort order to local storage Save selected columns to localStorage Loading spinner More sorting Add sorting Styling and hiding of column filter Add checkbox to show/hide columns Use fancycheckbox everywhere Fix is done badge Change sort order in table view Add is done column to table Better text handling Refactor is done into seperate component Add pagination to table view Add assignees to table view Fix redirecting to table view Add date tooltip to date field Add date tooltip to date field labels for table view Styling Add basic table view Extend priority label Split list view in mixins Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/76
This commit is contained in:
parent
cc513b5274
commit
724275e653
21 changed files with 623 additions and 197 deletions
23
src/App.vue
23
src/App.vue
|
@ -104,18 +104,9 @@
|
|||
</ul>
|
||||
</div>
|
||||
<aside class="menu namespaces-lists">
|
||||
<div class="fancycheckbox show-archived-check">
|
||||
<input type="checkbox" v-model="showArchived" @change="loadNamespaces()" style="display: none;" id="showArchivedCheckbox"/>
|
||||
<label class="check" for="showArchivedCheckbox">
|
||||
<svg width="18px" height="18px" viewBox="0 0 18 18">
|
||||
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
|
||||
<polyline points="1 9 7 14 15 4"></polyline>
|
||||
</svg>
|
||||
<span>
|
||||
Show Archived
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<fancycheckbox v-model="showArchived" @change="loadNamespaces()" class="show-archived-check">
|
||||
Show Archived
|
||||
</fancycheckbox>
|
||||
<div class="spinner" :class="{ 'is-loading': namespaceService.loading}"></div>
|
||||
<template v-for="n in namespaces">
|
||||
<div :key="n.id">
|
||||
|
@ -228,11 +219,15 @@
|
|||
import authTypes from './models/authTypes'
|
||||
|
||||
import swEvents from './ServiceWorker/events'
|
||||
import Notification from "./components/global/notification";
|
||||
import Notification from './components/global/notification'
|
||||
import Fancycheckbox from './components/global/fancycheckbox'
|
||||
|
||||
export default {
|
||||
name: 'app',
|
||||
components: {Notification},
|
||||
components: {
|
||||
Fancycheckbox,
|
||||
Notification,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
user: auth.user,
|
||||
|
|
58
src/components/global/fancycheckbox.vue
Normal file
58
src/components/global/fancycheckbox.vue
Normal file
|
@ -0,0 +1,58 @@
|
|||
<template>
|
||||
<div class="fancycheckbox" :class="{'is-disabled': disabled}">
|
||||
<input @change="updateData" type="checkbox" :id="checkBoxId" :checked="checked" style="display: none;" :disabled="disabled">
|
||||
<label :for="checkBoxId" class="check">
|
||||
<svg width="18px" height="18px" viewBox="0 0 18 18">
|
||||
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
|
||||
<polyline points="1 9 7 14 15 4"></polyline>
|
||||
</svg>
|
||||
<span>
|
||||
<slot></slot>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'fancycheckbox',
|
||||
data() {
|
||||
return {
|
||||
checked: false,
|
||||
checkBoxId: '',
|
||||
}
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
required: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
this.checked = newVal
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.checked = this.value
|
||||
},
|
||||
created() {
|
||||
this.checkBoxId = 'fancycheckbox' + Math.random()
|
||||
},
|
||||
methods: {
|
||||
updateData(e) {
|
||||
this.checked = e.target.checked
|
||||
this.$emit('input', this.checked)
|
||||
this.$emit('change', e.target.checked)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="user">
|
||||
<div class="user" :class="{'is-inline': isInline}">
|
||||
<img :src="user.getAvatarUrl(avatarSize)" class="avatar" alt="" v-tooltip="user.username" :width="avatarSize" :height="avatarSize"/>
|
||||
<span v-if="showUsername" class="username">{{ user.username }}</span>
|
||||
</div>
|
||||
|
@ -22,7 +22,11 @@
|
|||
required: false,
|
||||
type: Number,
|
||||
default: 50,
|
||||
}
|
||||
},
|
||||
isInline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
@ -31,6 +35,10 @@
|
|||
.user {
|
||||
margin: .5em;
|
||||
|
||||
&.is-inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
img {
|
||||
-webkit-border-radius: 100%;
|
||||
-moz-border-radius: 100%;
|
||||
|
|
|
@ -25,7 +25,6 @@
|
|||
{{ l.title }}
|
||||
</a>
|
||||
<a class="delete is-small" @click="deleteLabel(l)" v-if="user.infos.id === l.created_by.id"></a>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
<div class="column is-4" v-if="isLabelEdit">
|
||||
|
|
|
@ -28,10 +28,9 @@
|
|||
<div class="field">
|
||||
<label class="label" for="isArchivedCheck">Is Archived</label>
|
||||
<div class="control">
|
||||
<label class="checkbox" v-tooltip="'If a list is archived, you cannot create new tasks or edit the list or existing tasks.'">
|
||||
<input type="checkbox" id="isArchivedCheck" v-model="list.is_archived"/>
|
||||
<fancycheckbox v-model="list.is_archived" v-tooltip="'If a list is archived, you cannot create new tasks or edit the list or existing tasks.'">
|
||||
This list is archived
|
||||
</label>
|
||||
</fancycheckbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
|
@ -89,10 +88,11 @@
|
|||
import auth from '../../auth'
|
||||
import router from '../../router'
|
||||
import manageSharing from '../sharing/userTeam'
|
||||
import LinkSharing from '../sharing/linkSharing';
|
||||
import LinkSharing from '../sharing/linkSharing'
|
||||
|
||||
import ListModel from '../../models/list'
|
||||
import ListService from '../../services/list'
|
||||
import Fancycheckbox from '../global/fancycheckbox'
|
||||
|
||||
export default {
|
||||
name: "EditList",
|
||||
|
@ -110,6 +110,7 @@
|
|||
}
|
||||
},
|
||||
components: {
|
||||
Fancycheckbox,
|
||||
LinkSharing,
|
||||
manageSharing,
|
||||
verte,
|
||||
|
|
|
@ -10,12 +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'}">List</router-link>
|
||||
<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>
|
||||
</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/>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -30,6 +32,7 @@
|
|||
import ListModel from '../../models/list'
|
||||
import ListService from '../../services/list'
|
||||
import authType from '../../models/authTypes'
|
||||
import TableView from '../tasks/TableView'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
|
@ -40,6 +43,7 @@
|
|||
}
|
||||
},
|
||||
components: {
|
||||
TableView,
|
||||
Gantt,
|
||||
ShowListTask,
|
||||
},
|
||||
|
@ -50,7 +54,13 @@
|
|||
}
|
||||
|
||||
// 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 !== '') {
|
||||
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 !== ''
|
||||
) {
|
||||
router.push({name: 'showList', params: { id: this.$route.params.id }})
|
||||
}
|
||||
},
|
||||
|
|
|
@ -28,18 +28,9 @@
|
|||
<div class="field">
|
||||
<label class="label" for="isArchivedCheck">Is Archived</label>
|
||||
<div class="control">
|
||||
<div class="fancycheckbox" v-tooltip="'If a namespace is archived, you cannot create new lists or edit it.'">
|
||||
<input type="checkbox" id="isArchivedCheck" v-model="namespace.is_archived"/>
|
||||
<label class="check" for="isArchivedCheck">
|
||||
<svg width="18px" height="18px" viewBox="0 0 18 18">
|
||||
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
|
||||
<polyline points="1 9 7 14 15 4"></polyline>
|
||||
</svg>
|
||||
<span>
|
||||
This namespace is archived
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<fancycheckbox v-model="namespace.is_archived" v-tooltip="'If a namespace is archived, you cannot create new lists or edit it.'">
|
||||
This namespace is archived
|
||||
</fancycheckbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
|
@ -98,6 +89,7 @@
|
|||
|
||||
import NamespaceService from '../../services/namespace'
|
||||
import NamespaceModel from '../../models/namespace'
|
||||
import Fancycheckbox from '../global/fancycheckbox'
|
||||
|
||||
export default {
|
||||
name: "EditNamespace",
|
||||
|
@ -114,6 +106,7 @@
|
|||
}
|
||||
},
|
||||
components: {
|
||||
Fancycheckbox,
|
||||
manageSharing,
|
||||
verte,
|
||||
},
|
||||
|
|
|
@ -1,18 +1,9 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="gantt-options">
|
||||
<div class="fancycheckbox is-block">
|
||||
<input id="showTaskswithoutDates" type="checkbox" style="display: none;" v-model="showTaskswithoutDates">
|
||||
<label for="showTaskswithoutDates" class="check">
|
||||
<svg width="18px" height="18px" viewBox="0 0 18 18">
|
||||
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
|
||||
<polyline points="1 9 7 14 15 4"></polyline>
|
||||
</svg>
|
||||
<span>
|
||||
Show tasks which don't have dates set
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<fancycheckbox v-model="showTaskswithoutDates" class="is-block">
|
||||
Show tasks which don't have dates set
|
||||
</fancycheckbox>
|
||||
<div class="range-picker">
|
||||
<div class="field">
|
||||
<label class="label" for="dayWidth">Size</label>
|
||||
|
@ -66,10 +57,12 @@
|
|||
import GanttChart from './gantt-component'
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import ListModel from '../../models/list'
|
||||
import Fancycheckbox from '../global/fancycheckbox'
|
||||
|
||||
export default {
|
||||
name: 'Gantt',
|
||||
components: {
|
||||
Fancycheckbox,
|
||||
flatPickr,
|
||||
GanttChart
|
||||
},
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="loader-container" :class="{ 'is-loading': listService.loading || taskCollectionService.loading}">
|
||||
<div class="loader-container" :class="{ 'is-loading': taskCollectionService.loading}">
|
||||
<div class="search">
|
||||
<div class="field has-addons" :class="{ 'hidden': !showTaskSearch }">
|
||||
<div class="control has-icons-left has-icons-right">
|
||||
|
@ -87,13 +87,13 @@
|
|||
</div>
|
||||
|
||||
<nav class="pagination is-centered" role="navigation" aria-label="pagination" v-if="taskCollectionService.totalPages > 1">
|
||||
<router-link class="pagination-previous" :to="{name: 'showList', query: { page: currentPage - 1 }}" tag="button" :disabled="currentPage === 1">Previous</router-link>
|
||||
<router-link class="pagination-next" :to="{name: 'showList', query: { page: currentPage + 1 }}" tag="button" :disabled="currentPage === taskCollectionService.totalPages">Next page</router-link>
|
||||
<router-link class="pagination-previous" :to="getRouteForPagination(currentPage - 1)" tag="button" :disabled="currentPage === 1">Previous</router-link>
|
||||
<router-link class="pagination-next" :to="getRouteForPagination(currentPage + 1)" tag="button" :disabled="currentPage === taskCollectionService.totalPages">Next page</router-link>
|
||||
<ul class="pagination-list">
|
||||
<template v-for="(p, i) in pages">
|
||||
<li :key="'page'+i" v-if="p.isEllipsis"><span class="pagination-ellipsis">…</span></li>
|
||||
<li :key="'page'+i" v-else>
|
||||
<router-link :to="{name: 'showList', query: { page: p.number }}" :class="{'is-current': p.number === currentPage}" class="pagination-link" :aria-label="'Goto page ' + p.number">{{ p.number }}</router-link>
|
||||
<router-link :to="getRouteForPagination(p.number)" :class="{'is-current': p.number === currentPage}" class="pagination-link" :aria-label="'Goto page ' + p.number">{{ p.number }}</router-link>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
|
@ -102,35 +102,30 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import ListService from '../../services/list'
|
||||
import TaskService from '../../services/task'
|
||||
import ListModel from '../../models/list'
|
||||
import EditTask from './edit-task'
|
||||
import TaskModel from '../../models/task'
|
||||
import TaskCollectionService from '../../services/taskCollection'
|
||||
import SingleTaskInList from './reusable/singleTaskInList'
|
||||
import taskList from './helpers/taskList'
|
||||
|
||||
export default {
|
||||
name: 'ListView',
|
||||
data() {
|
||||
return {
|
||||
listID: this.$route.params.id,
|
||||
listService: ListService,
|
||||
taskService: TaskService,
|
||||
taskCollectionService: TaskCollectionService,
|
||||
pages: [],
|
||||
currentPage: 0,
|
||||
list: {},
|
||||
tasks: [],
|
||||
isTaskEdit: false,
|
||||
taskEditTask: TaskModel,
|
||||
newTaskText: '',
|
||||
|
||||
showError: false,
|
||||
|
||||
showTaskSearch: false,
|
||||
searchTerm: '',
|
||||
}
|
||||
},
|
||||
mixins: [
|
||||
taskList,
|
||||
],
|
||||
components: {
|
||||
SingleTaskInList,
|
||||
EditTask,
|
||||
|
@ -145,12 +140,9 @@
|
|||
theList() {
|
||||
this.list = this.theList
|
||||
},
|
||||
'$route.query': 'loadTasksForPage', // Only listen for query path changes
|
||||
},
|
||||
created() {
|
||||
this.listService = new ListService()
|
||||
this.taskService = new TaskService()
|
||||
this.taskCollectionService = new TaskCollectionService()
|
||||
this.initTasks(1)
|
||||
},
|
||||
methods: {
|
||||
|
@ -179,61 +171,6 @@
|
|||
this.error(e, this)
|
||||
})
|
||||
},
|
||||
loadTasks(page, search = '') {
|
||||
const params = {sort_by: ['done', 'id'], order_by: ['asc', 'desc']}
|
||||
if (search !== '') {
|
||||
params.s = search
|
||||
}
|
||||
this.taskCollectionService.getAll({listID: this.$route.params.id}, params, page)
|
||||
.then(r => {
|
||||
this.$set(this, 'tasks', r)
|
||||
this.$set(this, 'pages', [])
|
||||
this.currentPage = page
|
||||
|
||||
for (let i = 0; i < this.taskCollectionService.totalPages; i++) {
|
||||
|
||||
// Show ellipsis instead of all pages
|
||||
if(
|
||||
i > 0 && // Always at least the first page
|
||||
(i + 1) < this.taskCollectionService.totalPages && // And the last page
|
||||
(
|
||||
// And the current with current + 1 and current - 1
|
||||
(i + 1) > this.currentPage + 1 ||
|
||||
(i + 1) < this.currentPage - 1
|
||||
)
|
||||
) {
|
||||
// Only add an ellipsis if the last page isn't already one
|
||||
if(this.pages[i - 1] && !this.pages[i - 1].isEllipsis) {
|
||||
this.pages.push({
|
||||
number: 0,
|
||||
isEllipsis: true,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
this.pages.push({
|
||||
number: i + 1,
|
||||
isEllipsis: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e, this)
|
||||
})
|
||||
},
|
||||
loadTasksForPage(e) {
|
||||
// The page parameter can be undefined, in the case where the user loads a new list from the side bar menu
|
||||
let page = e.page
|
||||
if (typeof e.page === 'undefined') {
|
||||
page = 1
|
||||
}
|
||||
let search = e.search
|
||||
if (typeof e.search === 'undefined') {
|
||||
search = ''
|
||||
}
|
||||
this.initTasks(page, search)
|
||||
},
|
||||
editTask(id) {
|
||||
// Find the selected task and set it to the current object
|
||||
let theTask = this.getTaskByID(id) // Somehow this does not work if we directly assign this to this.taskEditTask
|
||||
|
@ -248,23 +185,6 @@
|
|||
}
|
||||
return {} // FIXME: This should probably throw something to make it clear to the user noting was found
|
||||
},
|
||||
sortTasks() {
|
||||
if (this.tasks === null || this.tasks === []) {
|
||||
return
|
||||
}
|
||||
return this.tasks.sort(function(a,b) {
|
||||
if (a.done < b.done)
|
||||
return -1
|
||||
if (a.done > b.done)
|
||||
return 1
|
||||
|
||||
if (a.id > b.id)
|
||||
return -1
|
||||
if (a.id < b.id)
|
||||
return 1
|
||||
return 0
|
||||
})
|
||||
},
|
||||
updateTasks(updatedTask) {
|
||||
for (const t in this.tasks) {
|
||||
if (this.tasks[t].id === updatedTask.id) {
|
||||
|
@ -274,25 +194,6 @@
|
|||
}
|
||||
this.sortTasks()
|
||||
},
|
||||
searchTasks() {
|
||||
if (this.searchTerm === '') {
|
||||
return
|
||||
}
|
||||
this.$router.push({
|
||||
name: 'showList',
|
||||
query: {search: this.searchTerm}
|
||||
})
|
||||
},
|
||||
hideSearchBar() {
|
||||
// This is a workaround.
|
||||
// When clicking on the search button, @blur from the input is fired. If we
|
||||
// would then directly hide the whole search bar directly, no click event
|
||||
// from the button gets fired. To prevent this, we wait 200ms until we hide
|
||||
// everything so the button has a chance of firering the search event.
|
||||
setTimeout(() => {
|
||||
this.showTaskSearch = false
|
||||
}, 200)
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
234
src/components/tasks/TableView.vue
Normal file
234
src/components/tasks/TableView.vue
Normal file
|
@ -0,0 +1,234 @@
|
|||
<template>
|
||||
<div class="table-view loader-container" :class="{'is-loading': taskCollectionService.loading}">
|
||||
<div class="column-filter">
|
||||
<button class="button" @click="showActiveColumnsFilter = !showActiveColumnsFilter">
|
||||
<span class="icon is-small">
|
||||
<icon icon="th"/>
|
||||
</span>
|
||||
Columns
|
||||
</button>
|
||||
<transition name="fade">
|
||||
<div class="card" v-if="showActiveColumnsFilter">
|
||||
<div class="card-content">
|
||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.id">#</fancycheckbox>
|
||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.done">Done</fancycheckbox>
|
||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.text">Name</fancycheckbox>
|
||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.priority">Priority</fancycheckbox>
|
||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.labels">Labels</fancycheckbox>
|
||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.assignees">Assignees</fancycheckbox>
|
||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.dueDate">Due Date</fancycheckbox>
|
||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.startDate">Start Date</fancycheckbox>
|
||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.endDate">End Date</fancycheckbox>
|
||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.percentDone">% Done</fancycheckbox>
|
||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.created">Created</fancycheckbox>
|
||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.updated">Updated</fancycheckbox>
|
||||
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.createdBy">Created By</fancycheckbox>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<table class="table is-hoverable is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="activeColumns.id">
|
||||
#
|
||||
<sort :order="sortBy.id" @click="sort('id')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.done">
|
||||
Done
|
||||
<sort :order="sortBy.done" @click="sort('done')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.text">
|
||||
Name
|
||||
<sort :order="sortBy.text" @click="sort('text')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.priority">
|
||||
Priority
|
||||
<sort :order="sortBy.priority" @click="sort('priority')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.labels">
|
||||
Labels
|
||||
</th>
|
||||
<th v-if="activeColumns.assignees">
|
||||
Assignees
|
||||
</th>
|
||||
<th v-if="activeColumns.dueDate">
|
||||
Due Date
|
||||
<sort :order="sortBy.due_date_unix" @click="sort('due_date_unix')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.startDate">
|
||||
Start Date
|
||||
<sort :order="sortBy.start_date_unix" @click="sort('start_date_unix')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.endDate">
|
||||
End Date
|
||||
<sort :order="sortBy.end_date_unix" @click="sort('end_date_unix')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.percentDone">
|
||||
% Done
|
||||
<sort :order="sortBy.percent_done" @click="sort('percent_done')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.created">
|
||||
Created
|
||||
<sort :order="sortBy.created" @click="sort('created')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.updated">
|
||||
Updated
|
||||
<sort :order="sortBy.updated" @click="sort('updated')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.createdBy">
|
||||
Created By
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
</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>
|
||||
</td>
|
||||
<td v-if="activeColumns.priority">
|
||||
<priority-label :priority="t.priority" :show-all="true"/>
|
||||
</td>
|
||||
<td v-if="activeColumns.labels">
|
||||
<labels :labels="t.labels"/>
|
||||
</td>
|
||||
<td v-if="activeColumns.assignees">
|
||||
<user
|
||||
:user="a"
|
||||
:avatar-size="27"
|
||||
:show-username="false"
|
||||
:is-inline="true"
|
||||
v-for="(a, i) in t.assignees"
|
||||
:key="t.id + 'assignee' + a.id + i"
|
||||
/>
|
||||
</td>
|
||||
<date-table-cell :date="t.dueDate" v-if="activeColumns.dueDate"/>
|
||||
<date-table-cell :date="t.startDate" v-if="activeColumns.startDate"/>
|
||||
<date-table-cell :date="t.endDate" v-if="activeColumns.endDate"/>
|
||||
<td v-if="activeColumns.percentDone">{{ t.percentDone }}%</td>
|
||||
<date-table-cell :date="t.created" v-if="activeColumns.created"/>
|
||||
<date-table-cell :date="t.updated" v-if="activeColumns.updated"/>
|
||||
<td v-if="activeColumns.createdBy">
|
||||
<user
|
||||
:user="t.createdBy"
|
||||
:show-username="false"
|
||||
:avatar-size="27"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<nav class="pagination is-centered" role="navigation" aria-label="pagination" v-if="taskCollectionService.totalPages > 1">
|
||||
<router-link class="pagination-previous" :to="getRouteForPagination(currentPage - 1, 'table')" tag="button" :disabled="currentPage === 1">Previous</router-link>
|
||||
<router-link class="pagination-next" :to="getRouteForPagination(currentPage + 1, 'table')" tag="button" :disabled="currentPage === taskCollectionService.totalPages">Next page</router-link>
|
||||
<ul class="pagination-list">
|
||||
<template v-for="(p, i) in pages">
|
||||
<li :key="'page'+i" v-if="p.isEllipsis"><span class="pagination-ellipsis">…</span></li>
|
||||
<li :key="'page'+i" v-else>
|
||||
<router-link :to="getRouteForPagination(p.number, 'table')" :class="{'is-current': p.number === currentPage}" class="pagination-link" :aria-label="'Goto page ' + p.number">{{ p.number }}</router-link>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</nav>
|
||||
</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'
|
||||
|
||||
export default {
|
||||
name: 'TableView',
|
||||
components: {
|
||||
Sort,
|
||||
Fancycheckbox,
|
||||
DateTableCell,
|
||||
Labels,
|
||||
PriorityLabel,
|
||||
User,
|
||||
},
|
||||
mixins: [
|
||||
taskList,
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
showActiveColumnsFilter: false,
|
||||
activeColumns: {
|
||||
id: true,
|
||||
done: true,
|
||||
text: true,
|
||||
priority: false,
|
||||
labels: true,
|
||||
assignees: true,
|
||||
dueDate: true,
|
||||
startDate: false,
|
||||
endDate: false,
|
||||
percentDone: false,
|
||||
created: false,
|
||||
updated: false,
|
||||
createdBy: false,
|
||||
},
|
||||
sortBy: {
|
||||
id: 'desc',
|
||||
},
|
||||
}
|
||||
},
|
||||
props: {
|
||||
list: {
|
||||
type: ListModel,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
const savedShowColumns = localStorage.getItem('tableViewColumns')
|
||||
if (savedShowColumns !== null) {
|
||||
this.$set(this, 'activeColumns', JSON.parse(savedShowColumns))
|
||||
}
|
||||
const savedSortBy = localStorage.getItem('tableViewSortBy')
|
||||
if (savedSortBy !== null) {
|
||||
this.$set(this, 'sortBy', JSON.parse(savedSortBy))
|
||||
}
|
||||
|
||||
this.initTasks(1)
|
||||
},
|
||||
methods: {
|
||||
initTasks(page, search = '') {
|
||||
let params = {sort_by: [], order_by: []}
|
||||
Object.keys(this.sortBy).map(s => {
|
||||
params.sort_by.push(s)
|
||||
params.order_by.push(this.sortBy[s])
|
||||
})
|
||||
this.loadTasks(page, search, params)
|
||||
},
|
||||
sort(property) {
|
||||
const order = this.sortBy[property]
|
||||
if (typeof order === 'undefined' || order === 'none') {
|
||||
this.$set(this.sortBy, property, 'desc')
|
||||
} else if (order === 'desc') {
|
||||
this.$set(this.sortBy, property, 'asc')
|
||||
} else {
|
||||
this.$delete(this.sortBy, property)
|
||||
}
|
||||
this.initTasks(this.currentPage, this.searchTerm)
|
||||
// Save the order to be able to retrieve them later
|
||||
localStorage.setItem('tableViewSortBy', JSON.stringify(this.sortBy))
|
||||
},
|
||||
saveTaskColumns() {
|
||||
localStorage.setItem('tableViewColumns', JSON.stringify(this.activeColumns))
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
128
src/components/tasks/helpers/taskList.js
Normal file
128
src/components/tasks/helpers/taskList.js
Normal file
|
@ -0,0 +1,128 @@
|
|||
import TaskCollectionService from '../../../services/taskCollection'
|
||||
|
||||
/**
|
||||
* This mixin provides a base set of methods and properties to get tasks on a list.
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
taskCollectionService: TaskCollectionService,
|
||||
tasks: [],
|
||||
|
||||
pages: [],
|
||||
currentPage: 0,
|
||||
|
||||
showTaskSearch: false,
|
||||
searchTerm: '',
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.query': 'loadTasksForPage', // Only listen for query path changes
|
||||
},
|
||||
created() {
|
||||
this.taskCollectionService = new TaskCollectionService()
|
||||
},
|
||||
methods: {
|
||||
loadTasks(page, search = '', params = {sort_by: ['done', 'id'], order_by: ['asc', 'desc']}) {
|
||||
if (search !== '') {
|
||||
params.s = search
|
||||
}
|
||||
this.taskCollectionService.getAll({listID: this.$route.params.id}, params, page)
|
||||
.then(r => {
|
||||
this.$set(this, 'tasks', r)
|
||||
this.$set(this, 'pages', [])
|
||||
this.currentPage = page
|
||||
|
||||
for (let i = 0; i < this.taskCollectionService.totalPages; i++) {
|
||||
|
||||
// Show ellipsis instead of all pages
|
||||
if(
|
||||
i > 0 && // Always at least the first page
|
||||
(i + 1) < this.taskCollectionService.totalPages && // And the last page
|
||||
(
|
||||
// And the current with current + 1 and current - 1
|
||||
(i + 1) > this.currentPage + 1 ||
|
||||
(i + 1) < this.currentPage - 1
|
||||
)
|
||||
) {
|
||||
// Only add an ellipsis if the last page isn't already one
|
||||
if(this.pages[i - 1] && !this.pages[i - 1].isEllipsis) {
|
||||
this.pages.push({
|
||||
number: 0,
|
||||
isEllipsis: true,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
this.pages.push({
|
||||
number: i + 1,
|
||||
isEllipsis: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
this.error(e, this)
|
||||
})
|
||||
},
|
||||
loadTasksForPage(e) {
|
||||
// The page parameter can be undefined, in the case where the user loads a new list from the side bar menu
|
||||
let page = e.page
|
||||
if (typeof e.page === 'undefined') {
|
||||
page = 1
|
||||
}
|
||||
let search = e.search
|
||||
if (typeof e.search === 'undefined') {
|
||||
search = ''
|
||||
}
|
||||
this.initTasks(page, search)
|
||||
},
|
||||
sortTasks() {
|
||||
if (this.tasks === null || this.tasks === []) {
|
||||
return
|
||||
}
|
||||
return this.tasks.sort(function(a,b) {
|
||||
if (a.done < b.done)
|
||||
return -1
|
||||
if (a.done > b.done)
|
||||
return 1
|
||||
|
||||
if (a.id > b.id)
|
||||
return -1
|
||||
if (a.id < b.id)
|
||||
return 1
|
||||
return 0
|
||||
})
|
||||
},
|
||||
searchTasks() {
|
||||
if (this.searchTerm === '') {
|
||||
return
|
||||
}
|
||||
this.$router.push({
|
||||
name: 'showList',
|
||||
query: {search: this.searchTerm}
|
||||
})
|
||||
},
|
||||
hideSearchBar() {
|
||||
// This is a workaround.
|
||||
// When clicking on the search button, @blur from the input is fired. If we
|
||||
// would then directly hide the whole search bar directly, no click event
|
||||
// from the button gets fired. To prevent this, we wait 200ms until we hide
|
||||
// everything so the button has a chance of firering the search event.
|
||||
setTimeout(() => {
|
||||
this.showTaskSearch = false
|
||||
}, 200)
|
||||
},
|
||||
getRouteForPagination(page = 1, type = 'list') {
|
||||
return {
|
||||
name: 'showListWithType',
|
||||
params: {
|
||||
type: type
|
||||
},
|
||||
query: {
|
||||
page: page,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
17
src/components/tasks/reusable/date-table-cell.vue
Normal file
17
src/components/tasks/reusable/date-table-cell.vue
Normal file
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<td v-tooltip="+date === 0 ? '' : formatDate(date)">
|
||||
{{ +date === 0 ? '-' : formatDateSince(date) }}
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'date-table-cell',
|
||||
props: {
|
||||
date: {
|
||||
type: Date,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
25
src/components/tasks/reusable/labels.vue
Normal file
25
src/components/tasks/reusable/labels.vue
Normal file
|
@ -0,0 +1,25 @@
|
|||
<template>
|
||||
<div class="label-wrapper">
|
||||
<span class="tag" v-for="label in labels" :style="{'background': label.hex_color, 'color': label.textColor}" :key="label.id">
|
||||
<span>{{ label.title }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'labels',
|
||||
props: {
|
||||
labels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.label-wrapper {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
|
@ -1,8 +1,11 @@
|
|||
<template>
|
||||
<span v-if="priority >= priorities.HIGH" class="high-priority" :class="{'not-so-high': priority === priorities.HIGH}">
|
||||
<span class="icon">
|
||||
<span v-if="showAll || priority >= priorities.HIGH" :class="{'not-so-high': priority === priorities.HIGH, 'high-priority': priority >= priorities.HIGH}">
|
||||
<span class="icon" v-if="priority >= priorities.HIGH">
|
||||
<icon icon="exclamation"/>
|
||||
</span>
|
||||
<template v-if="priority === priorities.UNSET">Unset</template>
|
||||
<template v-if="priority === priorities.LOW">Low</template>
|
||||
<template v-if="priority === priorities.MEDIUM">Medium</template>
|
||||
<template v-if="priority === priorities.HIGH">High</template>
|
||||
<template v-if="priority === priorities.URGENT">Urgent</template>
|
||||
<template v-if="priority === priorities.DO_NOW">DO NOW</template>
|
||||
|
@ -26,7 +29,11 @@
|
|||
priority: {
|
||||
default: 0,
|
||||
type: Number,
|
||||
}
|
||||
},
|
||||
showAll: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,15 +1,6 @@
|
|||
<template>
|
||||
<span>
|
||||
<div class="fancycheckbox" :class="{'is-disabled': isArchived}">
|
||||
<input @change="markAsDone" type="checkbox" :id="task.id" :checked="task.done"
|
||||
style="display: none;" :disabled="isArchived">
|
||||
<label :for="task.id" class="check">
|
||||
<svg width="18px" height="18px" viewBox="0 0 18 18">
|
||||
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
|
||||
<polyline points="1 9 7 14 15 4"></polyline>
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
<fancycheckbox v-model="task.done" @change="markAsDone" :disabled="isArchived"/>
|
||||
<router-link :to="{ name: 'taskDetailView', 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.related_tasks.parenttask !== 'undefined'">
|
||||
|
@ -19,18 +10,15 @@
|
|||
>
|
||||
</span>
|
||||
{{ task.text }}
|
||||
<span class="tag" v-for="label in task.labels" :style="{'background': label.hex_color, 'color': label.textColor}"
|
||||
:key="label.id">
|
||||
<span>{{ label.title }}</span>
|
||||
</span>
|
||||
<img
|
||||
:src="a.getAvatarUrl(27)"
|
||||
:alt="a.username"
|
||||
class="avatar"
|
||||
width="27"
|
||||
height="27"
|
||||
<labels :labels="task.labels"/>
|
||||
<user
|
||||
:user="a"
|
||||
:avatar-size="27"
|
||||
:show-username="false"
|
||||
:is-inline="true"
|
||||
v-for="(a, i) in task.assignees"
|
||||
:key="task.id + 'assignee' + a.id + i"/>
|
||||
:key="task.id + 'assignee' + a.id + i"
|
||||
/>
|
||||
<i v-if="task.dueDate > 0"
|
||||
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
|
||||
v-tooltip="formatDate(task.dueDate)"> - Due {{formatDateSince(task.dueDate)}}</i>
|
||||
|
@ -43,6 +31,9 @@
|
|||
import TaskModel from '../../../models/task'
|
||||
import PriorityLabel from './priorityLabel'
|
||||
import TaskService from '../../../services/task'
|
||||
import Labels from './labels'
|
||||
import User from '../../global/user'
|
||||
import Fancycheckbox from '../../global/fancycheckbox'
|
||||
|
||||
export default {
|
||||
name: 'singleTaskInList',
|
||||
|
@ -53,6 +44,9 @@
|
|||
}
|
||||
},
|
||||
components: {
|
||||
Fancycheckbox,
|
||||
User,
|
||||
Labels,
|
||||
PriorityLabel,
|
||||
},
|
||||
props: {
|
||||
|
@ -78,10 +72,8 @@
|
|||
this.taskService = new TaskService()
|
||||
},
|
||||
methods: {
|
||||
markAsDone(e) {
|
||||
let updateFunc = () => {
|
||||
// We get the task, update the 'done' property and then push it to the api.
|
||||
this.task.done = e.target.checked
|
||||
markAsDone(checked) {
|
||||
const updateFunc = () => {
|
||||
this.taskService.update(this.task)
|
||||
.then(t => {
|
||||
this.task = t
|
||||
|
@ -93,8 +85,7 @@
|
|||
title: 'Undo',
|
||||
callback: () => this.markAsDone({
|
||||
target: {
|
||||
id: e.target.id,
|
||||
checked: !e.target.checked
|
||||
checked: !checked
|
||||
}
|
||||
}),
|
||||
}]
|
||||
|
@ -105,8 +96,8 @@
|
|||
})
|
||||
}
|
||||
|
||||
if (e.target.checked) {
|
||||
setTimeout(updateFunc(), 300); // Delay it to show the animation when marking a task as done
|
||||
if (checked) {
|
||||
setTimeout(updateFunc, 300); // Delay it to show the animation when marking a task as done
|
||||
} else {
|
||||
updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
|
||||
}
|
||||
|
|
24
src/components/tasks/reusable/sort.vue
Normal file
24
src/components/tasks/reusable/sort.vue
Normal file
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<a @click="click">
|
||||
<icon icon="sort-up" v-if="order === 'asc'"/>
|
||||
<icon icon="sort-up" v-else-if="order === 'desc'" rotation="180"/>
|
||||
<icon icon="sort" v-else/>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'sort',
|
||||
props: {
|
||||
order: {
|
||||
type: String,
|
||||
default: 'none',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
click() {
|
||||
this.$emit('click')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -64,6 +64,9 @@ import { faClock } from '@fortawesome/free-regular-svg-icons'
|
|||
import { faHistory } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faCheckDouble } from '@fortawesome/free-solid-svg-icons'
|
||||
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 { faComments } from '@fortawesome/free-regular-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
|
@ -104,6 +107,9 @@ library.add(faHistory)
|
|||
library.add(faSearch)
|
||||
library.add(faCheckDouble)
|
||||
library.add(faComments)
|
||||
library.add(faTh)
|
||||
library.add(faSort)
|
||||
library.add(faSortUp)
|
||||
|
||||
Vue.component('icon', FontAwesomeIcon)
|
||||
|
||||
|
|
|
@ -13,3 +13,4 @@
|
|||
@import 'teams';
|
||||
@import 'migrator';
|
||||
@import 'comments';
|
||||
@import 'table-view';
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
span {
|
||||
font-size: 0.8em;
|
||||
vertical-align: top;
|
||||
padding-left: .5em;
|
||||
}
|
||||
|
||||
svg {
|
||||
|
|
32
src/styles/components/table-view.scss
Normal file
32
src/styles/components/table-view.scss
Normal file
|
@ -0,0 +1,32 @@
|
|||
.table-view {
|
||||
.table {
|
||||
background: transparent;
|
||||
|
||||
.user {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.is-done {
|
||||
font-size: .9em;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.column-filter {
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
position: absolute;
|
||||
right: 3em;
|
||||
margin-top: -80px;
|
||||
|
||||
.card {
|
||||
text-align: left;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.fancycheckbox {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -55,16 +55,6 @@
|
|||
height: auto;
|
||||
}
|
||||
|
||||
.is-done {
|
||||
background: $green;
|
||||
color: $white;
|
||||
padding: .5em;
|
||||
font-size: 1.5em;
|
||||
margin-left: .5em;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.date-input {
|
||||
|
@ -181,3 +171,15 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-done {
|
||||
background: $green;
|
||||
color: $white;
|
||||
padding: .5em;
|
||||
font-size: 1.5em;
|
||||
margin-left: .5em;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue