Merge branch 'main' into feature/vite

# Conflicts:
#	yarn.lock
This commit is contained in:
kolaente 2021-02-23 20:44:35 +01:00
commit b1afc5b1b7
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
39 changed files with 815 additions and 123 deletions

View File

@ -471,7 +471,7 @@ steps:
image: plugins/manifest
settings:
tags: latest
spec: docker-manifest.tmpl
spec: docker-manifest-latest.tmpl
password:
from_secret: docker_password
username:

View File

@ -102,6 +102,44 @@ describe('Lists', () => {
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
})
it('Should only show the color of a list in the navigation and not in the list view', () => {
const lists = ListFactory.create(1, {
hex_color: '00db60',
})
TaskFactory.create(10, {
list_id: lists[0].id,
})
cy.visit(`/lists/${lists[0].id}/`)
cy.get('.menu-list li .list-menu-link .color-bubble')
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
cy.get('.tasks-container .tasks .color-bubble')
.should('not.exist')
})
it('Should paginate for > 50 tasks', () => {
const tasks = TaskFactory.create(100, {
id: '{increment}',
title: i => `task${i}`,
list_id: 1,
})
cy.visit('/lists/1/list')
cy.get('.tasks-container .tasks')
.should('contain', tasks[99].title)
cy.get('.card-content .pagination .pagination-link')
.contains('2')
.click()
cy.url()
.should('contain', '?page=2')
cy.get('.tasks-container .tasks')
.should('contain', tasks[1].title)
cy.get('.tasks-container .tasks')
.should('not.contain', tasks[99].title)
})
})
describe('Table View', () => {
@ -371,5 +409,39 @@ describe('Lists', () => {
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should remove a task from the kanban board when moving it to another list', () => {
const lists = ListFactory.create(2)
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
bucket_id: 1,
})
const task = tasks[0]
cy.visit('/lists/1/kanban')
cy.getAttached('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
cy.get('.task-view .action-buttons .button')
.contains('Move task')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${lists[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results')
.children()
.first()
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.go('back')
cy.get('.kanban .bucket')
.should('not.contain', task.title)
})
})
})

View File

