Merge branch 'main' into pr-520-add-task-component

# Conflicts:
#	src/views/Home.vue
#	src/views/list/views/List.vue
This commit is contained in:
kolaente 2021-07-17 21:03:28 +02:00
commit 2c4829bc04
Signed by untrusted user: konrad
GPG Key ID: F40E70337AB24C9B
66 changed files with 2144 additions and 485 deletions

View File

@ -530,7 +530,7 @@ steps:
- failure
---
kind: pipeline
name: ping-weblate
name: update-translations
depends_on:
- build
@ -542,20 +542,40 @@ trigger:
- push
steps:
- name: update-translation-base
image: appleboy/drone-git-push
failure: ignore
# - name: download
# pull: always
# image: jonasfranz/crowdin
# settings:
# download: true
# export_dir: src/i18n/lang/
# ignore_branch: true
# project_identifier: vikunja
# environment:
# CROWDIN_KEY:
# from_secret: crowdin_key
#
# - name: push
# pull: always
# image: appleboy/drone-git-push
# settings:
# author_email: "frederik@vikunja.io"
# author_name: Frederick [Bot]
# branch: main
# commit: true
# commit_message: "[skip ci] Updated translations via Crowdin"
# remote: "git@kolaente.dev:9022/vikunja/frontend.git"
# environment:
# GIT_PUSH_SSH_KEY:
# from_secret: git_push_ssh_key
- name: upload
pull: always
image: jonasfranz/crowdin
settings:
branch: translations
remote: ssh://git@kolaente.dev:9022/vikunja/frontend.git
ssh_key:
from_secret: translations_branch_update_ssh_key
- name: notify-weblate
image: curlimages/curl
depends_on:
- update-translation-base
files:
en.json: src/i18n/lang/en.json
ignore_branch: true
project_identifier: vikunja
environment:
WEBLATE_TOKEN:
from_secret: weblate_token
commands:
- ./ping-weblate.sh
CROWDIN_KEY:
from_secret: crowdin_key

View File

@ -1,5 +1,5 @@
# Stage 1: Build application
FROM node:16.3.0 AS compile-image
FROM node:16 AS compile-image
WORKDIR /build

View File

@ -14,7 +14,6 @@ export class TaskFactory extends Factory {
done: false,
list_id: 1,
created_by_id: 1,
is_favorite: false,
index: '{increment}',
created: formatISO(now),
updated: formatISO(now)

View File

@ -13,7 +13,7 @@ export class UserFactory extends Factory {
id: '{increment}',
username: faker.lorem.word(10) + faker.random.uuid(),
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234
is_active: true,
status: 0,
created: formatISO(now),
updated: formatISO(now)
}

View File

@ -511,4 +511,34 @@ describe('Lists', () => {
.should('not.contain', task.title)
})
})
describe('List history', () => {
it('should show a list history on the home page', () => {
const lists = ListFactory.create(6)
cy.visit('/')
cy.get('h3')
.contains('Last viewed')
.should('not.exist')
cy.visit(`/lists/${lists[0].id}`)
cy.visit(`/lists/${lists[1].id}`)
cy.visit(`/lists/${lists[2].id}`)
cy.visit(`/lists/${lists[3].id}`)
cy.visit(`/lists/${lists[4].id}`)
cy.visit(`/lists/${lists[5].id}`)
cy.visit('/')
cy.get('h3')
.contains('Last viewed')
.should('exist')
cy.get('.list-cards-wrapper-2-rows')
.should('not.contain', lists[0].title)
.should('contain', lists[1].title)
.should('contain', lists[2].title)
.should('contain', lists[3].title)
.should('contain', lists[4].title)
.should('contain', lists[5].title)
})
})
})

View File

@ -0,0 +1,35 @@
import '../../support/authenticateUser'
const setHours = hours => {
const date = new Date()
date.setHours(hours)
cy.clock(+date)
}
describe('Home Page', () => {
it('shows the right salutation in the night', () => {
setHours(4)
cy.visit('/')
cy.get('h2').should('contain', 'Good Night')
})
it('shows the right salutation in the morning', () => {
setHours(8)
cy.visit('/')
cy.get('h2').should('contain', 'Good Morning')
})
it('shows the right salutation in the day', () => {
setHours(13)
cy.visit('/')
cy.get('h2').should('contain', 'Hi')
})
it('shows the right salutation in the night', () => {
setHours(20)
cy.visit('/')
cy.get('h2').should('contain', 'Good Evening')
})
it('shows the right salutation in the night again', () => {
setHours(23)
cy.visit('/')
cy.get('h2').should('contain', 'Good Night')
})
})

View File

@ -34,6 +34,7 @@ context('Login', () => {
cy.get('input[id=password]').type(fixture.password)
cy.get('.button').contains('Login').click()
cy.url().should('include', '/')
cy.clock(1625656161057) // 13:00
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
})

View File

@ -28,6 +28,7 @@ context('Registration', () => {
cy.get('#password2').type(fixture.password)
cy.get('#register-submit').click()
cy.url().should('include', '/')
cy.clock(1625656161057) // 13:00
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
})

View File

@ -18,19 +18,19 @@
"camel-case": "4.1.2",
"copy-to-clipboard": "3.3.1",
"date-fns": "2.22.1",
"dompurify": "2.2.9",
"highlight.js": "11.0.1",
"dompurify": "2.3.0",
"highlight.js": "11.1.0",
"lodash": "4.17.21",
"marked": "2.1.3",
"register-service-worker": "1.7.2",
"sass": "1.35.1",
"sass": "1.35.2",
"snake-case": "3.0.4",
"verte": "0.0.12",
"vue": "2.6.14",
"vue-advanced-cropper": "1.7.0",
"vue-drag-resize": "1.5.4",
"vue-easymde": "1.4.0",
"vue-i18n": "8.24.5",
"vue-i18n": "8.25.0",
"vue-shortkey": "3.1.7",
"vue-smooth-dnd": "0.8.1",
"vuex": "3.6.2"
@ -47,14 +47,14 @@
"@vue/cli-service": "4.5.13",
"axios": "0.21.1",
"babel-eslint": "10.1.0",
"cypress": "7.6.0",
"cypress": "7.7.0",
"cypress-file-upload": "5.0.8",
"eslint": "7.29.0",
"eslint-plugin-vue": "7.12.1",
"eslint": "7.30.0",
"eslint-plugin-vue": "7.13.0",
"faker": "5.5.3",
"jest": "27.0.6",
"sass-loader": "10.2.0",
"vue-flatpickr-component": "8.1.6",
"vue-flatpickr-component": "8.1.7",
"vue-notification": "1.3.20",
"vue-router": "3.5.2",
"vue-template-compiler": "2.6.14",
@ -109,6 +109,7 @@
"jest": {
"testPathIgnorePatterns": [
"cypress"
]
],
"testEnvironment": "jsdom"
}
}

View File

@ -13,14 +13,6 @@
{{ currentList.title === '' ? $t('misc.loading') : currentList.title }}
</h1>
<div class="box has-text-left view">
<div class="logout">
<x-button @click="logout()" type="secondary">
<span>{{ $t('user.auth.logout') }}</span>
<span class="icon is-small">
<icon icon="sign-out-alt"/>
</span>
</x-button>
</div>
<router-view/>
<a class="menu-bottom-link" href="https://vikunja.io" target="_blank">
{{ $t('misc.poweredBy') }}

View File

@ -54,14 +54,14 @@
<span
@click="toggleLists(n.id)"
class="menu-label"
v-tooltip="n.title + ' (' + n.lists.filter(l => !l.isArchived).length + ')'">
v-tooltip="getNamespaceTitle(n) + ' (' + n.lists.filter(l => !l.isArchived).length + ')'">
<span class="name">
<span
:style="{ backgroundColor: n.hexColor }"
class="color-bubble"
v-if="n.hexColor !== ''">
</span>
{{ n.title }} ({{ n.lists.filter(l => !l.isArchived).length }})
{{ getNamespaceTitle(n) }} ({{ n.lists.filter(l => !l.isArchived).length }})
</span>
</span>
<a
@ -91,7 +91,7 @@
v-if="l.hexColor !== ''">
</span>
<span class="list-menu-title">
{{ l.title }}
{{ getListTitle(l) }}
</span>
<span
:class="{'is-favorite': l.isFavorite}"

View File

@ -30,7 +30,7 @@
<h1
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
class="title">
{{ currentList.title === '' ? $t('misc.loading') : currentList.title }}
{{ currentList.title === '' ? $t('misc.loading') : getListTitle(currentList) }}
</h1>
<list-settings-dropdown v-if="canWriteCurrentList && currentList.id !== -1" :list="currentList"/>
@ -82,6 +82,9 @@
<a @click="$store.commit('keyboardShortcutsActive', true)" class="dropdown-item">
{{ $t('keyboardShortcuts.title') }}
</a>
<router-link :to="{name: 'about'}" class="dropdown-item">
{{ $t('about.title') }}
</router-link>
<a @click="logout()" class="dropdown-item">
{{ $t('user.auth.logout') }}
</a>

View File

@ -1,7 +1,7 @@
<template>
<div :class="{'is-pulled-up': isEditEnabled}" class="editor">
<div class="is-pulled-right mb-4" v-if="hasPreview && isEditEnabled && !hasEditBottom">
<x-button
<x-button
v-if="!isEditActive"
@click="toggleEdit"
:shadow="false"
@ -9,8 +9,8 @@
>
<icon icon="pen"/>
</x-button>
<x-button
v-else
<x-button
v-else
@click="toggleEdit"
:shadow="false"
type="secondary"
@ -35,15 +35,15 @@
<p class="has-text-centered has-text-grey is-italic" v-if="isPreviewActive && text === '' && emptyText !== ''">
{{ emptyText }}
<template v-if="isEditEnabled">
<a @click="toggleEdit">Edit</a>.
<a @click="toggleEdit">{{ $t('input.editor.edit') }}</a>.
</template>
</p>
<ul class="actions">
<template v-if="hasEditBottom && isEditEnabled">
<li>
<a v-if="!isEditActive" @click="toggleEdit">Edit</a>
<a v-else @click="toggleEdit">Done</a>
<a v-if="!isEditActive" @click="toggleEdit">{{ $t('input.editor.edit') }}</a>
<a v-else @click="toggleEdit">{{ $t('input.editor.done') }}</a>
</li>
</template>
<li v-for="(action, k) in bottomActions" :key="k">
@ -117,6 +117,7 @@ export default {
preview: '',
attachmentService: null,
loadedAttachments: {},
config: {
autoDownloadFontAwesome: false,
@ -284,7 +285,7 @@ export default {
// that in the end, only one change event is triggered to the outside per change.
handleInput(val) {
// Don't bubble if the text is up to date
if(val === this.text) {
if (val === this.text) {
return
}
@ -375,7 +376,7 @@ export default {
},
})
this.preview = DOMPurify.sanitize(marked(this.text), { ADD_ATTR: ['target'] })
this.preview = DOMPurify.sanitize(marked(this.text), {ADD_ATTR: ['target']})
// Since the render function is synchronous, we can't do async http requests in it.
// Therefore, we can't resolve the blob url at (markdown) compile time.
@ -392,6 +393,13 @@ export default {
const parts = img.dataset.src.substr(window.API_URL.length + 1).split('/')
const taskId = parseInt(parts[1])
const attachmentId = parseInt(parts[3])
const cacheKey = `${taskId}-${attachmentId}`
if (typeof this.loadedAttachments[cacheKey] !== 'undefined') {
img.src = this.loadedAttachments[cacheKey]
continue
}
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
if (this.attachmentService === null) {
@ -401,6 +409,7 @@ export default {
this.attachmentService.getBlobUrl(attachment)
.then(url => {
img.src = url
this.loadedAttachments[cacheKey] = url
})
}
}

View File

@ -130,7 +130,7 @@
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.label') }}</label>
<label class="label">{{ $t('task.attributes.labels') }}</label>
<div class="control">
<multiselect
:placeholder="$t('label.search')"

View File

@ -0,0 +1,89 @@
<template>
<router-link
:class="{
'has-light-text': !colorIsDark(list.hexColor),
'has-background': background !== null
}"
:style="{
'background-color': list.hexColor,
'background-image': background !== null ? `url(${background})` : false,
}"
:to="{ name: 'list.index', params: { listId: list.id} }"
class="list-card"
tag="span"
v-if="list !== null && (showArchived ? true : !list.isArchived)"
>
<div class="is-archived-container">
<span class="is-archived" v-if="list.isArchived">
{{ $t('namespace.archived') }}
</span>
<span
:class="{'is-favorite': list.isFavorite, 'is-archived': list.isArchived}"
@click.stop="toggleFavoriteList(list)"
class="favorite">
<icon icon="star" v-if="list.isFavorite"/>
<icon :icon="['far', 'star']" v-else/>
</span>
</div>
<div class="title">{{ list.title }}</div>
</router-link>
</template>
<script>
import ListService from '@/services/list'
export default {
name: 'list-card',
data() {
return {
background: null,
backgroundLoading: false,
}
},
props: {
list: {
required: true,
},
showArchived: {
default: false,
type: Boolean,
},
},
watch: {
list() {
this.loadBackground()
},
},
created() {
this.loadBackground()
},
methods: {
loadBackground() {
if (this.list === null || !this.list.backgroundInformation || this.backgroundLoading) {
return
}
this.backgroundLoading = true
const listService = new ListService()
listService.background(this.list)
.then(b => {
this.$set(this, 'background', b)
})
.catch(e => {
this.error(e)
})
.finally(() => this.backgroundLoading = false)
},
toggleFavoriteList(list) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
return
}
this.$store.dispatch('lists/toggleListFavorite', list)
.catch(e => this.error(e))
},
},
}
</script>

