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:
konrad 2020-04-01 20:13:57 +00:00
parent cc513b5274
commit 724275e653
21 changed files with 623 additions and 197 deletions

View file

@ -104,18 +104,9 @@
</ul> </ul>
</div> </div>
<aside class="menu namespaces-lists"> <aside class="menu namespaces-lists">
<div class="fancycheckbox show-archived-check"> <fancycheckbox v-model="showArchived" @change="loadNamespaces()" class="show-archived-check">
<input type="checkbox" v-model="showArchived" @change="loadNamespaces()" style="display: none;" id="showArchivedCheckbox"/> Show Archived
<label class="check" for="showArchivedCheckbox"> </fancycheckbox>
<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>
<div class="spinner" :class="{ 'is-loading': namespaceService.loading}"></div> <div class="spinner" :class="{ 'is-loading': namespaceService.loading}"></div>
<template v-for="n in namespaces"> <template v-for="n in namespaces">
<div :key="n.id"> <div :key="n.id">
@ -228,11 +219,15 @@
import authTypes from './models/authTypes' import authTypes from './models/authTypes'
import swEvents from './ServiceWorker/events' 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 { export default {
name: 'app', name: 'app',
components: {Notification}, components: {
Fancycheckbox,
Notification,
},
data() { data() {
return { return {
user: auth.user, user: auth.user,

View 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>

View file

@ -1,5 +1,5 @@
<template> <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"/> <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> <span v-if="showUsername" class="username">{{ user.username }}</span>
</div> </div>
@ -22,7 +22,11 @@
required: false, required: false,
type: Number, type: Number,
default: 50, default: 50,
} },
isInline: {
type: Boolean,
default: false,
},
}, },
} }
</script> </script>
@ -31,6 +35,10 @@
.user { .user {
margin: .5em; margin: .5em;
&.is-inline {
display: inline;
}
img { img {
-webkit-border-radius: 100%; -webkit-border-radius: 100%;
-moz-border-radius: 100%; -moz-border-radius: 100%;

View file

@ -25,7 +25,6 @@
{{ l.title }} {{ l.title }}
</a> </a>
<a class="delete is-small" @click="deleteLabel(l)" v-if="user.infos.id === l.created_by.id"></a> <a class="delete is-small" @click="deleteLabel(l)" v-if="user.infos.id === l.created_by.id"></a>
</span> </span>
</div> </div>
<div class="column is-4" v-if="isLabelEdit"> <div class="column is-4" v-if="isLabelEdit">

View file

@ -28,10 +28,9 @@
<div class="field"> <div class="field">
<label class="label" for="isArchivedCheck">Is Archived</label> <label class="label" for="isArchivedCheck">Is Archived</label>
<div class="control"> <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.'"> <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.'">
<input type="checkbox" id="isArchivedCheck" v-model="list.is_archived"/>
This list is archived This list is archived
</label> </fancycheckbox>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
@ -89,10 +88,11 @@
import auth from '../../auth' import auth from '../../auth'
import router from '../../router' import router from '../../router'
import manageSharing from '../sharing/userTeam' import manageSharing from '../sharing/userTeam'
import LinkSharing from '../sharing/linkSharing'; import LinkSharing from '../sharing/linkSharing'
import ListModel from '../../models/list' import ListModel from '../../models/list'
import ListService from '../../services/list' import ListService from '../../services/list'
import Fancycheckbox from '../global/fancycheckbox'
export default { export default {
name: "EditList", name: "EditList",
@ -110,6 +110,7 @@
} }
}, },
components: { components: {
Fancycheckbox,
LinkSharing, LinkSharing,
manageSharing, manageSharing,
verte, verte,

View file

@ -10,12 +10,14 @@
It is not possible to create new or edit tasks or it. It is not possible to create new or edit tasks or it.
</div> </div>
<div class="switch-view"> <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: '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>
</div> </div>
<gantt :list="list" v-if="$route.params.type === 'gantt'"/> <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/> <show-list-task :the-list="list" v-else/>
</div> </div>
</template> </template>
@ -30,6 +32,7 @@
import ListModel from '../../models/list' import ListModel from '../../models/list'
import ListService from '../../services/list' import ListService from '../../services/list'
import authType from '../../models/authTypes' import authType from '../../models/authTypes'
import TableView from '../tasks/TableView'
export default { export default {
data() { data() {
@ -40,6 +43,7 @@
} }
}, },
components: { components: {
TableView,
Gantt, Gantt,
ShowListTask, ShowListTask,
}, },
@ -50,7 +54,13 @@
} }
// If the type is invalid, redirect the user // 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 }}) router.push({name: 'showList', params: { id: this.$route.params.id }})
} }
}, },