@ -27,6 +27,10 @@ export class Factory {
for (let i = 1; i <= count; i++) {
const entry = merge(this.factory(), override)
for (const e in entry) {
if(typeof entry[e] === 'function') {
entry[e] = entry[e](i)
continue
}
if (entry[e] === '{increment}') {
entry[e] = i
}

View File

@ -0,0 +1,17 @@
image: vikunja/frontend:latest
manifests:
-
image: vikunja/frontend:latest-linux-amd64
platform:
architecture: amd64
os: linux
-
image: vikunja/frontend:latest-linux-arm64
platform:
architecture: arm64
os: linux
-
image: vikunja/frontend:latest-linux-arm
platform:
architecture: arm
os: linux

View File

@ -19,7 +19,7 @@
"date-fns": "2.17.0",
"dompurify": "2.2.6",
"highlight.js": "10.6.0",
"lodash": "4.17.20",
"lodash": "4.17.21",
"marked": "2.0.0",
"register-service-worker": "1.7.2",
"snake-case": "3.0.4",
@ -44,10 +44,10 @@
"@vue/cli-service": "4.5.11",
"axios": "0.21.1",
"babel-eslint": "10.1.0",
"cypress": "6.4.0",
"cypress": "6.5.0",
"cypress-file-upload": "5.0.2",
"eslint": "7.20.0",
"eslint-plugin-vue": "7.5.0",
"eslint-plugin-vue": "7.6.0",
"faker": "5.4.0",
"jest": "26.6.3",
"node-sass": "5.0.0",

View File

@ -51,8 +51,8 @@
<aside class="menu namespaces-lists loader-container" :class="{'is-loading': loading}">
<template v-for="n in namespaces">
<div :key="n.id" class="namespace-title">
<label
:for="n.id + 'checker'"
<span
@click="toggleLists(n.id)"
class="menu-label"
v-tooltip="n.title + ' (' + n.lists.filter(l => !l.isArchived).length + ')'">
<span class="name">
@ -63,16 +63,10 @@
</span>
{{ n.title }} ({{ n.lists.filter(l => !l.isArchived).length }})
</span>
</label>
</span>
<namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/>
</div>
<input
:id="n.id + 'checker'"
:key="n.id + 'checker'"
checked="checked"
class="checkinput"
type="checkbox"/>
<div :key="n.id + 'child'" class="more-container">
<div :key="n.id + 'child'" class="more-container" v-if="listsVisible[n.id]">
<ul class="menu-list can-be-hidden">
<template v-for="l in n.lists">
<!-- This is a bit ugly but vue wouldn't want to let me filter this - probably because the lists
@ -104,10 +98,14 @@
</li>
</template>
</ul>
<label :for="n.id + 'checker'" class="hidden-hint">
Show hidden lists ({{ n.lists.filter(l => !l.isArchived).length }})...
</label>
</div>
<span
@click="toggleLists(n.id)"
:key="`${n.id}_hidden_hint`"
class="hidden-hint"
v-else-if="n.lists.filter(l => !l.isArchived).length > 0">
Show hidden lists ({{ n.lists.filter(l => !l.isArchived).length }})...
</span>
</template>
</aside>
<a class="menu-bottom-link" href="https://vikunja.io" target="_blank">Powered by Vikunja</a>
@ -122,6 +120,11 @@ import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings
export default {
name: 'navigation',
data() {
return {
listsVisible: {},
}
},
components: {
ListSettingsDropdown,
NamespaceSettingsDropdown,
@ -137,6 +140,11 @@ export default {
}),
beforeCreate() {
this.$store.dispatch('namespaces/loadNamespaces')
.then(namespaces => {
namespaces.forEach(n => {
this.$set(this.listsVisible, n.id, true)
})
})
},
created() {
window.addEventListener('resize', this.resize)
@ -162,6 +170,9 @@ export default {
this.$store.commit(MENU_ACTIVE, true)
}
},
toggleLists(namespaceId) {
this.$set(this.listsVisible, namespaceId, !this.listsVisible[namespaceId] ?? false)
},
},
}
</script>

View File

@ -37,6 +37,7 @@
<div class="navbar-end">
<update/>
<notifications/>
<div class="user">
<img :src="userAvatar" alt="" class="avatar"/>
<dropdown class="is-right">
@ -86,10 +87,12 @@ import Rights from '@/models/rights.json'
import Update from '@/components/home/update'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown'
import Dropdown from '@/components/misc/dropdown'
import Notifications from '@/components/notifications/notifications'
export default {
name: 'topNavigation',
components: {
Notifications,
Dropdown,
ListSettingsDropdown,
Update,

View File

@ -54,6 +54,14 @@
>
Archive
</dropdown-item>
<task-subscription
class="dropdown-item has-no-shadow"
:is-button="false"
entity="list"
:entity-id="list.id"
:subscription="subscription"
@change="sub => subscription = sub"
/>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.delete`, params: { listId: list.id } }"
icon="trash-alt"
@ -69,10 +77,17 @@
import {getSavedFilterIdFromListId} from '@/helpers/savedFilter'
import Dropdown from '@/components/misc/dropdown'
import DropdownItem from '@/components/misc/dropdown-item'
import TaskSubscription from '@/components/misc/subscription'
export default {
name: 'list-settings-dropdown',
data() {
return {
subscription: null,
}
},
components: {
TaskSubscription,
DropdownItem,
Dropdown,
},
@ -81,6 +96,9 @@ export default {
required: true,
},
},
mounted() {
this.subscription = this.list.subscription
},
computed: {
backgroundsEnabled() {
return this.$store.state.config.enabledBackgroundProviders.length > 0

View File

@ -0,0 +1,121 @@
<template>
<x-button
type="secondary"
:icon="icon"
v-tooltip="tooltipText"
@click="changeSubscription"
:disabled="disabled"
v-if="isButton"
>
{{ buttonText }}
</x-button>
<a
v-tooltip="tooltipText"
@click="changeSubscription"
:class="{'is-disabled': disabled}"
v-else
>
<span class="icon">
<icon :icon="icon"/>
</span>
{{ buttonText }}
</a>
</template>
<script>
import SubscriptionService from '@/services/subscription'
import SubscriptionModel from '@/models/subscription'
export default {
name: 'task-subscription',
data() {
return {
subscriptionService: SubscriptionService,
}
},
props: {
entity: {
required: true,
type: String,
},
subscription: {
required: true,
},
entityId: {
required: true,
},
isButton: {
type: Boolean,
default: true,
},
},
created() {
this.subscriptionService = new SubscriptionService()
},
computed: {
tooltipText() {
if(this.disabled) {
return `You can't unsubscribe here because you are subscribed to this ${this.entity} through its ${this.subscription.entity}.`
}
return this.subscription !== null ?
`You are currently subscribed to this ${this.entity} and will receive notifications for changes.` :
`You are not subscribed to this ${this.entity} and won't receive notifications for changes.`
},
buttonText() {
return this.subscription !== null ? 'Unsubscribe' : 'Subscribe'
},
icon() {
return this.subscription !== null ? ['far', 'bell-slash'] : 'bell'
},
disabled() {
if (this.subscription === null) {
return false
}
return this.subscription.entity !== this.entity
},
},
methods: {
changeSubscription() {
if(this.disabled) {
return
}
if (this.subscription === null) {
this.subscribe()
} else {
this.unsubscribe()
}
},
subscribe() {
const subscription = new SubscriptionModel({
entity: this.entity,
entityId: this.entityId,
})
this.subscriptionService.create(subscription)
.then(() => {
this.$emit('change', subscription)
this.success({message: `You are now subscribed to this ${this.entity}`}, this)
})
.catch(e => {
this.error(e, this)
})
},
unsubscribe() {
const subscription = new SubscriptionModel({
entity: this.entity,
entityId: this.entityId,
})
this.subscriptionService.delete(subscription)
.then(() => {
this.$emit('change', null)
this.success({message: `You are now unsubscribed to this ${this.entity}`}, this)
})
.catch(e => {
this.error(e, this)
})
}
},
}
</script>