View File

@ -1,5 +1,5 @@
<template>
<div class="modal-mask keyboard-shortcuts-modal">
<div class="modal-mask hint-modal">
<div @click.self="close()" class="modal-container">
<div class="modal-content">
<card class="has-background-white has-no-shadow" :title="$t('keyboardShortcuts.title')">

View File

@ -1,7 +1,7 @@
<template>
<transition name="modal">
<div class="modal-mask">
<div class="modal-container" @mousedown.self.prevent.stop="$emit('close')">
<div class="modal-mask has-overflow" :class="{'has-overflow': overflow}">
<div class="modal-container" @mousedown.self.prevent.stop="$emit('close')" :class="{'has-overflow': overflow}">
<div class="modal-content" :class="{'has-overflow': overflow, 'is-wide': wide}">
<slot>
<div class="header">

View File

@ -9,7 +9,7 @@
<transition name="fade">
<div class="notifications-list" v-if="showNotifications" ref="popup">
<span class="head">Notifications</span>
<span class="head">{{ $t('notification.title') }}</span>
<div
v-for="(n, index) in notifications"
:key="n.id"

View File

@ -1,5 +1,5 @@
<template>
<modal v-if="active" class="quick-actions" @close="closeQuickActions">
<modal v-if="active" class="quick-actions" @close="closeQuickActions" :overflow="isNewTaskCommand">
<div class="card">
<div class="action-input" :class="{'has-active-cmd': selectedCmd !== null}">
<div class="active-cmd tag" v-if="selectedCmd !== null">
@ -20,10 +20,12 @@
/>
</div>
<div class="help has-text-grey-light p-2" v-if="hintText !== ''">
<div class="help has-text-grey-light p-2" v-if="hintText !== '' && !isNewTaskCommand">
{{ hintText }}
</div>
<quick-add-magic class="p-2 modal-container-smaller" v-if="isNewTaskCommand"/>
<div class="results" v-if="selectedCmd === null">
<div v-for="(r, k) in results" :key="k" class="result">
<span class="result-title">
@ -39,6 +41,7 @@
@click.prevent.stop="() => doAction(r.type, i)"
@keyup.prevent.enter="() => doAction(r.type, i)"
@keyup.prevent.esc="() => $refs.searchInput.focus()"
:class="{'is-strikethrough': i.done}"
>
{{ i.title }}
</button>
@ -53,12 +56,14 @@
import TaskService from '@/services/task'
import TeamService from '@/services/team'
import TaskModel from '@/models/task'
import NamespaceModel from '@/models/namespace'
import TeamModel from '@/models/team'
import {CURRENT_LIST, LOADING, LOADING_MODULE, QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
import ListModel from '@/models/list'
import createTask from '@/components/tasks/mixins/createTask'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic'
import {getHistory} from '@/modules/listHistory'
const TYPE_LIST = 'list'
const TYPE_TASK = 'task'
@ -77,6 +82,7 @@ const SEARCH_MODE_TEAMS = 'teams'
export default {
name: 'quick-actions',
components: {QuickAddMagic},
data() {
return {
query: '',
@ -91,6 +97,9 @@ export default {
teamService: null,
}
},
mixins: [
createTask,
],
computed: {
active() {
const active = this.$store.state[QUICK_ACTIONS_ACTIVE]
@ -107,7 +116,33 @@ export default {
query = query.substr(1)
}
lists = (Object.values(this.$store.state.lists).filter(l => {
const ncache = {}
const history = getHistory()
// Puts recently visited lists at the top
const allLists = [...new Set([
...history.map(l => {
return this.$store.getters['lists/getListById'](l.id)
}),
...Object.values(this.$store.state.lists)])]
lists = (allLists.filter(l => {
if (typeof l === 'undefined') {
return false
}
if (l.isArchived) {
return false
}
if (typeof ncache[l.namespaceId] === 'undefined') {
ncache[l.namespaceId] = this.$store.getters['namespaces/getNamespaceById'](l.namespaceId)
}
if (ncache[l.namespaceId].isArchived) {
return false
}
return l.title.toLowerCase().includes(query.toLowerCase())
}) ?? [])
}
@ -222,6 +257,9 @@ export default {
return SEARCH_MODE_ALL
},
isNewTaskCommand() {
return this.selectedCmd !== null && this.selectedCmd.action === CMD_NEW_TASK
},
},
created() {
this.taskService = new TaskService()
@ -348,11 +386,7 @@ export default {
return
}
const newTask = new TaskModel({
title: this.query,
listId: this.currentList.id,
})
this.taskService.create(newTask)
this.createNewTask(this.query, 0, this.currentList.id)
.then(r => {
this.success({message: this.$t('task.createSuccess')})
this.$router.push({name: 'task.detail', params: {id: r.id}})

View File

@ -62,7 +62,7 @@
id="linkSharePassword"
type="password"
class="input"
:placeholder="$t('user.auth.passwortPlaceholder')"
:placeholder="$t('user.auth.passwordPlaceholder')"
v-tooltip="$t('list.share.links.passwordExplanation')"
v-model="password"
/>

View File

@ -1,5 +1,6 @@
import AttachmentModel from '../../../models/attachment'
import AttachmentService from '../../../services/attachment'
import {generateAttachmentUrl} from '@/helpers/generateAttachmentUrl'
export default {
methods: {
@ -21,7 +22,7 @@ export default {
taskId: this.taskId,
attachment: a,
})
onSuccess(`${window.API_URL}/tasks/${this.taskId}/attachments/${a.id}`)
onSuccess(generateAttachmentUrl(this.taskId, a.id))
})
}
if (r.errors !== null) {

View File

@ -0,0 +1,124 @@
import {parseTaskText} from '@/helpers/parseTaskText'
import TaskModel from '@/models/task'
import {formatISO} from 'date-fns'
import LabelTask from '@/models/labelTask'
import LabelModel from '@/models/label'
import LabelTaskService from '@/services/labelTask'
import {mapState} from 'vuex'
import UserService from '@/services/user'
export default {
data() {
return {
labelTaskService: LabelTaskService,
userService: UserService,
}
},
created() {
this.labelTaskService = new LabelTaskService()
this.userService = new UserService()
},
computed: mapState({
labels: state => state.labels.labels,
}),
methods: {
createNewTask(newTaskTitle, bucketId = 0, lId = 0) {
const parsedTask = parseTaskText(newTaskTitle)
const assignees = []
let listId = null
if (parsedTask.list !== null) {
const list = this.$store.getters['lists/findListByExactname'](parsedTask.list)
listId = list === null ? null : list.id
}
if (listId === null) {
listId = lId !== 0 ? lId : this.$route.params.listId
}
// Separate closure because we need to wait for the results of the user search if users were entered in the
// task create request. Because _that_ happens in a promise, we'll need something to call when it resolves.
const createTask = () => {
const task = new TaskModel({
title: parsedTask.text,
listId: listId,
dueDate: parsedTask.date !== null ? formatISO(parsedTask.date) : null, // I don't know why, but it all goes up in flames when I just pass in the date normally.
priority: parsedTask.priority,
assignees: assignees,
bucketId: bucketId,
})
return this.taskService.create(task)
.then(task => {
if (parsedTask.labels.length > 0) {
const labelAddsToWaitFor = []
const addLabelToTask = label => {
const labelTask = new LabelTask({
taskId: task.id,
labelId: label.id,
})
return this.labelTaskService.create(labelTask)
.then(result => {
task.labels.push(label)
return Promise.resolve(result)
})
.catch(e => Promise.reject(e))
}
// Then do everything that is involved in finding, creating and adding the label to the task
parsedTask.labels.forEach(labelTitle => {
// Check if the label exists
const label = Object.values(this.labels).find(l => {
return l.title.toLowerCase() === labelTitle.toLowerCase()
})
// Label found, use it
if (typeof label !== 'undefined') {
labelAddsToWaitFor.push(addLabelToTask(label))
} else {
// label not found, create it
const label = new LabelModel({title: labelTitle})
labelAddsToWaitFor.push(this.$store.dispatch('labels/createLabel', label)
.then(res => {
return addLabelToTask(res)
})
.catch(e => Promise.reject(e))
)
}
})
// This waits until all labels are created and added to the task
return Promise.all(labelAddsToWaitFor)
.then(() => {
return Promise.resolve(task)
})
}
return Promise.resolve(task)
})
.catch(e => Promise.reject(e))
}
if (parsedTask.assignees.length > 0) {
const searches = []
parsedTask.assignees.forEach(a => {
searches.push(this.userService.getAll({}, {s: a})
.then(users => {
const user = users.find(u => u.username.toLowerCase() === a.toLowerCase())
if (typeof user !== 'undefined') {
assignees.push(user)
}
return Promise.resolve(users)
})
)
})
return Promise.all(searches)
.then(() => createTask())
}
return createTask()
},
},
}

View File

@ -55,14 +55,20 @@
<p>
<a
@click.prevent.stop="downloadAttachment(a)"
v-tooltip="'Download this attachment'"
v-tooltip="$t('task.attachment.downloadTooltip')"
>
{{ $t('task.attachment.download') }}
</a>
<a
@click.stop="copyUrl(a)"
v-tooltip="$t('task.attachment.copyUrlTooltip')"
>
{{ $t('task.attachment.copyUrl') }}
</a>
<a
@click.prevent.stop="() => {attachmentToDelete = a; showDeleteModal = true}"
v-if="editEnabled"
v-tooltip="'Delete this attachment'"
v-tooltip="$t('task.attachment.deleteTooltip')"
>
{{ $t('misc.delete') }}
</a>
@ -106,7 +112,7 @@
>
<span slot="header">{{ $t('task.attachment.delete') }}</span>
<p slot="text">
{{ $t('task.attachment.deleteText1', {filename: attachmentUpload.file.name}) }}<br/>
{{ $t('task.attachment.deleteText1', {filename: attachmentToDelete.file.name}) }}<br/>
<strong>{{ $t('task.attachment.deleteText2') }}</strong>
</p>
</modal>
@ -133,7 +139,9 @@ import AttachmentService from '../../../services/attachment'
import AttachmentModel from '../../../models/attachment'
import User from '../../misc/user'
import attachmentUpload from '@/components/tasks/mixins/attachmentUpload'
import {generateAttachmentUrl} from '@/helpers/generateAttachmentUrl'
import {mapState} from 'vuex'
import copy from 'copy-to-clipboard'
export default {
name: 'attachments',
@ -247,6 +255,9 @@ export default {
this.downloadAttachment(attachment)
}
},
copyUrl(attachment) {
copy(generateAttachmentUrl(this.taskId, attachment.id))
},
},
}
</script>

View File

@ -0,0 +1,82 @@
<template>
<div>
<p class="help has-text-grey">
{{ $t('task.quickAddMagic.hint') }}.
<a @click="() => visible = true">{{ $t('task.quickAddMagic.what') }}</a>
</p>
<transition name="fade">
<div class="modal-mask hint-modal" v-if="visible">
<div @click.self="() => visible = false" class="modal-container">
<div class="modal-content">
<card class="has-background-white has-no-shadow" :title="$t('task.quickAddMagic.title')">
<p>{{ $t('task.quickAddMagic.intro') }}</p>
<h3>{{ $t('task.attributes.labels') }}</h3>
<p>
{{ $t('task.quickAddMagic.label1', {prefix: '@'}) }}
{{ $t('task.quickAddMagic.label2') }}
{{ $t('task.quickAddMagic.multiple') }}
</p>
<p>
{{ $t('task.quickAddMagic.label3') }}
{{ $t('task.quickAddMagic.label4', {prefix: '@'}) }}
</p>
<h3>{{ $t('task.attributes.priority') }}</h3>
<p>
{{ $t('task.quickAddMagic.priority1', {prefix: '!'}) }}
{{ $t('task.quickAddMagic.priority2') }}
</p>
<h3>{{ $t('task.attributes.assignees') }}</h3>
<p>
{{ $t('task.quickAddMagic.assignees') }}
{{ $t('task.quickAddMagic.multiple') }}
</p>
<h3>{{ $t('list.list.title') }}</h3>
<p>
{{ $t('task.quickAddMagic.list1', {prefix: '#'}) }}
{{ $t('task.quickAddMagic.list2') }}
</p>
<h3>{{ $t('task.quickAddMagic.dateAndTime') }}</h3>
<p>
{{ $t('task.quickAddMagic.date') }}
</p>
<ul>
<!-- Not localized because these only work in english -->
<li>Today</li>
<li>Tomorrow</li>
<li>Next monday</li>
<li>This weekend</li>
<li>Later this week</li>
<li>Later next week</li>
<li>Next week</li>
<li>Next month</li>
<li>End of month</li>
<li>In 5 days [hours/weeks/months]</li>
<li>Tuesday ({{ $t('task.quickAddMagic.dateWeekday') }})</li>
<li>17/02/2021</li>
<li>Feb 17 ({{ $t('task.quickAddMagic.dateCurrentYear') }})</li>
<li>17th ({{ $t('task.quickAddMagic.dateNth', {day: '17'}) }})</li>
</ul>
<p>{{ $t('task.quickAddMagic.dateTime', {time: 'at 17:00', timePM: '5pm'}) }}</p>
</card>
</div>
</div>
</div>
</transition>
</div>
</template>
<script>
export default {
name: 'quick-add-magic',
data() {
return {
visible: false,
}
},
}
</script>

62
src/helpers/auth.js Normal file
View File

@ -0,0 +1,62 @@
import {HTTPFactory} from '@/http-common'
let savedToken = null
let persisted = false
/**
* Saves a token while optionally saving it to lacal storage. This is used when viewing a link share:
* It enables viewing multiple link shares indipendently from each in multiple tabs other without overriding any other open ones.
* @param token
* @param persist
*/
export const saveToken = (token, persist = true) => {
savedToken = token
if (persist) {
persisted = true
localStorage.setItem('token', token)
}
}
/**
* Returns a saved token. If there is one saved in memory it will use that before anything else.
* @returns {string|null}
*/
export const getToken = () => {
if (savedToken !== null) {
return savedToken
}
savedToken = localStorage.getItem('token')
return savedToken
}
/**
* Removes all tokens everywhere.
*/
export const removeToken = () => {
savedToken = null
localStorage.removeItem('token')
}
/**
* Refreshes an auth token while ensuring it is updated everywhere.
* @returns {Promise<AxiosResponse<any>>}
*/
export const refreshToken = () => {
const HTTP = HTTPFactory()
return HTTP.post('user/token', null, {
headers: {
Authorization: `Bearer ${getToken()}`,
},
})
.then(r => {
saveToken(r.data.token, persisted)
return Promise.resolve(r)
})
.catch(e => {
// eslint-disable-next-line
console.log('Error renewing token: ', e)
return Promise.reject(e)
})
}

View File

@ -1,4 +1,8 @@
export const colorIsDark = color => {
if (typeof color === 'undefined') {
return true // Defaults to dark
}
if (color === '#' || color === '') {
return true // Defaults to dark
}

View File

@ -0,0 +1,3 @@
export const generateAttachmentUrl = (taskId, attachmentId) => {
return `${window.API_URL}/tasks/${taskId}/attachments/${attachmentId}`
}

View File

@ -0,0 +1,6 @@
export const getListTitle = (l, $t) => {
if (l.id === -1) {
return $t('list.pseudo.favorites.title');
}
return l.title;
}

View File

@ -0,0 +1,12 @@
export const getNamespaceTitle = (n, $t) => {
if (n.id === -1) {
return $t('namespace.pseudo.sharedLists.title');
}
if (n.id === -2) {
return $t('namespace.pseudo.favorites.title');
}
if (n.id === -3) {
return $t('namespace.pseudo.savedFilters.title');
}
return n.title;
}

View File

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

View File

@ -0,0 +1,103 @@
import {parseDate} from './time/parseDate'
import priorities from '../models/priorities.json'
const LABEL_PREFIX = '@'
const LIST_PREFIX = '#'
const PRIORITY_PREFIX = '!'
const ASSIGNEE_PREFIX = '+'
/**
* Parses task text for dates, assignees, labels, lists, priorities and returns an object with all found intents.
*
* @param text
*/
export const parseTaskText = text => {
const result = {
text: text,
date: null,
labels: [],
list: null,
priority: null,
assignees: [],
}
result.labels = getItemsFromPrefix(text, LABEL_PREFIX)
const lists = getItemsFromPrefix(text, LIST_PREFIX)
result.list = lists.length > 0 ? lists[0] : null
result.priority = getPriority(text)
result.assignees = getItemsFromPrefix(text, ASSIGNEE_PREFIX)
const {newText, date} = parseDate(text)
result.text = newText
result.date = date
return cleanupResult(result)
}
const getItemsFromPrefix = (text, prefix) => {
const items = []
const itemParts = text.split(prefix)
itemParts.forEach((p, index) => {
// First part contains the rest
if (index < 1) {
return
}
let labelText
if (p.charAt(0) === `'`) {
labelText = p.split(`'`)[1]
} else if (p.charAt(0) === `"`) {
labelText = p.split(`"`)[1]
} else {
// Only until the next space
labelText = p.split(' ')[0]
}
items.push(labelText)
})
return Array.from(new Set(items))
}
const getPriority = text => {
const ps = getItemsFromPrefix(text, PRIORITY_PREFIX)
if (ps.length === 0) {
return null
}
for (const p of ps) {
for (const pi in priorities) {
if (priorities[pi] === parseInt(p)) {
return parseInt(p)
}
}
}
return null
}
const cleanupItemText = (text, items, prefix) => {
items.forEach(l => {
text = text
.replace(`${prefix}'${l}' `, '')
.replace(`${prefix}'${l}'`, '')
.replace(`${prefix}"${l}" `, '')
.replace(`${prefix}"${l}"`, '')
.replace(`${prefix}${l} `, '')
.replace(`${prefix}${l}`, '')
})
return text
}
const cleanupResult = result => {
result.text = cleanupItemText(result.text, result.labels, LABEL_PREFIX)
result.text = cleanupItemText(result.text, [result.list], LIST_PREFIX)
result.text = cleanupItemText(result.text, [result.priority], PRIORITY_PREFIX)
result.text = cleanupItemText(result.text, result.assignees, ASSIGNEE_PREFIX)
result.text = result.text.trim()
return result
}

View File

@ -0,0 +1,407 @@
import {parseTaskText} from './parseTaskText'
import {getDateFromText, getDateFromTextIn} from './time/parseDate'
import {calculateDayInterval} from './time/calculateDayInterval'
import priorities from '../models/priorities.json'
describe('Parse Task Text', () => {
it('should return text with no intents as is', () => {
expect(parseTaskText('Lorem Ipsum').text).toBe('Lorem Ipsum')
})
describe('Date Parsing', () => {
it('should not return any date if none was provided', () => {
const result = parseTaskText('Lorem Ipsum')
expect(result.text).toBe('Lorem Ipsum')
expect(result.date).toBeNull()
})
it('should ignore casing', () => {
const result = parseTaskText('Lorem Ipsum ToDay')
expect(result.text).toBe('Lorem Ipsum')
const now = new Date()
expect(result.date.getFullYear()).toBe(now.getFullYear())
expect(result.date.getMonth()).toBe(now.getMonth())
expect(result.date.getDate()).toBe(now.getDate())
})
it('should recognize today', () => {
const result = parseTaskText('Lorem Ipsum today')
expect(result.text).toBe('Lorem Ipsum')
const now = new Date()
expect(result.date.getFullYear()).toBe(now.getFullYear())
expect(result.date.getMonth()).toBe(now.getMonth())
expect(result.date.getDate()).toBe(now.getDate())
})
describe('should recognize today with a time', () => {
const cases = {
'at 15:00': '15:0',
'@ 15:00': '15:0',
'at 15:30': '15:30',
'@ 3pm': '15:0',
'at 3pm': '15:0',
'at 3 pm': '15:0',
'at 3am': '3:0',
'at 3:12 am': '3:12',
'at 3:12 pm': '15:12',
}
for (const c in cases) {
it('should recognize today with a time ' + c, () => {
const result = parseTaskText('Lorem Ipsum today ' + c)
expect(result.text).toBe('Lorem Ipsum')
const now = new Date()
expect(result.date.getFullYear()).toBe(now.getFullYear())
expect(result.date.getMonth()).toBe(now.getMonth())
expect(result.date.getDate()).toBe(now.getDate())
expect(`${result.date.getHours()}:${result.date.getMinutes()}`).toBe(cases[c])
expect(result.date.getSeconds()).toBe(0)
})
}
})
it('should recognize tomorrow', () => {
const result = parseTaskText('Lorem Ipsum tomorrow')
expect(result.text).toBe('Lorem Ipsum')
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
expect(result.date.getFullYear()).toBe(tomorrow.getFullYear())
expect(result.date.getMonth()).toBe(tomorrow.getMonth())
expect(result.date.getDate()).toBe(tomorrow.getDate())
})
it('should recognize next monday', () => {
const result = parseTaskText('Lorem Ipsum next monday')
const untilNextMonday = calculateDayInterval('nextMonday')
expect(result.text).toBe('Lorem Ipsum')
const nextMonday = new Date()
nextMonday.setDate(nextMonday.getDate() + untilNextMonday)
expect(result.date.getFullYear()).toBe(nextMonday.getFullYear())
expect(result.date.getMonth()).toBe(nextMonday.getMonth())
expect(result.date.getDate()).toBe(nextMonday.getDate())
})
it('should recognize this weekend', () => {
const result = parseTaskText('Lorem Ipsum this weekend')
const untilThisWeekend = calculateDayInterval('thisWeekend')
expect(result.text).toBe('Lorem Ipsum')
const thisWeekend = new Date()
thisWeekend.setDate(thisWeekend.getDate() + untilThisWeekend)
expect(result.date.getFullYear()).toBe(thisWeekend.getFullYear())
expect(result.date.getMonth()).toBe(thisWeekend.getMonth())
expect(result.date.getDate()).toBe(thisWeekend.getDate())
})
it('should recognize later this week', () => {
const result = parseTaskText('Lorem Ipsum later this week')
const untilLaterThisWeek = calculateDayInterval('laterThisWeek')
expect(result.text).toBe('Lorem Ipsum')
const laterThisWeek = new Date()
laterThisWeek.setDate(laterThisWeek.getDate() + untilLaterThisWeek)
expect(result.date.getFullYear()).toBe(laterThisWeek.getFullYear())
expect(result.date.getMonth()).toBe(laterThisWeek.getMonth())
expect(result.date.getDate()).toBe(laterThisWeek.getDate())
})
it('should recognize later next week', () => {
const result = parseTaskText('Lorem Ipsum later next week')
const untilLaterNextWeek = calculateDayInterval('laterNextWeek')
expect(result.text).toBe('Lorem Ipsum')
const laterNextWeek = new Date()
laterNextWeek.setDate(laterNextWeek.getDate() + untilLaterNextWeek)
expect(result.date.getFullYear()).toBe(laterNextWeek.getFullYear())
expect(result.date.getMonth()).toBe(laterNextWeek.getMonth())
expect(result.date.getDate()).toBe(laterNextWeek.getDate())
})
it('should recognize next week', () => {
const result = parseTaskText('Lorem Ipsum next week')
const untilNextWeek = calculateDayInterval('nextWeek')
expect(result.text).toBe('Lorem Ipsum')
const nextWeek = new Date()
nextWeek.setDate(nextWeek.getDate() + untilNextWeek)
expect(result.date.getFullYear()).toBe(nextWeek.getFullYear())
expect(result.date.getMonth()).toBe(nextWeek.getMonth())
expect(result.date.getDate()).toBe(nextWeek.getDate())
})
it('should recognize next month', () => {
const result = parseTaskText('Lorem Ipsum next month')
expect(result.text).toBe('Lorem Ipsum')
const nextMonth = new Date()
nextMonth.setDate(1)
nextMonth.setMonth(nextMonth.getMonth() + 1)
expect(result.date.getFullYear()).toBe(nextMonth.getFullYear())
expect(result.date.getMonth()).toBe(nextMonth.getMonth())
expect(result.date.getDate()).toBe(nextMonth.getDate())
})
it('should recognize a date', () => {
const result = parseTaskText('Lorem Ipsum 06/26/2021')
expect(result.text).toBe('Lorem Ipsum')
const date = new Date()
date.setFullYear(2021, 5, 26)
expect(result.date.getFullYear()).toBe(date.getFullYear())
expect(result.date.getMonth()).toBe(date.getMonth())
expect(result.date.getDate()).toBe(date.getDate())
})
it('should recognize end of month', () => {
const result = parseTaskText('Lorem Ipsum end of month')
expect(result.text).toBe('Lorem Ipsum')
const curDate = new Date()
const date = new Date(curDate.getFullYear(), curDate.getMonth() + 1, 0)
expect(result.date.getFullYear()).toBe(date.getFullYear())
expect(result.date.getMonth()).toBe(date.getMonth())
expect(result.date.getDate()).toBe(date.getDate())
})
it('should recognize weekdays', () => {
const result = parseTaskText('Lorem Ipsum thu')
expect(result.text).toBe('Lorem Ipsum')
const nextThursday = new Date()
nextThursday.setDate(nextThursday.getDate() + ((4 + 7 - nextThursday.getDay()) % 7))
expect(`${result.date.getFullYear()}-${result.date.getMonth()}-${result.date.getDate()}`).toBe(`${nextThursday.getFullYear()}-${nextThursday.getMonth()}-${nextThursday.getDate()}`)
})
it('should recognize weekdays with time', () => {
const result = parseTaskText('Lorem Ipsum thu at 14:00')
expect(result.text).toBe('Lorem Ipsum')
const nextThursday = new Date()
nextThursday.setDate(nextThursday.getDate() + ((4 + 7 - nextThursday.getDay()) % 7))
expect(`${result.date.getFullYear()}-${result.date.getMonth()}-${result.date.getDate()}`).toBe(`${nextThursday.getFullYear()}-${nextThursday.getMonth()}-${nextThursday.getDate()}`)
expect(`${result.date.getHours()}:${result.date.getMinutes()}`).toBe('14:0')
})
it('should recognize dates of the month in the past but next month', () => {
const date = new Date()
date.setDate(date.getDate() - 1)
const result = parseTaskText(`Lorem Ipsum ${date.getDate()}nd`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.date.getDate()).toBe(date.getDate())
expect(result.date.getMonth()).toBe(date.getMonth() + 1)
})
it('should recognize dates of the month in the future', () => {
const date = new Date()
const result = parseTaskText(`Lorem Ipsum ${date.getDate() + 1}nd`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.date.getDate()).toBe(date.getDate() + 1)
})
describe('Parse date from text', () => {
const now = new Date()
now.setFullYear(2021, 5, 24)
const cases = {
'Lorem Ipsum 06/08/2021 ad': '2021-6-8',
'Lorem Ipsum 6/7/21 ad': '2021-6-7',
'27/07/2021,': null,
'2021/07/06,': '2021-7-6',
'2021-07-06': '2021-7-6',
'27 jan': '2022-1-27',
'27/1': '2022-1-27',
'27/01': '2022-1-27',
'16/12': '2021-12-16',
'01/27': '2022-1-27',
'1/27': '2022-1-27',
'Jan 27': '2022-1-27',
'jan 27': '2022-1-27',
'feb 21': '2022-2-21',
'mar 21': '2022-3-21',
'apr 21': '2022-4-21',
'may 21': '2022-5-21',
'jun 21': '2022-6-21',
'jul 21': '2021-7-21',
'aug 21': '2021-8-21',
'sep 21': '2021-9-21',
'oct 21': '2021-10-21',
'nov 21': '2021-11-21',
'dec 21': '2021-12-21',
}
for (const c in cases) {
it(`should parse '${c}' as '${cases[c]}'`, () => {
const {date} = getDateFromText(c, now)
if (date === null && cases[c] === null) {
expect(date).toBeNull()
return
}
expect(`${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`).toBe(cases[c])
})
}
})
describe('Parse date from text in', () => {
const now = new Date()
now.setFullYear(2021, 5, 24)
now.setHours(12)
now.setMinutes(0)
now.setSeconds(0)
const cases = {
'Lorem Ipsum in 1 hour': '2021-6-24 13:0',
'in 2 hours': '2021-6-24 14:0',
'in 1 day': '2021-6-25 12:0',
'in 2 days': '2021-6-26 12:0',
'in 1 week': '2021-7-1 12:0',
'in 2 weeks': '2021-7-8 12:0',
'in 4 weeks': '2021-7-22 12:0',
'in 1 month': '2021-7-24 12:0',
'in 3 months': '2021-9-24 12:0',
}
for (const c in cases) {
it(`should parse '${c}' as '${cases[c]}'`, () => {
const {date} = getDateFromTextIn(c, now)
if (date === null && cases[c] === null) {
expect(date).toBeNull()
return
}
expect(`${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()} ${date.getHours()}:${date.getMinutes()}`).toBe(cases[c])
})
}
})
})
describe('Labels', () => {
it('should parse labels', () => {
const result = parseTaskText('Lorem Ipsum @label1 @label2')
expect(result.text).toBe('Lorem Ipsum')
expect(result.labels).toHaveLength(2)
expect(result.labels[0]).toBe('label1')
expect(result.labels[1]).toBe('label2')
})
it('should parse labels from the start', () => {
const result = parseTaskText('@label1 Lorem Ipsum @label2')
expect(result.text).toBe('Lorem Ipsum')
expect(result.labels).toHaveLength(2)
expect(result.labels[0]).toBe('label1')
expect(result.labels[1]).toBe('label2')
})
it('should resolve duplicate labels', () => {
const result = parseTaskText('Lorem Ipsum @label1 @label1 @label2')
expect(result.text).toBe('Lorem Ipsum')
expect(result.labels).toHaveLength(2)
expect(result.labels[0]).toBe('label1')
expect(result.labels[1]).toBe('label2')
})
it('should correctly parse labels with spaces in them', () => {
const result = parseTaskText(`Lorem @'label with space' Ipsum`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.labels).toHaveLength(1)
expect(result.labels[0]).toBe('label with space')
})
it('should correctly parse labels with spaces in them and "', () => {
const result = parseTaskText('Lorem @"label with space" Ipsum')
expect(result.text).toBe('Lorem Ipsum')
expect(result.labels).toHaveLength(1)
expect(result.labels[0]).toBe('label with space')
})
})
describe('List', () => {
it('should parse a list', () => {
const result = parseTaskText('Lorem Ipsum #list')
expect(result.text).toBe('Lorem Ipsum')
expect(result.list).toBe('list')
})
it('should parse a list with a space in it', () => {
const result = parseTaskText(`Lorem Ipsum #'list with long name'`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.list).toBe('list with long name')
})
it('should parse a list with a space in it and "', () => {
const result = parseTaskText(`Lorem Ipsum #"list with long name"`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.list).toBe('list with long name')
})
it('should parse only the first list', () => {
const result = parseTaskText(`Lorem Ipsum #list1 #list2 #list3`)
expect(result.text).toBe('Lorem Ipsum #list2 #list3')
expect(result.list).toBe('list1')
})
})
describe('Priority', () => {
for (const p in priorities) {
it(`should parse priority ${p}`, () => {
const result = parseTaskText(`Lorem Ipsum !${priorities[p]}`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.priority).toBe(priorities[p])
})
}
it(`should not parse an invalid priority`, () => {
const result = parseTaskText(`Lorem Ipsum !9999`)
expect(result.text).toBe('Lorem Ipsum !9999')
expect(result.priority).toBe(null)
})
it(`should not parse an invalid priority but use the first valid one it finds`, () => {
const result = parseTaskText(`Lorem Ipsum !9999 !1`)
expect(result.text).toBe('Lorem Ipsum !9999')
expect(result.priority).toBe(1)
})
})
describe('Assignee', () => {
it('should parse an assignee', () => {
const result = parseTaskText('Lorem Ipsum +user')
expect(result.text).toBe('Lorem Ipsum')
expect(result.assignees).toHaveLength(1)
expect(result.assignees[0]).toBe('user')
})
it('should parse multiple assignees', () => {
const result = parseTaskText('Lorem Ipsum +user1 +user2 +user3')
expect(result.text).toBe('Lorem Ipsum')
expect(result.assignees).toHaveLength(3)
expect(result.assignees[0]).toBe('user1')
expect(result.assignees[1]).toBe('user2')
expect(result.assignees[2]).toBe('user3')
})
it('should parse avoid duplicate assignees', () => {
const result = parseTaskText('Lorem Ipsum +user1 +user1 +user2')
expect(result.text).toBe('Lorem Ipsum')
expect(result.assignees).toHaveLength(2)
expect(result.assignees[0]).toBe('user1')
expect(result.assignees[1]).toBe('user2')
})
it('should parse an assignee with a space in it', () => {
const result = parseTaskText(`Lorem Ipsum +'user with long name'`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.assignees).toHaveLength(1)
expect(result.assignees[0]).toBe('user with long name')
})
it('should parse an assignee with a space in it and "', () => {
const result = parseTaskText(`Lorem Ipsum +"user with long name"`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.assignees).toHaveLength(1)
expect(result.assignees[0]).toBe('user with long name')
})
})
})

15
src/helpers/replaceAll.js Normal file
View File

@ -0,0 +1,15 @@
/**
* This function replaces all text, no matter the case.
*
* See https://stackoverflow.com/a/7313467/10924593
*
* @parma str
* @param search
* @param replace
* @returns {*}
*/
export const replaceAll = (str, search, replace) => {
const esc = search.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const reg = new RegExp(esc, 'ig');
return str.replace(reg, replace);
}

View File

@ -0,0 +1,30 @@
const key = 'collapsedBuckets'
const getAllState = () => {
const saved = localStorage.getItem(key)
if (saved === null) {
return {}
}
return JSON.parse(saved)
}
export const saveCollapsedBucketState = (listId, collapsedBuckets) => {
const state = getAllState()
state[listId] = collapsedBuckets
for (const bucketId in state[listId]) {
if (!state[listId][bucketId]) {
delete state[listId][bucketId]
}
}
localStorage.setItem(key, JSON.stringify(state))
}
export const getCollapsedBucketState = listId => {
const state = getAllState()
if (typeof state[listId] !== 'undefined') {
return state[listId]
}
return {}
}

View File

@ -1,6 +1,6 @@
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {format, formatDistance} from 'date-fns'
import { enGB, de } from 'date-fns/locale'
import {enGB, de} from 'date-fns/locale'
const locales = {enGB, de}
@ -30,7 +30,7 @@ export const formatDateSince = (date, $t) => {
date = createDateFromString(date)
const currentDate = new Date()
const distance = formatDistance(date, currentDate)
const distance = formatDistance(date, currentDate, {locale: locales[$t('date.locale')]})
if (date > currentDate) {
return $t('date.in', {date: distance})

View File

@ -0,0 +1,290 @@
import {calculateDayInterval} from './calculateDayInterval'
import {calculateNearestHours} from './calculateNearestHours'
import {replaceAll} from '../replaceAll'
export const parseDate = text => {
const lowerText = text.toLowerCase()
if (lowerText.includes('today')) {
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('today')), 'today')
}
if (lowerText.includes('tomorrow')) {
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('tomorrow')), 'tomorrow')
}
if (lowerText.includes('next monday')) {
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('nextMonday')), 'next monday')
}
if (lowerText.includes('this weekend')) {
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('thisWeekend')), 'this weekend')
}
if (lowerText.includes('later this week')) {
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('laterThisWeek')), 'later this week')
}
if (lowerText.includes('later next week')) {
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('laterNextWeek')), 'later next week')
}
if (lowerText.includes('next week')) {
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('nextWeek')), 'next week')
}
if (lowerText.includes('next month')) {
const date = new Date()
date.setDate(1)
date.setMonth(date.getMonth() + 1)
date.setHours(calculateNearestHours(date))
date.setMinutes(0)
date.setSeconds(0)
return addTimeToDate(text, date, 'next month')
}
if (lowerText.includes('end of month')) {
const curDate = new Date()
const date = new Date(curDate.getFullYear(), curDate.getMonth() + 1, 0)
date.setHours(calculateNearestHours(date))
date.setMinutes(0)
date.setSeconds(0)
return addTimeToDate(text, date, 'end of month')
}
let parsed = getDateFromWeekday(text)
if (parsed.date !== null) {
return addTimeToDate(text, parsed.date, parsed.foundText)
}
parsed = getDayFromText(text)
if (parsed.date !== null) {
return addTimeToDate(text, parsed.date, parsed.foundText)
}
parsed = getDateFromTextIn(text)
if (parsed.date !== null) {
return {
newText: replaceAll(text, parsed.foundText, ''),
date: parsed.date,
}
}
parsed = getDateFromText(text)
return {
newText: replaceAll(text, parsed.foundText, ''),
date: parsed.date,
}
}
const addTimeToDate = (text, date, match) => {
const matcher = new RegExp(`(${match} (at|@) )([0-9][0-9]?(:[0-9][0-9]?)?( ?(a|p)m)?)`, 'ig')
const results = matcher.exec(text)
if (results !== null) {
const time = results[3]
const parts = time.split(':')
let hours = parseInt(parts[0])
let minutes = 0
if (time.endsWith('pm')) {
hours += 12
}
if (parts.length > 1) {
minutes = parseInt(parts[1])
}
date.setHours(hours)
date.setMinutes(minutes)
date.setSeconds(0)
}
const replace = results !== null ? results[0] : match
return {
newText: replaceAll(text, replace, ''),
date: date,
}
}
export const getDateFromText = (text, now = new Date()) => {
const fullDateRegex = /([0-9][0-9]?\/[0-9][0-9]?\/[0-9][0-9]([0-9][0-9])?|[0-9][0-9][0-9][0-9]\/[0-9][0-9]?\/[0-9][0-9]?|[0-9][0-9][0-9][0-9]-[0-9][0-9]?-[0-9][0-9]?)/ig
// 1. Try parsing the text as a "usual" date, like 2021-06-24 or 06/24/2021
let results = fullDateRegex.exec(text)
let result = results === null ? null : results[0]
let foundText = result
let containsYear = true
if (result === null) {
// 2. Try parsing the date as something like "jan 21" or "21 jan"
const monthRegex = /((jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec) [0-9][0-9]?|[0-9][0-9]? (jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec))/ig
results = monthRegex.exec(text)
result = results === null ? null : `${results[0]} ${now.getFullYear()}`
foundText = results === null ? '' : results[0]
containsYear = false
if (result === null) {
// 3. Try parsing the date as "27/01" or "01/27"
const monthNumericRegex = /([0-9][0-9]?\/[0-9][0-9]?)/ig
results = monthNumericRegex.exec(text)
// Put the year before or after the date, depending on what works
result = results === null ? null : `${now.getFullYear()}/${results[0]}`
foundText = results === null ? '' : results[0]
if (isNaN(new Date(result))) {
result = results === null ? null : `${results[0]}/${now.getFullYear()}`
}
if (isNaN(new Date(result)) && results[0] !== null) {
const parts = results[0].split('/')
result = `${parts[1]}/${parts[0]}/${now.getFullYear()}`
}
}
}
if (result === null) {
return {
foundText,
date: null,
}
}
const date = new Date(result)
if (isNaN(date)) {
return {
foundText,
date: null,
}
}
if (!containsYear && date < now) {
date.setFullYear(date.getFullYear() + 1)
}
return {
foundText,
date,
}
}
export const getDateFromTextIn = (text, now = new Date()) => {
const regex = /(in [0-9]+ (hours?|days?|weeks?|months?))/ig
const results = regex.exec(text)
if (results === null) {
return {
foundText: '',
date: null,
}
}
let foundText = results[0]
const date = new Date(now)
const parts = foundText.split(' ')
switch (parts[2]) {
case 'hours':
case 'hour':
date.setHours(date.getHours() + parseInt(parts[1]))
break
case 'days':
case 'day':
date.setDate(date.getDate() + parseInt(parts[1]))
break
case 'weeks':
case 'week':
date.setDate(date.getDate() + parseInt(parts[1]) * 7)
break
case 'months':
case 'month':
date.setMonth(date.getMonth() + parseInt(parts[1]))
break
}
return {
foundText,
date,
}
}
const getDateFromWeekday = text => {
const matcher = /(mon|monday|tue|tuesday|wed|wednesday|thu|thursday|fri|friday|sat|saturday|sun|sunday)/ig
const results = matcher.exec(text)
if (results === null) {
return {
foundText: null,
date: null,
}
}
const date = new Date()
const currentDay = date.getDay()
let day = 0
switch (results[0]) {
case 'mon':
case 'monday':
day = 1
break
case 'tue':
case 'tuesday':
day = 2
break
case 'wed':
case 'wednesday':
day = 3
break
case 'thu':
case 'thursday':
day = 4
break
case 'fri':
case 'friday':
day = 5
break
case 'sat':
case 'saturday':
day = 6
break
case 'sun':
case 'sunday':
day = 0
break
default:
return {
foundText: null,
date: null,
}
}
const distance = (day + 7 - currentDay) % 7
date.setDate(date.getDate() + distance)
return {
foundText: results[0],
date: date,
}
}
const getDayFromText = text => {
const matcher = /(([1-2][0-9])|(3[01])|(0?[1-9]))(st|nd|rd|th|\.)/ig
const results = matcher.exec(text)
if (results === null) {
return {
foundText: null,
date: null,
}
}
const date = new Date()
date.setDate(parseInt(results[0]))
if (date < new Date()) {
date.setMonth(date.getMonth() + 1)
}
return {
foundText: results[0],
date: date,
}
}
const getDateFromInterval = interval => {
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
newDate.setHours(calculateNearestHours(newDate))
newDate.setMinutes(0)
newDate.setSeconds(0)
return newDate
}