View file

@ -28,18 +28,9 @@
<div class="field"> <div class="field">
<label class="label" for="isArchivedCheck">Is Archived</label> <label class="label" for="isArchivedCheck">Is Archived</label>
<div class="control"> <div class="control">
<div class="fancycheckbox" v-tooltip="'If a namespace is archived, you cannot create new lists or edit it.'"> <fancycheckbox v-model="namespace.is_archived" 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"/> This namespace is archived
<label class="check" for="isArchivedCheck"> </fancycheckbox>
<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>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
@ -98,6 +89,7 @@
import NamespaceService from '../../services/namespace' import NamespaceService from '../../services/namespace'
import NamespaceModel from '../../models/namespace' import NamespaceModel from '../../models/namespace'
import Fancycheckbox from '../global/fancycheckbox'
export default { export default {
name: "EditNamespace", name: "EditNamespace",
@ -114,6 +106,7 @@
} }
}, },
components: { components: {
Fancycheckbox,
manageSharing, manageSharing,
verte, verte,
}, },

View file

@ -1,18 +1,9 @@
<template> <template>
<div> <div>
<div class="gantt-options"> <div class="gantt-options">
<div class="fancycheckbox is-block"> <fancycheckbox v-model="showTaskswithoutDates" class="is-block">
<input id="showTaskswithoutDates" type="checkbox" style="display: none;" v-model="showTaskswithoutDates"> Show tasks which don't have dates set
<label for="showTaskswithoutDates" class="check"> </fancycheckbox>
<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>
<div class="range-picker"> <div class="range-picker">
<div class="field"> <div class="field">
<label class="label" for="dayWidth">Size</label> <label class="label" for="dayWidth">Size</label>
@ -66,10 +57,12 @@
import GanttChart from './gantt-component' import GanttChart from './gantt-component'
import flatPickr from 'vue-flatpickr-component' import flatPickr from 'vue-flatpickr-component'
import ListModel from '../../models/list' import ListModel from '../../models/list'
import Fancycheckbox from '../global/fancycheckbox'
export default { export default {
name: 'Gantt', name: 'Gantt',
components: { components: {
Fancycheckbox,
flatPickr, flatPickr,
GanttChart GanttChart
}, },

View file

@ -1,5 +1,5 @@
<template> <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="search">
<div class="field has-addons" :class="{ 'hidden': !showTaskSearch }"> <div class="field has-addons" :class="{ 'hidden': !showTaskSearch }">
<div class="control has-icons-left has-icons-right"> <div class="control has-icons-left has-icons-right">
@ -87,13 +87,13 @@
</div> </div>
<nav class="pagination is-centered" role="navigation" aria-label="pagination" v-if="taskCollectionService.totalPages > 1"> <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-previous" :to="getRouteForPagination(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-next" :to="getRouteForPagination(currentPage + 1)" tag="button" :disabled="currentPage === taskCollectionService.totalPages">Next page</router-link>
<ul class="pagination-list"> <ul class="pagination-list">
<template v-for="(p, i) in pages"> <template v-for="(p, i) in pages">
<li :key="'page'+i" v-if="p.isEllipsis"><span class="pagination-ellipsis">&hellip;</span></li> <li :key="'page'+i" v-if="p.isEllipsis"><span class="pagination-ellipsis">&hellip;</span></li>
<li :key="'page'+i" v-else> <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> </li>
</template> </template>
</ul> </ul>
@ -102,35 +102,30 @@
</template> </template>
<script> <script>
import ListService from '../../services/list'
import TaskService from '../../services/task' import TaskService from '../../services/task'
import ListModel from '../../models/list' import ListModel from '../../models/list'
import EditTask from './edit-task' import EditTask from './edit-task'
import TaskModel from '../../models/task' import TaskModel from '../../models/task'
import TaskCollectionService from '../../services/taskCollection'
import SingleTaskInList from './reusable/singleTaskInList' import SingleTaskInList from './reusable/singleTaskInList'
import taskList from './helpers/taskList'
export default { export default {
name: 'ListView',
data() { data() {
return { return {
listID: this.$route.params.id, listID: this.$route.params.id,
listService: ListService,
taskService: TaskService, taskService: TaskService,
taskCollectionService: TaskCollectionService,
pages: [],
currentPage: 0,
list: {}, list: {},
tasks: [],
isTaskEdit: false, isTaskEdit: false,
taskEditTask: TaskModel, taskEditTask: TaskModel,
newTaskText: '', newTaskText: '',
showError: false, showError: false,
showTaskSearch: false,
searchTerm: '',
} }
}, },
mixins: [
taskList,
],
components: { components: {
SingleTaskInList, SingleTaskInList,
EditTask, EditTask,
@ -145,12 +140,9 @@
theList() { theList() {
this.list = this.theList this.list = this.theList
}, },
'$route.query': 'loadTasksForPage', // Only listen for query path changes
}, },
created() { created() {
this.listService = new ListService()
this.taskService = new TaskService() this.taskService = new TaskService()
this.taskCollectionService = new TaskCollectionService()
this.initTasks(1) this.initTasks(1)
}, },
methods: { methods: {
@ -179,61 +171,6 @@
this.error(e, this) 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) { editTask(id) {
// Find the selected task and set it to the current object // 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 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 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) { updateTasks(updatedTask) {
for (const t in this.tasks) { for (const t in this.tasks) {
if (this.tasks[t].id === updatedTask.id) { if (this.tasks[t].id === updatedTask.id) {
@ -274,25 +194,6 @@
} }
this.sortTasks() 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> </script>

View 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&nbsp;Date
<sort :order="sortBy.due_date_unix" @click="sort('due_date_unix')"/>
</th>
<th v-if="activeColumns.startDate">
Start&nbsp;Date
<sort :order="sortBy.start_date_unix" @click="sort('start_date_unix')"/>
</th>
<th v-if="activeColumns.endDate">
End&nbsp;Date
<sort :order="sortBy.end_date_unix" @click="sort('end_date_unix')"/>
</th>
<th v-if="activeColumns.percentDone">
%&nbsp;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&nbsp;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">&hellip;</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>

View 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,
},
}
}
}
}

View 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>

View 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>

View file

@ -1,8 +1,11 @@
<template> <template>
<span v-if="priority >= priorities.HIGH" class="high-priority" :class="{'not-so-high': priority === priorities.HIGH}"> <span v-if="showAll || priority >= priorities.HIGH" :class="{'not-so-high': priority === priorities.HIGH, 'high-priority': priority >= priorities.HIGH}">
<span class="icon"> <span class="icon" v-if="priority >= priorities.HIGH">
<icon icon="exclamation"/> <icon icon="exclamation"/>
</span> </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.HIGH">High</template>
<template v-if="priority === priorities.URGENT">Urgent</template> <template v-if="priority === priorities.URGENT">Urgent</template>
<template v-if="priority === priorities.DO_NOW">DO NOW</template> <template v-if="priority === priorities.DO_NOW">DO NOW</template>
@ -26,7 +29,11 @@
priority: { priority: {
default: 0, default: 0,
type: Number, type: Number,
} },
showAll: {
type: Boolean,
default: false,
},
} }
} }
</script> </script>

View file

@ -1,15 +1,6 @@
<template> <template>
<span> <span>
<div class="fancycheckbox" :class="{'is-disabled': isArchived}"> <fancycheckbox v-model="task.done" @change="markAsDone" :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>
<router-link :to="{ name: 'taskDetailView', params: { id: task.id } }" class="tasktext" :class="{ 'done': task.done}"> <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 --> <!-- 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'"> <span class="parent-tasks" v-if="typeof task.related_tasks.parenttask !== 'undefined'">
@ -19,18 +10,15 @@
> >
</span> </span>
{{ task.text }} {{ task.text }}
<span class="tag" v-for="label in task.labels" :style="{'background': label.hex_color, 'color': label.textColor}" <labels :labels="task.labels"/>
:key="label.id"> <user
<span>{{ label.title }}</span> :user="a"
</span> :avatar-size="27"
<img :show-username="false"
:src="a.getAvatarUrl(27)" :is-inline="true"
:alt="a.username"
class="avatar"
width="27"
height="27"
v-for="(a, i) in task.assignees" 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" <i v-if="task.dueDate > 0"
:class="{'overdue': task.dueDate <= new Date() && !task.done}" :class="{'overdue': task.dueDate <= new Date() && !task.done}"
v-tooltip="formatDate(task.dueDate)"> - Due {{formatDateSince(task.dueDate)}}</i> v-tooltip="formatDate(task.dueDate)"> - Due {{formatDateSince(task.dueDate)}}</i>
@ -43,6 +31,9 @@
import TaskModel from '../../../models/task' import TaskModel from '../../../models/task'
import PriorityLabel from './priorityLabel' import PriorityLabel from './priorityLabel'
import TaskService from '../../../services/task' import TaskService from '../../../services/task'
import Labels from './labels'
import User from '../../global/user'
import Fancycheckbox from '../../global/fancycheckbox'
export default { export default {
name: 'singleTaskInList', name: 'singleTaskInList',
@ -53,6 +44,9 @@
} }
}, },
components: { components: {
Fancycheckbox,
User,
Labels,
PriorityLabel, PriorityLabel,
}, },
props: { props: {
@ -78,10 +72,8 @@
this.taskService = new TaskService() this.taskService = new TaskService()
}, },
methods: { methods: {
markAsDone(e) { markAsDone(checked) {
let updateFunc = () => { const updateFunc = () => {
// We get the task, update the 'done' property and then push it to the api.
this.task.done = e.target.checked
this.taskService.update(this.task) this.taskService.update(this.task)
.then(t => { .then(t => {
this.task = t this.task = t
@ -93,8 +85,7 @@
title: 'Undo', title: 'Undo',
callback: () => this.markAsDone({ callback: () => this.markAsDone({
target: { target: {
id: e.target.id, checked: !checked
checked: !e.target.checked
} }
}), }),
}] }]
@ -105,8 +96,8 @@
}) })
} }
if (e.target.checked) { if (checked) {
setTimeout(updateFunc(), 300); // Delay it to show the animation when marking a task as done setTimeout(updateFunc, 300); // Delay it to show the animation when marking a task as done
} else { } else {
updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
} }