View File

@ -33,6 +33,14 @@
>
Archive
</dropdown-item>
<task-subscription
class="dropdown-item has-no-shadow"
:is-button="false"
entity="namespace"
:entity-id="namespace.id"
:subscription="subscription"
@change="sub => subscription = sub"
/>
<dropdown-item
:to="{ name: 'namespace.settings.delete', params: { id: namespace.id } }"
icon="trash-alt"
@ -47,17 +55,27 @@
<script>
import Dropdown from '@/components/misc/dropdown'
import DropdownItem from '@/components/misc/dropdown-item'
import TaskSubscription from '@/components/misc/subscription'
export default {
name: 'namespace-settings-dropdown',
data() {
return {
subscription: null,
}
},
components: {
DropdownItem,
Dropdown,
TaskSubscription,
},
props: {
namespace: {
required: true,
},
},
mounted() {
this.subscription = this.namespace.subscription
},
}
</script>

View File

@ -0,0 +1,135 @@
<template>
<div class="notifications">
<a @click.stop="showNotifications = !showNotifications" class="trigger">
<span class="unread-indicator" v-if="unreadNotifications > 0"></span>
<icon icon="bell"/>
</a>
<transition name="fade">
<div class="notifications-list" v-if="showNotifications" ref="popup">
<span class="head">Notifications</span>
<div
v-for="(n, index) in notifications"
:key="n.id"
class="single-notification"
>
<div class="read-indicator" :class="{'read': n.readAt !== null}"></div>
<user
:user="n.notification.doer"
:show-username="true"
:avatar-size="16"
v-if="n.notification.doer"/>
<span class="detail">
<a @click="() => to(n, index)()">
{{ n.toText(userInfo) }}
</a>
<span class="created" v-tooltip="formatDate(n.created)">
{{ formatDateSince(n.created) }}
</span>
</span>
</div>
<p class="nothing" v-if="notifications.length === 0">
You don't have any notifications. Have a nice day!<br/>
<span class="explainer">
Notifications will appear here when actions on namespaces, lists or tasks you subscribed to happen.
</span>
</p>
</div>
</transition>
</div>
</template>
<script>
import NotificationService from '@/services/notification'
import User from '@/components/misc/user'
import names from '@/models/notificationNames.json'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {mapState} from 'vuex'
export default {
name: 'notifications',
components: {User},
data() {
return {
notificationService: NotificationService,
notifications: [],
showNotifications: false,
interval: null,
}
},
created() {
this.notificationService = new NotificationService()
},
mounted() {
this.loadNotifications()
document.addEventListener('click', this.hidePopup)
this.interval = setInterval(this.loadNotifications, 10000)
},
beforeDestroy() {
document.removeEventListener('click', this.hidePopup)
clearInterval(this.interval)
},
computed: {
unreadNotifications() {
return this.notifications.filter(n => n.readAt === null).length
},
...mapState({
userInfo: state => state.auth.info,
}),
},
methods: {
hidePopup(e) {
if (this.showNotifications) {
closeWhenClickedOutside(e, this.$refs.popup, () => this.showNotifications = false)
}
},
loadNotifications() {
this.notificationService.getAll()
.then(r => {
this.$set(this, 'notifications', r)
})
.catch(e => {
this.error(e, this)
})
},
to(n, index) {
const to = {
name: '',
params: {},
}
switch (n.name) {
case names.TASK_COMMENT:
case names.TASK_ASSIGNED:
to.name = 'task.detail'
to.params.id = n.notification.task.id
break
case names.TASK_DELETED:
// Nothing
break
case names.LIST_CREATED:
to.name = 'task.index'
to.params.listId = n.notification.list.id
break
case names.TEAM_MEMBER_ADDED:
to.name = 'teams.edit'
to.params.id = n.notification.team.id
break
}
return () => {
if (to.name !== '') {
this.$router.push(to)
}
n.read = true
this.notificationService.update(n)
.then(r => {
this.$set(this.notifications, index, r)
})
.catch(e => this.error(e, this))
}
},
},
}
</script>

View File