View File

@ -1,6 +1,10 @@
{
"home": {
"welcome": "Hi {username}",
"welcomeNight": "Good Night {username}",
"welcomeMorning": "Good Morning {username}",
"welcomeDay": "Hi {username}",
"welcomeEvening": "Good Evening {username}",
"lastViewed": "Last viewed",
"list": {
"newText": "You can create a new list for your new tasks:",
"new": "Create a new list",
@ -239,7 +243,13 @@
"deleteBucketText2": "This will not delete any tasks but move them into the default bucket.",
"deleteBucketSuccess": "The bucket has been deleted successfully.",
"bucketTitleSavedSuccess": "The bucket title has been saved successfully.",
"bucketLimitSavedSuccess": "The bucket limit been saved successfully."
"bucketLimitSavedSuccess": "The bucket limit been saved successfully.",
"collapse": "Collapse this bucket"
},
"pseudo": {
"favorites": {
"title": "Favorites"
}
}
},
"namespace": {
@ -289,6 +299,17 @@
"color": "Color",
"archived": "Is Archived",
"isArchived": "This namespace is archived"
},
"pseudo": {
"sharedLists": {
"title": "Shared Lists"
},
"favorites": {
"title": "Favorites"
},
"savedFilters": {
"title": "Filters"
}
}
},
"filters": {
@ -394,7 +415,8 @@
"doit": "Do it!",
"saving": "Saving…",
"saved": "Saved!",
"default": "Default"
"default": "Default",
"close": "Close"
},
"input": {
"resetColor": "Reset Color",
@ -408,6 +430,7 @@
"chooseDate": "Choose a date"
},
"editor": {
"edit": "Edit",
"done": "Done",
"heading1": "Heading 1",
"heading2": "Heading 2",
@ -523,11 +546,15 @@
"title": "Attachments",
"createdBy": "created {0} by {1}",
"download": "Download",
"downloadTooltip": "Download this attachment",
"upload": "Upload attachment",
"drop": "Drop files here to upload",
"delete": "Delete attachment",
"deleteTooltip": "Delete this attachment",
"deleteText1": "Are you sure you want to delete the attachment {filename}?",
"deleteText2": "This cannot be undone!"
"deleteText2": "This cannot be undone!",
"copyUrl": "Copy URL",
"copyUrlTooltip": "Copy the url of this attachment for usage in text"
},
"comment": {
"title": "Comments",
@ -597,6 +624,28 @@
"weeks": "Weeks",
"months": "Months",
"years": "Years"
},
"quickAddMagic": {
"hint": "You can use Quick Add Magic",
"what": "What?",
"title": "Quick Add Magic",
"intro": "When creating a task, you can use special keywords to directly add attributes to the newly created task. This allows to add commonly used attributes to tasks much faster.",
"multiple": "You can use this multiple times.",
"label1": "To add a label, simply prefix the name of the label with {prefix}.",
"label2": "Vikunja will first check if the label already exist and create it if not.",
"label3": "To use spaces, simply add a \" around the label name.",
"label4": "For example: {prefix}\"Label with spaces\".",
"priority1": "To set a task's priority, add a number 1-5, prefixed with a {prefix}.",
"priority2": "The higher the number, the higher the priority.",
"assignees": "To directly assign the task to a user, add their username prefixed with @ to the task.",
"list1": "To set a list for the task to appear in, enter its name prefixed with {prefix}.",
"list2": "This will return an error if the list does not exist.",
"dateAndTime": "Date and time",
"date": "Any date will be used as the due date of the new task. You can use dates in any of these formats:",
"dateWeekday": "any weekday, will use the next date with that date",
"dateCurrentYear": "will use the current year",
"dateNth": "will use the {day}th of the current month",
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time."
}
},
"team": {
@ -684,6 +733,7 @@
"contact": "contact us"
},
"notification": {
"title": "Notifications",
"none": "You don't have any notifications. Have a nice day!",
"explainer": "Notifications will appear here when actions on namespaces, lists or tasks you subscribed to happen."
},
@ -791,5 +841,10 @@
"12002": "You are already subscribed to the entity itself or a parent entity.",
"13001": "This link share requires a password for authentication, but none was provided.",
"13002": "The provided link share password was invalid."
},
"about": {
"title": "About",
"frontendVersion": "Frontend Version: {version}",
"apiVersion": "API Version: {version}"
}
}

View File

@ -85,6 +85,8 @@ import vueShortkey from 'vue-shortkey'
import message from './message'
import {colorIsDark} from './helpers/color/colorIsDark'
import {setTitle} from './helpers/setTitle'
import {getNamespaceTitle} from './helpers/getNamespaceTitle'
import {getListTitle} from './helpers/getListTitle'
// Vuex
import {store} from './store'
// i18n
@ -199,6 +201,12 @@ Vue.mixin({
formatDateShort(date) {
return formatDate(date, 'PPpp', this.$t('date.locale'))
},
getNamespaceTitle(n) {
return getNamespaceTitle(n, p => this.$t(p))
},
getListTitle(l) {
return getListTitle(l, p => this.$t(p))
},
error(e, actions = []) {
return message.error(e, this, p => this.$t(p), actions)
},

View File

@ -11,7 +11,7 @@ export default class FileModel extends AbstractModel {
id: 0,
mime: '',
name: '',
size: '',
size: 0,
created: null,
}
}

View File

@ -0,0 +1,29 @@
export const getHistory = () => {
const savedHistory = localStorage.getItem('listHistory')
if (savedHistory === null) {
return []
}
return JSON.parse(savedHistory)
}
export function saveListToHistory(list) {
const history = getHistory()
list.id = parseInt(list.id)
// Remove the element if it already exists in history, preventing duplicates and essentially moving it to the beginning
for (const i in history) {
if (history[i].id === list.id) {
history.splice(i, 1)
}
}
// Add the new list to the beginning of the list
history.unshift(list)
if (history.length > 5) {
history.pop()
}
localStorage.setItem('listHistory', JSON.stringify(history))
}

View File

@ -0,0 +1,79 @@
import {getHistory, saveListToHistory} from './listHistory'
test('return an empty history when none was saved', () => {
Storage.prototype.getItem = jest.fn(() => null)
const h = getHistory()
expect(h).toStrictEqual([])
})
test('return a saved history', () => {
const saved = [{id: 1}, {id: 2}]
Storage.prototype.getItem = jest.fn(() => JSON.stringify(saved))
const h = getHistory()
expect(h).toStrictEqual(saved)
})
test('store list in history', () => {
let saved = {}
Storage.prototype.getItem = jest.fn(() => null)
Storage.prototype.setItem = jest.fn((key, lists) => {
saved = lists
})
saveListToHistory({id: 1})
expect(saved).toBe('[{"id":1}]')
})
test('store only the last 5 lists in history', () => {
let saved = null
Storage.prototype.getItem = jest.fn(() => saved)
Storage.prototype.setItem = jest.fn((key, lists) => {
saved = lists
})
saveListToHistory({id: 1})
saveListToHistory({id: 2})
saveListToHistory({id: 3})
saveListToHistory({id: 4})
saveListToHistory({id: 5})
saveListToHistory({id: 6})
expect(saved).toBe('[{"id":6},{"id":5},{"id":4},{"id":3},{"id":2}]')
})
test('don\'t store the same list twice', () => {
let saved = null
Storage.prototype.getItem = jest.fn(() => saved)
Storage.prototype.setItem = jest.fn((key, lists) => {
saved = lists
})
saveListToHistory({id: 1})
saveListToHistory({id: 1})
expect(saved).toBe('[{"id":1}]')
})
test('don\'t store the same list twice with different id types', () => {
let saved = null
Storage.prototype.getItem = jest.fn(() => saved)
Storage.prototype.setItem = jest.fn((key, lists) => {
saved = lists
})
saveListToHistory({id: 1})
saveListToHistory({id: '1'})
expect(saved).toBe('[{"id":1}]')
})
test('move a list to the beginning when storing it multiple times', () => {
let saved = null
Storage.prototype.getItem = jest.fn(() => saved)
Storage.prototype.setItem = jest.fn((key, lists) => {
saved = lists
})
saveListToHistory({id: 1})
saveListToHistory({id: 2})
saveListToHistory({id: 1})
expect(saved).toBe('[{"id":1},{"id":2}]')
})

View File

@ -2,6 +2,7 @@
import {register} from 'register-service-worker'
import swEvents from './ServiceWorker/events'
import {getToken} from './helpers/auth'
if (process.env.NODE_ENV === 'production') {
register(`${process.env.BASE_URL}sw.js`, {
@ -44,7 +45,7 @@ if (navigator && navigator.serviceWorker) {
if (action === 'getBearerToken') {
console.debug('Token request from sw')
port.postMessage({
authToken: localStorage.getItem('token'),
authToken: getToken(),
})
} else {
console.error('Unknown event', event)

View File

@ -5,6 +5,7 @@ import HomeComponent from '../views/Home'
import NotFoundComponent from '../views/404'
import LoadingComponent from '../components/misc/loading'
import ErrorComponent from '../components/misc/error'
import About from '../views/About'
// User Handling
import LoginComponent from '../views/user/Login'
import RegisterComponent from '../views/user/Register'
@ -527,5 +528,10 @@ export default new Router({
name: 'openid.auth',
component: OpenIdAuth,
},
{
path: '/about',
name: 'about',
component: About,
},
],
})

View File

@ -2,6 +2,7 @@ import axios from 'axios'
import reduce from 'lodash/reduce'
import replace from 'lodash/replace'
import {objectToSnakeCase} from '@/helpers/case'
import {getToken} from '@/helpers/auth'
export default class AbstractService {
@ -66,12 +67,9 @@ export default class AbstractService {
})
// Set the default auth header if we have a token
if (
localStorage.getItem('token') !== '' &&
localStorage.getItem('token') !== null &&
localStorage.getItem('token') !== undefined
) {
this.http.defaults.headers.common['Authorization'] = 'Bearer ' + localStorage.getItem('token')
const token = getToken()
if (token !== null) {
this.http.defaults.headers.common['Authorization'] = `Bearer ${token}`
}
this.paths = {

View File

@ -20,7 +20,6 @@ import attachments from './modules/attachments'
import labels from './modules/labels'
import ListService from '../services/list'
import {setTitle} from '@/helpers/setTitle'
Vue.use(Vuex)
@ -69,8 +68,6 @@ export const store = new Vuex.Store({
return
}
setTitle(currentList.title)
// Not sure if this is the right way to do it but hey, it works
if (
// List changed
@ -119,7 +116,7 @@ export const store = new Vuex.Store({
// Server updates don't return the right. Therefore the right is reset after updating the list which is
// confusing because all the buttons will disappear in that case. To prevent this, we're keeping the right
// when updating the list in global state.
if (typeof state.currentList.maxRight !== 'undefined') {
if (typeof state.currentList.maxRight !== 'undefined' && (typeof currentList.maxRight === 'undefined' || currentList.maxRight === null)) {
currentList.maxRight = state.currentList.maxRight
}
state.currentList = currentList
@ -140,4 +137,4 @@ export const store = new Vuex.Store({
state.quickActionsActive = active
},
},
})
})

View File

@ -1,6 +1,7 @@
import { HTTPFactory } from '@/http-common'
import { ERROR_MESSAGE, LOADING } from '../mutation-types'
import UserModel from '../../models/user'
import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth'
const defaultSettings = settings => {
if (typeof settings.weekStart === 'undefined' || settings.weekStart === '') {
@ -60,7 +61,7 @@ export default {
ctx.commit(LOADING, true, { root: true })
// Delete an eventually preexisting old token
localStorage.removeItem('token')
removeToken()
const data = {
username: credentials.username,
@ -74,7 +75,7 @@ export default {
return HTTP.post('login', data)
.then(response => {
// Save the token to local storage for later use
localStorage.setItem('token', response.data.token)
saveToken(response.data.token)
// Tell others the user is autheticated
ctx.commit('isLinkShareAuth', false)
@ -127,11 +128,11 @@ export default {
}
// Delete an eventually preexisting old token
localStorage.removeItem('token')
removeToken()
return HTTP.post(`/auth/openid/${provider}/callback`, data)
.then(response => {
// Save the token to local storage for later use
localStorage.setItem('token', response.data.token)
saveToken(response.data.token)
// Tell others the user is autheticated
ctx.commit('isLinkShareAuth', false)
@ -151,7 +152,7 @@ export default {
password: password,
})
.then(r => {
localStorage.setItem('token', r.data.token)
saveToken(r.data.token, false)
ctx.dispatch('checkAuth')
return Promise.resolve(r.data)
}).catch(e => {
@ -167,7 +168,7 @@ export default {
return Promise.resolve()
}
const jwt = localStorage.getItem('token')
const jwt = getToken()
let authenticated = false
if (jwt) {
const base64 = jwt
@ -213,33 +214,29 @@ export default {
},
// Renews the api token and saves it to local storage
renewToken(ctx) {
const HTTP = HTTPFactory()
if (!ctx.state.authenticated) {
return
}
// Timeout to avoid race conditions when authenticated as a user (=auth token in localStorage) and as a
// link share in another tab. Without the timeout both the token renew and link share auth are executed at
// the same time and one might win over the other.
setTimeout(() => {
if (!ctx.state.authenticated) {
return
}
HTTP.post('user/token', null, {
headers: {
Authorization: 'Bearer ' + localStorage.getItem('token'),
},
})
.then(r => {
localStorage.setItem('token', r.data.token)
ctx.dispatch('checkAuth')
})
.catch(e => {
// eslint-disable-next-line
console.log('Error renewing token: ', e)
// Don't logout on network errors as the user would then get logged out if they don't have
// internet for a short period of time - such as when the laptop is still reconnecting
if (e.request.status) {
ctx.dispatch('logout')
}
})
refreshToken()
.then(() => {
ctx.dispatch('checkAuth')
})
.catch(e => {
// Don't logout on network errors as the user would then get logged out if they don't have
// internet for a short period of time - such as when the laptop is still reconnecting
if (e.request.status) {
ctx.dispatch('logout')
}
})
}, 5000)
},
logout(ctx) {
localStorage.removeItem('token')
removeToken()
ctx.dispatch('checkAuth')
},
},

View File

@ -25,6 +25,12 @@ export default {
}
return null
},
findListByExactname: state => name => {
const list = Object.values(state).find(l => {
return l.title.toLowerCase() === name.toLowerCase()
})
return typeof list === 'undefined' ? null : list
},
},
actions: {
toggleListFavorite(ctx, list) {

View File

@ -24,3 +24,4 @@
@import 'datepicker';
@import 'notifications';
@import 'quick-actions';
@import 'hint-modal';

View File

@ -0,0 +1,43 @@
.hint-modal {
z-index: 4600;
.card-content {
text-align: left;
.info {
font-style: italic;
}
p {
display: flex;
justify-content: space-between;
align-items: center;
.shortcuts {
display: flex;
align-items: center;
i {
padding: 0 .25rem;
}
span {
padding: .1rem .35rem;
border: 1px solid $grey-300;
background: $grey-100;
border-radius: 3px;
font-size: .75rem;
}
}
}
.message-body {
padding: .5rem .75rem;
}
}
}
.modal-container-smaller .hint-modal .modal-container {
height: calc(100vh - 5rem);
}

View File

@ -1,7 +1,11 @@
@use 'sass:math';
$bucket-background: $grey-100;
$task-background: $white;
$ease-out: all .3s cubic-bezier(0.23, 1, 0.32, 1);
$bucket-width: 300px;
$bucket-header-height: 60px;
$bucket-right-margin: 1rem;
$crazy-height-calculation: '100vh - 4.5rem - 1.5rem - 1rem - 1.5rem - 11px';
$crazy-height-calculation-tasks: '#{$crazy-height-calculation} - 1rem - 2.5rem - 2rem - #{$button-height} - 1rem';
@ -31,7 +35,7 @@ $filter-container-height: '1rem - #{$switch-view-height}';
position: relative;
flex: 0 0 $bucket-width;
margin: 0 1rem 0 0;
margin: 0 $bucket-right-margin 0 0;
max-height: 100%;
min-height: 20px;
max-width: $bucket-width;
@ -233,6 +237,18 @@ $filter-container-height: '1rem - #{$switch-view-height}';
a.dropdown-item {
padding-right: 1rem;
}
&.is-collapsed {
transform: rotate(90deg) translateX(math.div($bucket-width, 2) - math.div($bucket-header-height, 2));
// Using negative margins instead of translateY here to make all other buckets fill the empty space
margin-left: (math.div($bucket-width, 2) - math.div($bucket-header-height, 2)) * -1;
margin-right: calc(#{(math.div($bucket-width, 2) - math.div($bucket-header-height, 2)) * -1} + #{$bucket-right-margin});
cursor: pointer;
.tasks, .bucket-footer {
display: none;
}
}
}
.bucket-header {
@ -240,6 +256,7 @@ $filter-container-height: '1rem - #{$switch-view-height}';
align-items: center;
justify-content: space-between;
padding: .5rem;
height: $bucket-header-height;
.limit {
padding-left: .5rem;

View File

@ -7,42 +7,3 @@
color: $grey-500;
transition: color $transition;
}
.keyboard-shortcuts-modal {
z-index: 4600;
.card-content {
text-align: left;
.info {
font-style: italic;
}
p {
display: flex;
justify-content: space-between;
align-items: center;
.shortcuts {
display: flex;
align-items: center;
i {
padding: 0 .25rem;
}
span {
padding: .1rem .35rem;
border: 1px solid $grey-300;
background: $grey-100;
border-radius: 3px;
font-size: .75rem;
}
}
}
.message-body {
padding: .5rem .75rem;
}
}
}

View File

@ -163,3 +163,140 @@ $filter-container-top-link-share-list: -47px;
.is-archived .notification.is-warning {
margin-bottom: 1rem;
}
$lists-per-row: 5;
$list-height: 150px;
$list-spacing: 1rem;
.list-card {
cursor: pointer;
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
height: $list-height;
background: $white;
margin: 0 $list-spacing $list-spacing 0;
padding: 1rem;
border-radius: $radius;
box-shadow: $shadow-sm;
transition: box-shadow $transition;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
&:hover {
box-shadow: $shadow-md;
}
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: $shadow-xs !important;
}
@media screen and (min-width: $widescreen) {
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $widescreen) and (min-width: $tablet) {
$lists-per-row: 3;
& {
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
}
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $tablet) {
$lists-per-row: 2;
& {
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
}
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $mobile) {
$lists-per-row: 1;
& {
width: 100%;
margin-right: 0;
}
}
.is-archived-container {
width: 100%;
text-align: right;
.is-archived {
font-size: .75rem;
float: left;
}
}
.title {
align-self: flex-end;
font-family: $vikunja-font;
font-weight: 400;
font-size: 1.5rem;
color: $text;
width: 100%;
margin-bottom: 0;
max-height: calc(100% - 2rem); // 1rem padding, 1rem height of the "is archived" badge
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
&.has-light-text .title {
color: $light;
}
&.has-background {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
.title {
text-shadow: 0 0 10px $black, 1px 1px 5px $grey-700, -1px -1px 5px $grey-700;
color: $white;
}
}
.favorite {
transition: opacity $transition, color $transition;
opacity: 0;
&:hover {
color: $orange;
}
&.is-archived {
display: none;
}
&.is-favorite {
display: inline-block;
opacity: 1;
color: $orange;
}
}
&:hover .favorite {
opacity: 1;
}
}
.list-cards-wrapper-2-rows {
flex-wrap: wrap;
max-height: calc(#{$list-height * 2} + #{$list-spacing * 2});
overflow: hidden;
}

View File

@ -1,5 +1,3 @@
$lists-per-row: 5;
.namespaces-list {
.button.new-namespace {
float: right;
@ -44,133 +42,6 @@ $lists-per-row: 5;
.lists {
display: flex;
flex-flow: row wrap;
.list {
cursor: pointer;
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
height: 150px;
background: $white;
margin: 0 1rem 1rem 0;
padding: 1rem;
border-radius: $radius;
box-shadow: $shadow-sm;
transition: box-shadow $transition;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
&:hover {
box-shadow: $shadow-md;
}
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: $shadow-xs !important;
}
@media screen and (min-width: $widescreen) {
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $widescreen) and (min-width: $tablet) {
$lists-per-row: 3;
& {
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
}
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $tablet) {
$lists-per-row: 2;
& {
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
}
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $mobile) {
$lists-per-row: 1;
& {
width: 100%;
margin-right: 0;
}
}
.is-archived-container {
width: 100%;
text-align: right;
.is-archived {
font-size: .75rem;
float: left;
}
}
.title {
align-self: flex-end;
font-family: $vikunja-font;
font-weight: 400;
font-size: 1.5rem;
color: $text;
width: 100%;
margin-bottom: 0;
max-height: calc(100% - 2rem); // 1rem padding, 1rem height of the "is archived" badge
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
&.has-light-text .title {
color: $light;
}
&.has-background {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
.title {
text-shadow: 0 0 10px $black, 1px 1px 5px $grey-700, -1px -1px 5px $grey-700;
color: $white;
}
}
.favorite {
transition: opacity $transition, color $transition;
opacity: 0;
&:hover {
color: $orange;
}
&.is-archived {
display: none;
}
&.is-favorite {
display: inline-block;
opacity: 1;
color: $orange;
}
}
&:hover .favorite {
opacity: 1;
}
}
}
}
}

View File

@ -83,15 +83,6 @@
}
}
table {
table-layout: fixed;
td {
overflow: hidden;
word-break: break-all;
}
}
.details {
padding-bottom: 0.75rem;
flex-flow: row wrap;

View File

@ -5,11 +5,6 @@
margin: 2rem 0 1.5rem;
}
.logout {
text-align: right;
margin-bottom: 1rem;
}
.column {
max-width: 100%;
}

View File

@ -144,3 +144,7 @@ button.table {
box-shadow: $shadow-md;
}
}
.is-strikethrough {
text-decoration: line-through;
}

51
src/views/About.vue Normal file
View File

@ -0,0 +1,51 @@
<template>
<transition name="fade">
<div class="modal-mask hint-modal">
<div @click.self="$router.back()" class="modal-container">
<div class="modal-content">
<card
class="has-background-white has-no-shadow"
:title="$t('about.title')"
:has-close="true"
close-icon="times"
@close="$router.back()"
:padding="false"
>
<div class="p-4">
<p>
{{ $t('about.frontendVersion', {version: this.frontendVersion}) }}
</p>
<p>
{{ $t('about.apiVersion', {version: this.apiVersion}) }}
</p>
</div>
<footer class="modal-card-foot is-flex is-justify-content-flex-end">
<x-button
type="secondary"
@click.prevent.stop="$router.back()"
>
{{ $t('misc.close') }}
</x-button>
</footer>
</card>
</div>
</div>
</div>
</transition>
</template>
<script>
import {VERSION} from '../version.json'
export default {
name: 'About',
computed: {
frontendVersion() {
return VERSION
},
apiVersion() {
return this.$store.state.config.version
},
},
}
</script>

View File

@ -1,7 +1,7 @@
<template>
<div class="content has-text-centered">
<h2>
{{ $t('home.welcome', {username: userInfo.name !== '' ? userInfo.name : userInfo.username}) }}!
{{ $t(`home.welcome${welcome}`, {username: userInfo.name !== '' ? userInfo.name : userInfo.username}) }}!
</h2>
<add-task
:listId="defaultListId"
@ -28,6 +28,17 @@
{{ $t('home.list.import') }}
</x-button>
</template>
<div v-if="listHistory.length > 0" class="is-max-width-desktop has-text-left">
<h3>{{ $t('home.lastViewed') }}</h3>
<div class="is-flex list-cards-wrapper-2-rows">
<list-card
v-for="(l, k) in listHistory"
:key="`l${k}`"
:list="l"
:background-resolver="() => null"
/>
</div>
</div>
<ShowTasks :show-all="true" v-if="hasLists" :key="showTasksKey" />
</div>
</template>
@ -35,11 +46,14 @@
<script>
import {mapState} from 'vuex'
import ShowTasks from './tasks/ShowTasks'
import {getHistory} from '@/modules/listHistory'
import ListCard from '@/components/list/partials/list-card'
import AddTask from '../components/tasks/add-task'
export default {
name: 'Home',
components: {
ListCard,
ShowTasks,
AddTask,
},
@ -51,33 +65,62 @@ export default {
showTasksKey: 0,
}
},
computed: mapState({
migratorsEnabled: state =>
state.config.availableMigrators !== null &&
state.config.availableMigrators.length > 0,
authenticated: state => state.auth.authenticated,
userInfo: state => state.auth.info,
hasTasks: state => state.hasTasks,
defaultListId: state => state.auth.defaultListId,
defaultNamespaceId: state => {
if (state.namespaces.namespaces.length === 0) {
return 0
computed: {
welcome() {
const now = new Date()
if (now.getHours() < 5) {
return 'Night'
}
return state.namespaces.namespaces[0].id
},
hasLists: state => {
if (state.namespaces.namespaces.length === 0) {
return false
if (now.getHours() < 11) {
return 'Morning'
}
return state.namespaces.namespaces[0].lists.length > 0
if (now.getHours() < 18) {
return 'Day'
}
if (now.getHours() < 23) {
return 'Evening'
}
return 'Night'
},
}),
methods: {
updateTaskList() {
this.showTasksKey += 1
listHistory() {
const history = getHistory()
return history.map(l => {
return this.$store.getters['lists/getListById'](l.id)
})
},
...mapState({
migratorsEnabled: state =>
state.config.availableMigrators !== null &&
state.config.availableMigrators.length > 0,
authenticated: state => state.auth.authenticated,
userInfo: state => state.auth.info,
hasTasks: state => state.hasTasks,
defaultListId: state => state.auth.defaultListId,
defaultNamespaceId: state => {
if (state.namespaces.namespaces.length === 0) {
return 0
}
return state.namespaces.namespaces[0].id
},
hasLists: state => {
if (state.namespaces.namespaces.length === 0) {
return false
}
return state.namespaces.namespaces[0].lists.length > 0
},
}),
},
methods: {
updateTaskList() {
this.showTasksKey += 1
},
},
}
</script>

View File

@ -1,5 +1,5 @@
<template>
<div class="modal-mask keyboard-shortcuts-modal">
<div class="modal-mask hint-modal">
<div @click.self="$router.back()" class="modal-container">
<div class="modal-content">
<card class="has-background-white has-no-shadow" :title="$t('filters.create.title')">

View File

@ -44,6 +44,7 @@ import ListModel from '../../models/list'
import ListService from '../../services/list'
import {CURRENT_LIST} from '@/store/mutation-types'
import {getListView} from '@/helpers/saveListView'
import {saveListToHistory} from '@/modules/listHistory'
export default {
data() {
@ -88,11 +89,15 @@ export default {
return
},
loadList() {
if(this.$route.name.includes('.settings.')) {
if (this.$route.name.includes('.settings.')) {
return
}
this.setTitle(this.currentList.title)
const listData = {id: this.$route.params.listId}
saveListToHistory(listData)
this.setTitle(this.currentList.id ? this.getListTitle(this.currentList) : '')
// This invalidates the loaded list at the kanban board which lets it reload its content when
// switched to it. This ensures updates done to tasks in the gantt or list views are consistently
@ -111,12 +116,16 @@ export default {
return this.replaceListView()
}
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
// the currently loaded list has the right set.
if (
this.$route.params.listId === this.listLoaded ||
typeof this.$route.params.listId === 'undefined' ||
this.$route.params.listId === this.currentList.id ||
parseInt(this.$route.params.listId) === this.currentList.id
(
this.$route.params.listId === this.listLoaded ||
typeof this.$route.params.listId === 'undefined' ||
this.$route.params.listId === this.currentList.id ||
parseInt(this.$route.params.listId) === this.currentList.id
)
&& typeof this.currentList !== 'undefined' && this.currentList.maxRight !== null
) {
return
}
@ -134,11 +143,12 @@ export default {
console.debug(`Loading list, $route.name = ${this.$route.name}, $route.params =`, this.$route.params, `, listLoaded = ${this.listLoaded}, currentList = `, this.currentList)
// We create an extra list object instead of creating it in this.list because that would trigger a ui update which would result in bad ux.
let list = new ListModel({id: this.$route.params.listId})
const list = new ListModel(listData)
this.listService.get(list)
.then(r => {
this.$set(this, 'list', r)
this.$store.commit(CURRENT_LIST, r)
this.setTitle(this.getListTitle(r))
})
.catch(e => {
this.error(e)
@ -149,4 +159,4 @@ export default {
},
},
}
</script>
</script>

View File

@ -17,8 +17,13 @@
/>
</div>
<div :class="{ 'is-loading': loading && !oneTaskUpdating}" class="kanban loader-container">
<div :key="`bucket${bucket.id}`" class="bucket" v-for="bucket in buckets">
<div class="bucket-header">
<div
:key="`bucket${bucket.id}`"
class="bucket"
:class="{'is-collapsed': collapsedBuckets[bucket.id]}"
v-for="bucket in buckets"
>
<div class="bucket-header" @click="() => unCollapseBucket(bucket)">
<span
v-if="bucket.isDoneBucket"
class="icon is-small has-text-success mr-2"
@ -31,7 +36,7 @@
@focusout="() => saveBucketTitle(bucket.id)"
@keydown.enter.prevent.stop="() => saveBucketTitle(bucket.id)"
class="title input"
:contenteditable="canWrite"
:contenteditable="canWrite && !collapsedBuckets[bucket.id]"
spellcheck="false">{{ bucket.title }}</h2>
<span
:class="{'is-max': bucket.tasks.length >= bucket.limit}"
@ -41,7 +46,7 @@
</span>
<dropdown
class="is-right options"
v-if="canWrite"
v-if="canWrite && !collapsedBuckets[bucket.id]"
trigger-icon="ellipsis-v"
@close="() => showSetLimitInput = false"
>
@ -71,11 +76,13 @@
</div>
</div>
<template v-else>
{{ $t('list.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('list.kanban.noLimit') }) }}
{{
$t('list.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('list.kanban.noLimit')})
}}
</template>
</a>
<a
@click="toggleDoneBucket(bucket)"
@click.stop="toggleDoneBucket(bucket)"
class="dropdown-item"
v-tooltip="$t('list.kanban.doneBucketHintExtended')"
>
@ -83,9 +90,15 @@
icon="check-double"/></span>
{{ $t('list.kanban.doneBucket') }}
</a>
<a
class="dropdown-item"
@click.stop="() => collapseBucket(bucket)"
>
{{ $t('list.kanban.collapse') }}
</a>
<a
:class="{'is-disabled': buckets.length <= 1}"
@click="() => deleteBucketModal(bucket.id)"
@click.stop="() => deleteBucketModal(bucket.id)"
class="dropdown-item has-text-danger"
v-tooltip="buckets.length <= 1 ? $t('list.kanban.deleteLast') : ''"
>
@ -264,7 +277,6 @@
<script>
import TaskService from '../../../services/task'
import TaskModel from '../../../models/task'
import BucketModel from '../../../models/bucket'
import {Container, Draggable} from 'vue-smooth-dnd'
@ -281,6 +293,8 @@ import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
import FilterPopup from '@/components/list/partials/filter-popup'
import Dropdown from '@/components/misc/dropdown'
import {playPop} from '@/helpers/playPop'
import createTask from '@/components/tasks/mixins/createTask'
import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState'
export default {
name: 'Kanban',
@ -313,6 +327,7 @@ export default {
showNewBucketInput: false,
newTaskError: {},
showSetLimitInput: false,
collapsedBuckets: {},
// We're using this to show the loading animation only at the task when updating it
taskUpdating: {},
@ -328,6 +343,9 @@ export default {
filtersChanged: false, // To trigger a reload of the board
}
},
mixins: [
createTask,
],
created() {
this.taskService = new TaskService()
this.loadBuckets()
@ -364,6 +382,8 @@ export default {
return
}
this.collapsedBuckets = getCollapsedBucketState(this.$route.params.listId)
console.debug(`Loading buckets, loadedListId = ${this.loadedListId}, $route.params =`, this.$route.params)
this.filtersChanged = false
@ -488,24 +508,7 @@ export default {
}
this.$set(this.newTaskError, bucketId, false)
// We need the actual bucket index so we put that in a seperate function
const bucketIndex = () => {
for (const t in this.buckets) {
if (this.buckets[t].id === bucketId) {
return t
}
}
}
const bi = bucketIndex()
const task = new TaskModel({
title: this.newTaskText,
bucketId: this.buckets[bi].id,
listId: this.$route.params.listId,
})
this.taskService.create(task)
this.createNewTask(this.newTaskText, bucketId)
.then(r => {
this.newTaskText = ''
this.$store.commit('kanban/addTaskToBucket', r)
@ -514,10 +517,10 @@ export default {
this.error(e)
})
.finally(() => {
if (!this.$refs[`tasks-container${task.bucketId}`][0]) {
if (!this.$refs[`tasks-container${bucketId}`][0]) {
return
}
this.$refs[`tasks-container${task.bucketId}`][0].scrollTop = this.$refs[`tasks-container${task.bucketId}`][0].scrollHeight
this.$refs[`tasks-container${bucketId}`][0].scrollTop = this.$refs[`tasks-container${bucketId}`][0].scrollHeight
})
},
createNewBucket() {
@ -624,6 +627,18 @@ export default {
bucket.isDoneBucket = !bucket.isDoneBucket
})
},
collapseBucket(bucket) {
this.$set(this.collapsedBuckets, bucket.id, true)
saveCollapsedBucketState(this.$route.params.listId, this.collapsedBuckets)
},
unCollapseBucket(bucket) {
if (!this.collapsedBuckets[bucket.id]) {
return
}
this.$set(this.collapsedBuckets, bucket.id, false)
saveCollapsedBucketState(this.$route.params.listId, this.collapsedBuckets)
},
},
}
</script>

View File

@ -69,6 +69,7 @@
<p class="help is-danger" v-if="showError && newTaskText === ''">
{{ $t('list.list.addTitleRequired') }}
</p>
<quick-add-magic v-if="!showError"/>
</div>
<nothing v-if="ctaVisible && tasks.length === 0 && !taskCollectionService.loading">
@ -106,7 +107,8 @@
</div>
<card
v-if="isTaskEdit"
class="taskedit mt-0" :title="$t('list.list.editTask')" :has-close="true" @close="() => isTaskEdit = false"
class="taskedit mt-0" :title="$t('list.list.editTask')" :has-close="true"
@close="() => isTaskEdit = false"
:shadow="false">
<edit-task :task="taskEditTask"/>
</card>
@ -162,7 +164,6 @@
<script>
import TaskService from '../../../services/task'
import TaskModel from '../../../models/task'
import LabelTaskService from '../../../services/labelTask'
import EditTask from '../../../components/tasks/edit-task'
import AddTask from '../../../components/tasks/add-task'
@ -174,6 +175,8 @@ import { mapState } from 'vuex'
import FilterPopup from '@/components/list/partials/filter-popup'
import { HAS_TASKS } from '@/store/mutation-types'
import Nothing from '@/components/misc/nothing'
import createTask from '@/components/tasks/mixins/createTask'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic'
export default {
name: 'List',
@ -183,15 +186,16 @@ export default {
isTaskEdit: false,
taskEditTask: TaskModel,
newTaskText: '',
showError: false,
labelTaskService: LabelTaskService,
ctaVisible: false,
}
},
mixins: [taskList],
mixins: [
taskList,
createTask,
],
components: {
QuickAddMagic,
Nothing,
FilterPopup,
SingleTaskInList,
@ -200,7 +204,6 @@ export default {
},
created() {
this.taskService = new TaskService()
this.labelTaskService = new LabelTaskService()
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
@ -220,6 +223,23 @@ export default {
this.isTaskEdit = false
this.loadTasks(page, search)
},
addTask() {
if (this.newTaskText === '') {
this.showError = true
return
}
this.showError = false
this.createNewTask(this.newTaskText)
.then(task => {
this.tasks.push(task)
this.sortTasks()
this.newTaskText = ''
})
.catch(e => {
this.error(e)
})
},
focusNewTaskInput() {
this.$refs.newTaskInput.$refs.newTaskInput.focus()
},

View File

@ -39,7 +39,7 @@
</x-button>
<h1>
<span>{{ n.title }}</span>
<span>{{ getNamespaceTitle(n) }}</span>
<span class="is-archived" v-if="n.isArchived">
{{ $t('namespace.archived') }}
</span>
@ -53,37 +53,12 @@
</p>
<div class="lists">
<template v-for="l in n.lists">
<router-link
:class="{
'has-light-text': !colorIsDark(l.hexColor),
'has-background': typeof backgrounds[l.id] !== 'undefined',
}"
:key="`l${l.id}`"
:style="{
'background-color': l.hexColor,
'background-image': typeof backgrounds[l.id] !== 'undefined' ? `url(${backgrounds[l.id]})` : false,
}"
:to="{ name: 'list.index', params: { listId: l.id} }"
class="list"
tag="span"
v-if="showArchived ? true : !l.isArchived"
>
<div class="is-archived-container">
<span class="is-archived" v-if="l.isArchived">
{{ $t('namespace.archived') }}
</span>
<span
:class="{'is-favorite': l.isFavorite, 'is-archived': l.isArchived}"
@click.stop="toggleFavoriteList(l)"
class="favorite">
<icon icon="star" v-if="l.isFavorite"/>
<icon :icon="['far', 'star']" v-else/>
</span>
</div>
<div class="title">{{ l.title }}</div>
</router-link>
</template>
<list-card
v-for="l in n.lists"
:key="`l${l.id}`"
:list="l"
:show-archived="showArchived"
/>
</div>
</div>
</div>
@ -91,25 +66,23 @@
<script>
import {mapState} from 'vuex'
import ListService from '../../services/list'
import Fancycheckbox from '../../components/input/fancycheckbox'
import {LOADING} from '@/store/mutation-types'
import ListCard from '@/components/list/partials/list-card'
export default {
name: 'ListNamespaces',
components: {
ListCard,
Fancycheckbox,
},
data() {
return {
showArchived: false,
// listId is the key, the object is the background blob
backgrounds: {},
}
},
created() {
this.showArchived = JSON.parse(localStorage.getItem('showArchived')) ?? false
this.loadBackgroundsForLists()
},
mounted() {
this.setTitle(this.$t('namespace.title'))
@ -121,31 +94,6 @@ export default {
loading: LOADING,
}),
methods: {
loadBackgroundsForLists() {
const listService = new ListService()
this.namespaces.forEach(n => {
n.lists.forEach(l => {
if (l.backgroundInformation) {
listService.background(l)
.then(b => {
this.$set(this.backgrounds, l.id, b)
})
.catch(e => {
this.error(e)
})
}
})
})
},
toggleFavoriteList(list) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
return
}
this.$store.dispatch('lists/toggleListFavorite', list)
.catch(e => this.error(e))
},
saveShowArchivedState() {
localStorage.setItem('showArchived', JSON.stringify(this.showArchived))
},

View File

@ -25,8 +25,8 @@
{{ $t('user.auth.login') }}
</x-button>
<div class="notification is-danger mt-4" v-if="error !== ''">
{{ error }}
<div class="notification is-danger mt-4" v-if="errorMessage !== ''">
{{ errorMessage }}
</div>
</div>
</div>
@ -42,7 +42,7 @@ export default {
return {
loading: true,
authenticateWithPassword: false,
error: '',
errorMessage: '',
hash: '',
password: '',
@ -59,7 +59,7 @@ export default {
}),
methods: {
auth() {
this.error = ''
this.errorMessage = ''
if (this.authLinkShare) {
return
@ -77,15 +77,15 @@ export default {
return
}
// TODO: Put this logic in a global error handler method which checks all auth codes
let error = this.$t('sharing.error')
// TODO: Put this logic in a global errorMessage handler method which checks all auth codes
let errorMessage = this.$t('sharing.errorMessage')
if (e.response && e.response.data && e.response.data.message) {
error = e.response.data.message
errorMessage = e.response.data.message
}
if (typeof e.response.data.code !== 'undefined' && e.response.data.code === 13002) {
error = this.$t('sharing.invalidPassword')
errorMessage = this.$t('sharing.invalidPassword')
}
this.error = error
this.errorMessage = errorMessage
})
.finally(() => {
this.loading = false

View File

@ -3,9 +3,9 @@
<div class="task-view">
<heading v-model="task" :can-write="canWrite" ref="heading"/>
<h6 class="subtitle" v-if="parent && parent.namespace && parent.list">
{{ parent.namespace.title }} >
{{ getNamespaceTitle(parent.namespace) }} >
<router-link :to="{ name: listViewName, params: { listId: parent.list.id } }">
{{ parent.list.title }}
{{ getListTitle(parent.list) }}
</router-link>
</h6>

102
yarn.lock
View File

@ -1796,6 +1796,20 @@
dependencies:
"@hapi/hoek" "^9.0.0"
"@humanwhocodes/config-array@^0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9"
integrity sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==
dependencies:
"@humanwhocodes/object-schema" "^1.2.0"
debug "^4.1.1"
minimatch "^3.0.4"
"@humanwhocodes/object-schema@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf"
integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==
"@intervolga/optimize-cssnano-plugin@^1.0.5":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@intervolga/optimize-cssnano-plugin/-/optimize-cssnano-plugin-1.0.6.tgz#be7c7846128b88f6a9b1d1261a0ad06eb5c0fdf8"
@ -5383,10 +5397,10 @@ cypress-file-upload@5.0.8:
resolved "https://registry.yarnpkg.com/cypress-file-upload/-/cypress-file-upload-5.0.8.tgz#d8824cbeaab798e44be8009769f9a6c9daa1b4a1"
integrity sha512-+8VzNabRk3zG6x8f8BWArF/xA/W0VK4IZNx3MV0jFWrJS/qKn8eHfa5nU73P9fOQAgwHFJx7zjg4lwOnljMO8g==
cypress@7.6.0:
version "7.6.0"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-7.6.0.tgz#80fe7496cd4165a0fa06e25fc11413dda4544463"
integrity sha512-tTwQExY28CKt6cY85/2V1uLExcMfpBEBWXt/EcE2ht/Onl9k4lxUS7ul1UnUO5MrYwMIHMdGVh13DxdzXj4Z5w==
cypress@7.7.0:
version "7.7.0"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-7.7.0.tgz#0839ae28e5520536f9667d6c9ae81496b3836e64"
integrity sha512-uYBYXNoI5ym0UxROwhQXWTi8JbUEjpC6l/bzoGZNxoKGsLrC1SDPgIDJMgLX/MeEdPL0UInXLDUWN/rSyZUCjQ==
dependencies:
"@cypress/request" "^2.88.5"
"@cypress/xvfb" "^1.2.4"
@ -5821,10 +5835,10 @@ domhandler@^2.3.0:
dependencies:
domelementtype "1"
dompurify@2.2.9:
version "2.2.9"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.2.9.tgz#4b42e244238032d9286a0d2c87b51313581d9624"
integrity sha512-+9MqacuigMIZ+1+EwoEltogyWGFTJZWU3258Rupxs+2CGs4H914G9er6pZbsme/bvb5L67o2rade9n21e4RW/w==
dompurify@2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.0.tgz#07bb39515e491588e5756b1d3e8375b5964814e2"
integrity sha512-VV5C6Kr53YVHGOBKO/F86OYX6/iLTw2yVSI721gKetxpHCK/V5TaLEf9ODjRgl1KLSWRMY6cUhAbv/c+IUnwQw==
domutils@1.5.1:
version "1.5.1"
@ -6159,15 +6173,15 @@ eslint-loader@^2.2.1:
object-hash "^1.1.4"
rimraf "^2.6.1"
eslint-plugin-vue@7.12.1:
version "7.12.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-7.12.1.tgz#ef6499ce4fe0566659c8e12c71713f5308630a76"
integrity sha512-xHf/wCt88qmzqQerjaSteUFGASj7fPreglKD4ijnvoKRkoSJ3/H3kuJE8QFFtc+2wjw6hRDs834HH7vpuTJQzg==
eslint-plugin-vue@7.13.0:
version "7.13.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-7.13.0.tgz#6f3d232bf1fcd0428353b0d581ebaca1c5dbc17a"
integrity sha512-u0+jL8h2MshRuMTCLslktxRsPTjlENNcNufhgHu01N982DmHVdeFniyMPoVLLRjACQOwdz3FdlsgYGBMBG+AKg==
dependencies:
eslint-utils "^2.1.0"
natural-compare "^1.4.0"
semver "^7.3.2"
vue-eslint-parser "^7.6.0"
vue-eslint-parser "^7.8.0"
eslint-scope@^4.0.3:
version "4.0.3"
@ -6177,14 +6191,6 @@ eslint-scope@^4.0.3:
esrecurse "^4.1.0"
estraverse "^4.1.1"
eslint-scope@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.0.0.tgz#e87c8887c73e8d1ec84f1ca591645c358bfc8fb9"
integrity sha512-oYrhJW7S0bxAFDvWqzvMPRm6pcgcnWc4QnofCAqRTRfQC0JcwenzGglTtsLyIuuWFfkqDG9vz67cnttSd53djw==
dependencies:
esrecurse "^4.1.0"
estraverse "^4.1.1"
eslint-scope@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c"
@ -6215,13 +6221,14 @@ eslint-visitor-keys@^2.0.0:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz#21fdc8fbcd9c795cc0321f0563702095751511a8"
integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==
eslint@7.29.0:
version "7.29.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.29.0.tgz#ee2a7648f2e729485e4d0bd6383ec1deabc8b3c0"
integrity sha512-82G/JToB9qIy/ArBzIWG9xvvwL3R86AlCjtGw+A29OMZDqhTybz/MByORSukGxeI+YPCR4coYyITKk8BFH9nDA==
eslint@7.30.0:
version "7.30.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.30.0.tgz#6d34ab51aaa56112fd97166226c9a97f505474f8"
integrity sha512-VLqz80i3as3NdloY44BQSJpFw534L9Oh+6zJOUaViV4JPd+DaHwutqP7tcpkW3YiXbK6s05RZl7yl7cQn+lijg==
dependencies:
"@babel/code-frame" "7.12.11"
"@eslint/eslintrc" "^0.4.2"
"@humanwhocodes/config-array" "^0.5.0"
ajv "^6.10.0"
chalk "^4.0.0"
cross-spawn "^7.0.2"
@ -7564,10 +7571,10 @@ hex-color-regex@^1.1.0:
resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e"
integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==
highlight.js@11.0.1:
version "11.0.1"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.0.1.tgz#a78bafccd9aa297978799fe5eed9beb7ee1ef887"
integrity sha512-EqYpWyTF2s8nMfttfBA2yLKPNoZCO33pLS4MnbXQ4hECf1TKujCt1Kq7QAdrio7roL4+CqsfjqwYj4tYgq0pJQ==
highlight.js@11.1.0:
version "11.1.0"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.1.0.tgz#0198f7326e64ddfbea5f76b00e84ab542cf24ae8"
integrity sha512-X9VVhYKHQPPuwffO8jk4bP/FVj+ibNCy3HxZZNDXFtJrq4O5FdcdCDRIkDis5MiMnjh7UwEdHgRZJcHFYdzDdA==
highlight.js@^9.6.0:
version "9.17.1"
@ -11863,10 +11870,10 @@ sass-loader@10.2.0:
schema-utils "^3.0.0"
semver "^7.3.2"
sass@1.35.1:
version "1.35.1"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.35.1.tgz#90ecf774dfe68f07b6193077e3b42fb154b9e1cd"
integrity sha512-oCisuQJstxMcacOPmxLNiLlj4cUyN2+8xJnG7VanRoh2GOLr9RqkvI4AxA4a6LHVg/rsu+PmxXeGhrdSF9jCiQ==
sass@1.35.2:
version "1.35.2"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.35.2.tgz#b732314fcdaf7ef8d0f1698698adc378043cb821"
integrity sha512-jhO5KAR+AMxCEwIH3v+4zbB2WB0z67V1X0jbapfVwQQdjHZUGUyukpnoM6+iCMfsIUC016w9OPKQ5jrNOS9uXw==
dependencies:
chokidar ">=3.0.0 <4.0.0"
@ -13500,22 +13507,23 @@ vue-easymde@1.4.0:
easymde "^2.14.0"
marked "^2.0.1"
vue-eslint-parser@^7.6.0:
version "7.6.0"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.6.0.tgz#01ea1a2932f581ff244336565d712801f8f72561"
integrity sha512-QXxqH8ZevBrtiZMZK0LpwaMfevQi9UL7lY6Kcp+ogWHC88AuwUPwwCIzkOUc1LR4XsYAt/F9yHXAB/QoD17QXA==
vue-eslint-parser@^7.8.0:
version "7.8.0"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.8.0.tgz#43850bf856c9a69d62c0e12769609c338423684b"
integrity sha512-ehmmrLZNYLUoKayvVW8l8HyPQIfuYZHiJoQLRP3dapDlTU7bGs4tqIKVGdAEpMuXS/b4R/PImCt7Tkj4UhX1SQ==
dependencies:
debug "^4.1.1"
eslint-scope "^5.0.0"
eslint-scope "^5.1.1"
eslint-visitor-keys "^1.1.0"
espree "^6.2.1"
esquery "^1.4.0"
lodash "^4.17.15"
lodash "^4.17.21"
semver "^6.3.0"
vue-flatpickr-component@8.1.6:
version "8.1.6"
resolved "https://registry.yarnpkg.com/vue-flatpickr-component/-/vue-flatpickr-component-8.1.6.tgz#8fb25dc72946ceb1ab005b871a8151461c0883f6"
integrity sha512-RUu/M/1lbuzQT+U3yP7O5/M2EIizr9vXLXqSc1eFOvCT3AHurQg8+iO02rjW+2l78Kit8pCNAUvHw7eexws6hw==
vue-flatpickr-component@8.1.7:
version "8.1.7"
resolved "https://registry.yarnpkg.com/vue-flatpickr-component/-/vue-flatpickr-component-8.1.7.tgz#202a8977ee5838b09e6920154c99a69ce17a00e0"
integrity sha512-T9aapLERf5XrisKUHw8QVByFpN9UB583Bhu6+HtzvhCcfXqIYBtRc3rQC0ZhFSRk3CNMo7533U+B5Qs4WAbhyA==
dependencies:
flatpickr "^4.6.6"
@ -13524,10 +13532,10 @@ vue-hot-reload-api@^2.3.0:
resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz#532955cc1eb208a3d990b3a9f9a70574657e08f2"
integrity sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==
vue-i18n@8.24.5:
version "8.24.5"
resolved "https://registry.yarnpkg.com/vue-i18n/-/vue-i18n-8.24.5.tgz#7127a666d5be2199be69be39e439a419a90ff931"
integrity sha512-p8W5xOmniuZ8fj76VXe0vBL3bRWVU87jHuC/v8VwmhKVH2iMQsKnheB1U+umxDBqC/5g9K+NwzokepcLxnBAVQ==
vue-i18n@8.25.0:
version "8.25.0"
resolved "https://registry.yarnpkg.com/vue-i18n/-/vue-i18n-8.25.0.tgz#1037d9295fa2845a230b771de473481edb2cfc4c"
integrity sha512-ynhcL+PmTxuuSE1T10htiSXzjBozxYIE3ffbM1RfgAkVbr/v1SP+9Mi/7/uv8ZVV1yGuKjFAYp9BXq+X7op6MQ==
"vue-loader-v16@npm:vue-loader@^16.1.0":
version "16.1.2"