View 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>

View file

@ -64,6 +64,9 @@ import { faClock } from '@fortawesome/free-regular-svg-icons'
import { faHistory } from '@fortawesome/free-solid-svg-icons' import { faHistory } from '@fortawesome/free-solid-svg-icons'
import { faSearch } from '@fortawesome/free-solid-svg-icons' import { faSearch } from '@fortawesome/free-solid-svg-icons'
import { faCheckDouble } 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 { faComments } from '@fortawesome/free-regular-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
@ -104,6 +107,9 @@ library.add(faHistory)
library.add(faSearch) library.add(faSearch)
library.add(faCheckDouble) library.add(faCheckDouble)
library.add(faComments) library.add(faComments)
library.add(faTh)
library.add(faSort)
library.add(faSortUp)
Vue.component('icon', FontAwesomeIcon) Vue.component('icon', FontAwesomeIcon)

View file

@ -13,3 +13,4 @@
@import 'teams'; @import 'teams';
@import 'migrator'; @import 'migrator';
@import 'comments'; @import 'comments';
@import 'table-view';

View file

@ -28,6 +28,7 @@
span { span {
font-size: 0.8em; font-size: 0.8em;
vertical-align: top; vertical-align: top;
padding-left: .5em;
} }
svg { svg {

View 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;
}
}
}

View file

@ -55,16 +55,6 @@
height: auto; 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 { .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;
}