@ -1,4 +1,5 @@
import TaskCollectionService from '../../../services/taskCollection'
import {cloneDeep} from 'lodash'
/**
* This mixin provides a base set of methods and properties to get tasks on a list.
@ -55,19 +56,6 @@ export default {
return
}
const list = {listId: parseInt(this.$route.params.listId)}
const currentList = {
id: list.listId,
params: params,
search: search,
}
if (JSON.stringify(currentList) === JSON.stringify(this.loadedList)) {
return
}
this.$set(this, 'tasks', [])
if (params === null) {
params = this.params
}
@ -76,6 +64,20 @@ export default {
params.s = search
}
const list = {listId: parseInt(this.$route.params.listId)}
const currentList = {
id: list.listId,
params: params,
search: search,
page: page,
}
if (JSON.stringify(currentList) === JSON.stringify(this.loadedList)) {
return
}
this.$set(this, 'tasks', [])
this.taskCollectionService.getAll(list, params, page)
.then(r => {
this.$set(this, 'tasks', r)
@ -110,7 +112,7 @@ export default {
})
}
this.loadedList = currentList
this.loadedList = cloneDeep(currentList)
})
.catch(e => {
this.error(e, this)

View File

@ -1,10 +1,7 @@
<template>
<div class="heading">
<h1 class="title task-id" v-if="task.identifier === ''">
#{{ task.index }}
</h1>
<h1 class="title task-id" v-else>
{{ task.identifier }}
<h1 class="title task-id">
{{ task.getTextIdentifier() }}
</h1>
<div class="is-done" v-if="task.done">Done</div>
<h1

View File

@ -219,7 +219,7 @@ export default {
})
},
removeTaskRelation() {
let rel = new TaskRelationModel({
const rel = new TaskRelationModel({
relationKind: this.relationToDelete.relationKind,
taskId: this.taskId,
otherTaskId: this.relationToDelete.otherTaskId,

View File

@ -2,9 +2,10 @@
<div :class="{'is-loading': taskService.loading}" class="task loader-container">
<fancycheckbox :disabled="isArchived || disabled" @change="markAsDone" v-model="task.done"/>
<span
v-if="showListColor && listColor !== ''"
:style="{backgroundColor: listColor }"
class="color-bubble"
v-if="listColor !== ''">
>
</span>
<router-link
:to="{ name: taskDetailRoute, params: { id: task.id } }"
@ -131,6 +132,10 @@ export default {
type: Boolean,
default: false,
},
showListColor: {
type: Boolean,
default: true,
}
},
watch: {
theTask(newVal) {

View File

@ -0,0 +1,6 @@
export const parseDateOrNull = date => {
if (date && !date.startsWith('0001')) {
return new Date(date)
}
return null
}

View File

@ -61,8 +61,9 @@ import {
faArchive,
faShareAlt,
faImage,
faBell,
} from '@fortawesome/free-solid-svg-icons'
import {faCalendarAlt, faClock, faComments, faSave, faStar, faTimesCircle, faSun} from '@fortawesome/free-regular-svg-icons'
import {faCalendarAlt, faClock, faComments, faSave, faStar, faTimesCircle, faSun, faBellSlash} from '@fortawesome/free-regular-svg-icons'
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome'
// PWA
import './registerServiceWorker'
@ -152,6 +153,8 @@ library.add(faEllipsisH)
library.add(faArchive)
library.add(faShareAlt)
library.add(faImage)
library.add(faBell)
library.add(faBellSlash)
Vue.component('icon', FontAwesomeIcon)

View File

@ -2,6 +2,7 @@ import AbstractModel from './abstractModel'
import TaskModel from './task'
import UserModel from './user'
import {getSavedFilterIdFromListId} from '@/helpers/savedFilter'
import SubscriptionModel from '@/models/subscription'
export default class ListModel extends AbstractModel {
@ -19,6 +20,10 @@ export default class ListModel extends AbstractModel {
this.owner = new UserModel(this.owner)
if(typeof this.subscription !== 'undefined' && this.subscription !== null) {
this.subscription = new SubscriptionModel(this.subscription)
}
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}
@ -37,6 +42,7 @@ export default class ListModel extends AbstractModel {
identifier: '',
backgroundInformation: null,
isFavorite: false,
subscription: null,
created: null,
updated: null,

View File

@ -1,6 +1,7 @@
import AbstractModel from './abstractModel'
import ListModel from './list'
import UserModel from './user'
import SubscriptionModel from '@/models/subscription'
export default class NamespaceModel extends AbstractModel {
constructor(data) {
@ -13,8 +14,13 @@ export default class NamespaceModel extends AbstractModel {
this.lists = this.lists.map(l => {
return new ListModel(l)
})
this.owner = new UserModel(this.owner)
if(typeof this.subscription !== 'undefined' && this.subscription !== null) {
this.subscription = new SubscriptionModel(this.subscription)
}
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}
@ -29,6 +35,7 @@ export default class NamespaceModel extends AbstractModel {
lists: [],
isArchived: false,
hexColor: '',
subscription: null,
created: null,
updated: null,

View File

@ -0,0 +1,84 @@
import AbstractModel from '@/models/abstractModel'
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
import UserModel from '@/models/user'
import TaskModel from '@/models/task'
import TaskCommentModel from '@/models/taskComment'
import ListModel from '@/models/list'
import TeamModel from '@/models/team'
import names from './notificationNames.json'
export default class NotificationModel extends AbstractModel {
constructor(data) {
super(data)
switch (this.name) {
case names.TASK_COMMENT:
this.notification.doer = new UserModel(this.notification.doer)
this.notification.task = new TaskModel(this.notification.task)
this.notification.comment = new TaskCommentModel(this.notification.comment)
break
case names.TASK_ASSIGNED:
this.notification.doer = new UserModel(this.notification.doer)
this.notification.task = new TaskModel(this.notification.task)
this.notification.assignee = new UserModel(this.notification.assignee)
break
case names.TASK_DELETED:
this.notification.doer = new UserModel(this.notification.doer)
this.notification.task = new TaskModel(this.notification.task)
break
case names.LIST_CREATED:
this.notification.doer = new UserModel(this.notification.doer)
this.notification.list = new ListModel(this.notification.list)
break
case names.TEAM_MEMBER_ADDED:
this.notification.doer = new UserModel(this.notification.doer)
this.notification.member = new UserModel(this.notification.member)
this.notification.team = new TeamModel(this.notification.team)
break
}
this.created = new Date(this.created)
this.readAt = parseDateOrNull(this.readAt)
}
defaults() {
return {
id: 0,
name: '',
notification: null,
read: false,
readAt: null,
}
}
toText(user = null) {
let who = ''
switch (this.name) {
case names.TASK_COMMENT:
return `commented on ${this.notification.task.getTextIdentifier()}`
case names.TASK_ASSIGNED:
who = `${this.notification.assignee.getDisplayName()}`
if (user !== null && user.id === this.notification.assignee.id) {
who = 'you'
}
return `assigned ${who} to ${this.notification.task.getTextIdentifier()}`
case names.TASK_DELETED:
return `deleted ${this.notification.task.getTextIdentifier()}`
case names.LIST_CREATED:
return `created ${this.notification.list.title}`
case names.TEAM_MEMBER_ADDED:
who = `${this.notification.member.getDisplayName()}`
if (user !== null && user.id === this.notification.member.id) {
who = 'you'
}
return `added ${who} to the ${this.notification.team.name} team`
}
return ''
}
}

View File

@ -0,0 +1,7 @@
{
"TASK_COMMENT": "task.comment",
"TASK_ASSIGNED": "task.assigned",
"TASK_DELETED": "task.deleted",
"LIST_CREATED": "list.created",
"TEAM_MEMBER_ADDED": "team.member.added"
}

View File

@ -28,8 +28,8 @@
"Blocked By"
],
"precedes": [
"Preceds",
"Preceds"
"Precedes",
"Precedes"
],
"follows": [
"Follows",

View File

@ -0,0 +1,20 @@
import AbstractModel from '@/models/abstractModel'
import UserModel from '@/models/user'
export default class SubscriptionModel extends AbstractModel {
constructor(data) {
super(data)
this.user = new UserModel(this.user)
this.created = new Date(this.created)
}
defaults() {
return {
id: 0,
entity: '',
entityId: 0,
created: null,
user: {},
}
}
}

View File

@ -2,13 +2,8 @@ import AbstractModel from './abstractModel'
import UserModel from './user'
import LabelModel from './label'
import AttachmentModel from './attachment'
const parseDate = date => {
if (date && !date.startsWith('0001')) {
return new Date(date)
}
return null
}
import SubscriptionModel from '@/models/subscription'
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
export default class TaskModel extends AbstractModel {
@ -21,10 +16,10 @@ export default class TaskModel extends AbstractModel {
this.listId = Number(this.listId)
// Make date objects from timestamps
this.dueDate = parseDate(this.dueDate)
this.startDate = parseDate(this.startDate)
this.endDate = parseDate(this.endDate)
this.doneAt = parseDate(this.doneAt)
this.dueDate = parseDateOrNull(this.dueDate)
this.startDate = parseDateOrNull(this.startDate)
this.endDate = parseDateOrNull(this.endDate)
this.doneAt = parseDateOrNull(this.doneAt)
// Cancel all scheduled notifications for this task to be sure to only have available notifications
this.cancelScheduledNotifications()
@ -75,6 +70,10 @@ export default class TaskModel extends AbstractModel {
this.identifier = ''
}
if (typeof this.subscription !== 'undefined' && this.subscription !== null) {
this.subscription = new SubscriptionModel(this.subscription)
}
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}
@ -104,6 +103,7 @@ export default class TaskModel extends AbstractModel {
identifier: '',
index: 0,
isFavorite: false,
subscription: null,
createdBy: UserModel,
created: null,
@ -113,6 +113,14 @@ export default class TaskModel extends AbstractModel {
}
}
getTextIdentifier() {
if(this.identifier === '') {
return `#${this.index}`
}
return this.identifier
}
/////////////////
// Helper functions
///////////////

View File

@ -0,0 +1,22 @@
import AbstractService from '@/services/abstractService'
import {formatISO} from 'date-fns'
import NotificationModel from '@/models/notification'
export default class NotificationService extends AbstractService {
constructor() {
super({
getAll: '/notifications',
update: '/notifications/{id}',
})
}
modelFactory(data) {
return new NotificationModel(data)
}
beforeUpdate(model) {
model.created = formatISO(new Date(model.created))
model.readAt = formatISO(new Date(model.readAt))
return model
}
}

View File

@ -0,0 +1,15 @@
import AbstractService from '@/services/abstractService'
import SubscriptionModel from '@/models/subscription'
export default class SubscriptionService extends AbstractService {
constructor() {
super({
create: '/subscriptions/{entity}/{entityId}',
delete: '/subscriptions/{entity}/{entityId}',
})
}
modelFactory(data) {
return new SubscriptionModel(data)
}
}

View File

@ -6,7 +6,7 @@ export default class TaskRelationService extends AbstractService {
constructor() {
super({
create: '/tasks/{taskId}/relations',
delete: '/tasks/{taskId}/relations',
delete: '/tasks/{taskId}/relations/{relationKind}/{otherTaskId}',
})
}

View File

@ -114,7 +114,7 @@ export default {
ctx.commit('lists/addLists', lists, {root: true})
return Promise.resolve()
return Promise.resolve(r)
})
.catch(e => {
return Promise.reject(e)

View File

@ -22,3 +22,4 @@
@import 'keyboard-shortcuts';
@import 'api-config';
@import 'datepicker';
@import 'notifications';

View File

@ -0,0 +1,110 @@
.notifications {
width: 50px;
.trigger {
cursor: pointer;
color: $grey-400;
padding: 1rem;
font-size: 1.25rem;
position: relative;
.unread-indicator {
position: absolute;
top: 1rem;
right: .75rem;
width: .75rem;
height: .75rem;
background: $primary;
border-radius: 100%;
border: 2px solid $white;
}
}
.notifications-list {
position: fixed;
right: 1rem;
margin-top: 1rem;
max-height: 400px;
overflow-y: auto;
background: $white;
width: 350px;
max-width: calc(100vw - 2rem);
padding: .75rem .25rem;
border-radius: $radius;
box-shadow: $shadow-sm;
font-size: .85rem;
@media screen and (max-width: $tablet) {
max-height: calc(100vh - 1rem - #{$navbar-height});
}
.head {
font-family: $vikunja-font;
font-size: 1rem;
padding: .5rem;
}
.single-notification {
display: flex;
align-items: center;
transition: background-color $transition;
&:hover {
background: $grey-100;
border-radius: $radius;
}
.read-indicator {
width: .35rem;
height: .35rem;
background: $primary;
border-radius: 100%;
margin-left: .5rem;
&.read {
background: transparent;
}
}
.user {
display: flex;
align-items: center;
width: auto;
margin-right: .25rem;
span {
font-family: $family-sans-serif;
}
.avatar {
height: 16px;
}
}
.detail .created {
color: $grey-400;
}
&:last-child {
margin-bottom: .25rem;
}
a {
color: $grey-800;
}
}
.nothing {
text-align: center;
padding: 1rem 0;
color: $grey-500;
.explainer {
font-size: .75rem;
}
}
}
}

View File

@ -29,6 +29,7 @@
.navbar-end {
margin-left: 0;
align-items: center;
display: flex;
}
@media screen and (max-width: $desktop) {
@ -194,40 +195,15 @@
color: $vikunja-nav-color;
}
.checkinput {
display: none;
}
.checkinput:checked + .more-container {
.menu-list.can-be-hidden {
opacity: 1;
height: auto;
}
}
.checkinput:not(:checked) + .more-container .hidden-hint {
opacity: 1;
height: auto;
}
.hidden-hint {
display: block;
opacity: 0;
height: 0;
text-align: center;
color: $grey-500;
cursor: pointer;
font-size: 0.8rem;
font-size: 0.75rem;
}
.menu-list {
&.can-be-hidden {
transition: all $transition;
height: 0;
//overflow: hidden;
opacity: 0;
}
li {
height: 44px;
display: flex;

View File

@ -120,6 +120,7 @@ export default {
this.success({message: 'The filter was saved successfully.'}, this)
this.filter = r
this.filters = objectToSnakeCase(this.filter.filters)
this.$router.back()
})
.catch(e => this.error(e, this))
},

View File

@ -117,6 +117,7 @@ export default {
.then(r => {
this.$store.commit('namespaces/setListInNamespaceById', r)
this.success({message: 'The list was successfully updated.'}, this)
this.$router.back()
})
.catch(e => {
this.error(e, this)

View File

@ -92,6 +92,7 @@
<div class="tasks-container">
<div :class="{'short': isTaskEdit}" class="tasks mt-0" v-if="tasks && tasks.length > 0">
<single-task-in-list
:show-list-color="false"
:disabled="!canWrite"
:key="t.id"
:the-task="t"

View File

@ -127,6 +127,7 @@ export default {
// Update the namespace in the parent
this.$store.commit('namespaces/setNamespaceById', r)
this.success({message: 'The namespace was successfully updated.'}, this)
this.$router.back()
})
.catch(e => {
this.error(e, this)

View File

@ -34,7 +34,7 @@
<x-button type="secondary" @click="setDatesToNextWeek()" class="mr-2">Next Week</x-button>
<x-button type="secondary" @click="setDatesToNextMonth()">Next Month</x-button>
</div>
<template v-if="!taskService.loading && (!hasUndoneTasks || !tasks || tasks.length === 0) && showNothingToDo">
<template v-if="!taskService.loading && (!tasks || tasks.length === 0) && showNothingToDo">
<h3 class="nothing">Nothing to do - Have a nice day!</h3>
<img alt="" src="/images/cool.svg"/>
</template>
@ -73,7 +73,6 @@ export default {
data() {
return {
tasks: [],
hasUndoneTasks: false,
taskService: TaskService,
showNulls: true,
showOverdue: false,
@ -139,7 +138,7 @@ export default {
const params = {
sort_by: ['due_date', 'id'],
order_by: ['asc', 'desc'],
order_by: ['desc', 'desc'],
filter_by: ['done'],
filter_value: [false],
filter_comparator: ['equals'],
@ -170,14 +169,19 @@ export default {
this.taskService.getAll({}, params)
.then(r => {
if (r.length > 0) {
for (const index in r) {
if (r[index].done !== true) {
this.hasUndoneTasks = true
}
}
}
this.$set(this, 'tasks', r.filter(t => !t.done))
// Sort all tasks to put those with a due date before the ones without a due date, the
// soonest before the later ones.
// We can't use the api sorting here because that sorts tasks with a due date after
// ones without a due date.
r.sort((a, b) => {
return a.dueDate === null && b.dueDate === null ? -1 : 1
})
const tasks = r.
filter(t => t.dueDate !== null).
concat(r.filter(t => t.dueDate === null))
this.$set(this, 'tasks', tasks)
this.$store.commit(HAS_TASKS, r.length > 0)
})
.catch(e => {

View File

@ -255,6 +255,12 @@
>
{{ task.done ? 'Mark as undone' : 'Done!' }}
</x-button>
<task-subscription
entity="task"
:entity-id="task.id"
:subscription="task.subscription"
@change="sub => task.subscription = sub"
/>
<x-button
@click="setFieldActive('assignees')"
@shortkey="setFieldActive('assignees')"
@ -422,10 +428,12 @@ import attachmentUpload from '../../components/tasks/mixins/attachmentUpload'
import heading from '@/components/tasks/partials/heading'
import Datepicker from '@/components/input/datepicker'
import {playPop} from '@/helpers/playPop'
import TaskSubscription from '@/components/misc/subscription'
export default {
name: 'TaskDetailView',
components: {
TaskSubscription,
Datepicker,
ColorPicker,
ListSearch,
@ -671,6 +679,7 @@ export default {
changeList(list) {
this.task.listId = list.id
this.saveTask()
this.$store.commit('kanban/removeTaskInBucket', this.task)
},
},
}

View File

@ -29,7 +29,6 @@
</template>
<script>
import router from '../../router'
import TeamModel from '../../models/team'
import TeamService from '../../services/team'
import CreateEdit from '@/components/misc/create-edit'
@ -64,7 +63,7 @@ export default {
this.teamService
.create(this.team)
.then((response) => {
router.push({
this.$router.push({
name: 'teams.edit',
params: { id: response.id },
})
@ -77,9 +76,6 @@ export default {
this.error(e, this)
})
},
back() {
router.go(-1)
},
},
}
</script>

View File

@ -2599,6 +2599,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-13.11.1.tgz#49a2a83df9d26daacead30d0ccc8762b128d53c7"
integrity sha512-eWQGP3qtxwL8FGneRrC5DwrJLGN4/dH1clNTuLfN81HCrxVtxRjygDTUoZJ5ASlDEeo0ppYFQjQIlXhtXpOn6g==
"@types/node@12.12.50":
version "12.12.50"
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.50.tgz#e9b2e85fafc15f2a8aa8fdd41091b983da5fd6ee"
integrity sha512-5ImO01Fb8YsEOYpV+aeyGYztcYcjGsBvN4D7G5r1ef2cuQOpymjWNQi5V0rKHE6PC2ru3HkoUr/Br2/8GUA84w==
"@types/node@^10.1.0":
version "10.17.19"
resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.19.tgz#1d31ddd5503dba2af7a901aafef3392e4955620e"
@ -6778,14 +6783,15 @@ cypress-file-upload@5.0.2:
dependencies:
mime "^2.5.0"
cypress@6.4.0:
version "6.4.0"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-6.4.0.tgz#432c516bf4f1a0f042a6aa1f2c3a4278fa35a8b2"
integrity sha512-SrsPsZ4IBterudkoFYBvkQmXOVxclh1/+ytbzpV8AH/D2FA+s2Qy5ISsaRzOFsbQa4KZWoi3AKwREmF1HucYkg==
cypress@6.5.0:
version "6.5.0"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-6.5.0.tgz#d853d7a8f915f894249a8788294bfba077278c17"
integrity sha512-ol/yTAqHrQQpYBjxLlRSvZf4DOb9AhaQNVlwdOZgJcBHZOOa52/p/6/p3PPcvzjWGOMG6Yq0z4G+jrbWyk/9Dg==
dependencies:
"@cypress/listr-verbose-renderer" "^0.4.1"
"@cypress/request" "^2.88.5"
"@cypress/xvfb" "^1.2.4"
"@types/node" "12.12.50"
"@types/sinonjs__fake-timers" "^6.0.1"
"@types/sizzle" "^2.3.2"
arch "^2.1.2"
@ -6798,7 +6804,7 @@ cypress@6.4.0:
commander "^5.1.0"
common-tags "^1.8.0"
dayjs "^1.9.3"
debug "^4.1.1"
debug "4.3.2"
eventemitter2 "^6.4.2"
execa "^4.0.2"
executable "^4.1.1"
@ -6871,6 +6877,13 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
dependencies:
ms "2.0.0"
debug@4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==
dependencies:
ms "2.1.2"
debug@^3.1.0, debug@^3.1.1, debug@^3.2.5, debug@^3.2.6:
version "3.2.6"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
@ -7568,15 +7581,15 @@ eslint-loader@^2.2.1:
object-hash "^1.1.4"
rimraf "^2.6.1"
eslint-plugin-vue@7.5.0:
version "7.5.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-7.5.0.tgz#cc6d983eb22781fa2440a7573cf39af439bb5725"
integrity sha512-QnMMTcyV8PLxBz7QQNAwISSEs6LYk2LJvGlxalXvpCtfKnqo7qcY0aZTIxPe8QOnHd7WCwiMZLOJzg6A03T0Gw==
eslint-plugin-vue@7.6.0:
version "7.6.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-7.6.0.tgz#ea616e6dfd45d545adb16cba628c5a992cc31f0b"
integrity sha512-qYpKwAvpcQXyUXVcG8Zd+fxHDx9iSgTQuO7dql7Ug/2BCvNNDr6s3I9p8MoUo23JJdO7ZAjW3vSwY/EBf4uBcw==
dependencies:
eslint-utils "^2.1.0"
natural-compare "^1.4.0"
semver "^7.3.2"
vue-eslint-parser "^7.4.1"
vue-eslint-parser "^7.5.0"
eslint-scope@^4.0.3:
version "4.0.3"
@ -7704,13 +7717,6 @@ esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0:
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
esquery@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708"
integrity sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==
dependencies:
estraverse "^4.0.0"
esquery@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.4.0.tgz#2148ffc38b82e8c7057dfed48425b3e61f0f24a5"
@ -7732,7 +7738,7 @@ esrecurse@^4.3.0:
dependencies:
estraverse "^5.2.0"
estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0:
estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
@ -11100,16 +11106,21 @@ lodash@4, lodash@^4.0.0, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, loda
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
lodash@4.17.20, lodash@^4.17.20, lodash@^4.17.4:
version "4.17.20"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
lodash@4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
lodash@^4.17.19:
version "4.17.19"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b"
integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==
lodash@^4.17.20:
version "4.17.20"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
log-symbols@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18"
@ -16081,16 +16092,16 @@ vue-easymde@1.3.2:
easymde "^2.13.0"
marked "^1.2.7"
vue-eslint-parser@^7.4.1:
version "7.4.1"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.4.1.tgz#e4adcf7876a7379758d9056a72235af18a587f92"
integrity sha512-AFvhdxpFvliYq1xt/biNBslTHE/zbEvSnr1qfHA/KxRIpErmEDrQZlQnvEexednRHmLfDNOMuDYwZL5xkLzIXQ==
vue-eslint-parser@^7.5.0:
version "7.5.0"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.5.0.tgz#b68221c55fee061899afcfb4441ec74c1495285e"
integrity sha512-6EHzl00hIpy4yWZo3qSbtvtVw1A1cTKOv1w95QSuAqGgk4113XtRjvNIiEGo49r0YWOPYsrmI4Dl64axL5Agrw==
dependencies:
debug "^4.1.1"
eslint-scope "^5.0.0"
eslint-visitor-keys "^1.1.0"
espree "^6.2.1"
esquery "^1.0.1"
esquery "^1.4.0"
lodash "^4.17.15"
vue-flatpickr-component@8.1.6: