Merge branch 'main' into translations

# Conflicts:
#	src/i18n/lang/en.json
This commit is contained in:
kolaente 2021-07-09 10:31:13 +02:00
commit 86ca6c29c5
No known key found for this signature in database
GPG key ID: F40E70337AB24C9B
53 changed files with 2298 additions and 891 deletions

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

@ -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,12 +18,12 @@
"camel-case": "4.1.2",
"copy-to-clipboard": "3.3.1",
"date-fns": "2.22.1",
"dompurify": "2.2.9",
"dompurify": "2.3.0",
"highlight.js": "11.0.1",
"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",
@ -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.5",
"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",
@ -92,6 +92,7 @@
"jest": {
"testPathIgnorePatterns": [
"cypress"
]
],
"testEnvironment": "jsdom"
}
}

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

@ -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">

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,29 @@ 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 (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 +253,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 +382,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>

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,7 +1,11 @@
{
"home": {
"welcome": "Hi {username}",
"list": {
"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",
"importText": "Or import your lists and tasks from other services into Vikunja:",
@ -239,8 +243,13 @@
"deleteBucketText2": "This will not delete any tasks but move them into the default bucket.",
"deleteBucketSuccess": "Bucket deleted.",
"bucketTitleSavedSuccess": "Bucket name saved.",
"bucketLimitSavedSuccess": "Bucket limit saved."
}
"bucketLimitSavedSuccess": "Bucket limit saved.",
"collapse": "Collapse this bucket"
},
"pseudo": {
"favorites": {
"title": "Favorites"
}}
},
"namespace": {
"title": "Namespaces and Lists",
@ -288,7 +297,17 @@
"descriptionPlaceholder": "Enter a namespace description…",
"color": "Color",
"archived": "Is archived",
"isArchived": "This namespace is archived"
"isArchived": "This namespace is archived"},
"pseudo": {
"sharedLists": {
"title": "Shared Lists"
},
"favorites": {
"title": "Favorites"
},
"savedFilters": {
"title": "Filters"
}
}
},
"filters": {
@ -394,7 +413,8 @@
"doit": "Do it!",
"saving": "Saving…",
"saved": "Saved!",
"default": "Default"
"default": "Default",
"close": "Close"
},
"input": {
"resetColor": "Reset Color",
@ -408,7 +428,8 @@
"chooseDate": "Choose a date"
},
"editor": {
"done": "Done",
"edit": "Edit",
"done": "Done",
"heading1": "Heading 1",
"heading2": "Heading 2",
"heading3": "Heading 3",
@ -523,12 +544,15 @@
"title": "Attachments",
"createdBy": "created {0} by {1}",
"download": "Download",
"upload": "Upload attachment",
"downloadTooltip": "Download this attachment",
"upload": "Upload attachment",
"drop": "Drop files here to upload",
"delete": "Delete attachment",
"deleteText1": "Delete the {filename} attachment?",
"deleteText2": "This cannot be undone!"
},
"deleteTooltip": "Delete this attachment",
"deleteText1": "Delete the {filename} attachment?",
"deleteText2": "This cannot be undone!",
"copyUrl": "Copy URL",
"copyUrlTooltip": "Copy the url of this attachment for usage in text"},
"comment": {
"title": "Comments",
"loading": "Loading comments…",
@ -597,8 +621,29 @@
"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": {
"title": "Teams",
"noTeams": "You are currently not part of any teams.",
@ -683,7 +728,7 @@
"tryAgain": "try again",
"contact": "contact us"
},
"notification": {
"notification": {"title": "Notifications",
"none": "You don't have any notifications. Have a nice day!",
"explainer": "Notifications appear here when actions (for namespaces, lists, or tasks) you subscribe to happen."
},
@ -791,5 +836,10 @@
"12002": "You are already subscribed to the entity itself or a parent entity.",
"13001": "The required password was not supplied for this link share.",
"13002": "Invalid link-share password."
}
},
"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

@ -0,0 +1,26 @@
export const getHistory = () => {
const savedHistory = localStorage.getItem('listHistory')
if (savedHistory === null) {
return []
}
return JSON.parse(savedHistory)
}
export function saveListToHistory(list) {
const history = getHistory()
// 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)
}
}
history.unshift(list)
if (history.length > 5) {
history.pop()
}
localStorage.setItem('listHistory', JSON.stringify(history))
}

View file

@ -0,0 +1,67 @@
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('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

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

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

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) {
@ -72,6 +78,6 @@ export default {
return Promise.reject(e)
})
.finally(() => cancel())
}
},
},
}

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

@ -2,6 +2,8 @@ $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 +33,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 +235,18 @@ $filter-container-height: '1rem - #{$switch-view-height}';
a.dropdown-item {
padding-right: 1rem;
}
&.is-collapsed {
transform: rotate(90deg) translateX($bucket-width / 2 - $bucket-header-height / 2);
// Using negative margins instead of translateY here to make all other buckets fill the empty space
margin-left: ($bucket-width / 2 - $bucket-header-height / 2) * -1;
margin-right: calc(#{($bucket-width / 2 - $bucket-header-height / 2) * -1} + #{$bucket-right-margin});
cursor: pointer;
.tasks, .bucket-footer {
display: none;
}
}
}
.bucket-header {
@ -240,6 +254,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

@ -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>
<template v-if="!hasTasks">
<p>{{ $t('home.list.newText') }}</p>
@ -23,6 +23,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"/>
</div>
</template>
@ -30,10 +41,13 @@
<script>
import {mapState} from 'vuex'
import ShowTasks from './tasks/ShowTasks'
import {getHistory} from '@/modules/listHistory'
import ListCard from '@/components/list/partials/list-card'
export default {
name: 'Home',
components: {
ListCard,
ShowTasks,
},
data() {
@ -43,25 +57,54 @@ export default {
tasks: [],
}
},
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,
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'
},
}),
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,
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
},
}),
}
}
</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() {
@ -92,7 +93,11 @@ export default {
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
@ -134,11 +139,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)

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

@ -82,6 +82,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">
@ -109,7 +110,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>
@ -156,15 +158,13 @@
<router-view/>
</transition>
</div>
</template>
<script>
import TaskService from '../../../services/task'
import TaskModel from '../../../models/task'
import LabelTaskService from '../../../services/labelTask'
import LabelTask from '../../../models/labelTask'
import LabelModel from '../../../models/label'
import EditTask from '../../../components/tasks/edit-task'
import SingleTaskInList from '../../../components/tasks/partials/singleTaskInList'
@ -173,8 +173,9 @@ import {saveListView} from '@/helpers/saveListView'
import Rights from '../../../models/rights.json'
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',
@ -184,17 +185,16 @@ export default {
isTaskEdit: false,
taskEditTask: TaskModel,
newTaskText: '',
showError: false,
labelTaskService: LabelTaskService,
ctaVisible: false,
}
},
mixins: [
taskList,
createTask,
],
components: {
QuickAddMagic,
Nothing,
FilterPopup,
SingleTaskInList,
@ -202,7 +202,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.
@ -229,148 +228,11 @@ export default {
}
this.showError = false
let task = new TaskModel({title: this.newTaskText, listId: this.$route.params.listId})
this.taskService.create(task)
this.createNewTask(this.newTaskText)
.then(task => {
this.tasks.push(task)
this.sortTasks()
this.newTaskText = ''
// Unlike a proper programming language, Javascript only knows references to objects and does not
// allow you to control what is a reference and what isnt. Because of this we can't just add
// all labels to the task they belong to right after we found and added them to the task since
// the task update method also ensures all data the api sees has the right format. That means
// it processes labels. That processing changes the date format and the label color and makes
// the label pretty much unusable for everything else. Normally, this is not a big deal, because
// the labels on a task get thrown away anyway and replaced with the new models from the api
// when we get the updated answer back. However, in this specific case because we're passing a
// label we obtained from vuex that reference is kept and not thrown away. The task itself gets
// a new label object - you won't notice the bad reference until you want to add the same label
// again and notice it doesn't have a color anymore.
// I think this is what happens: (or rather would happen without the hack I've put in)
// 1. Query the store for a label which matches the name
// 2. Find one - remember, we get only a *reference* to the label from the store, not a new label object.
// (Now there's *two* places with a reference to the same label object: in the store and in the
// variable which holds the label from the search in the store)
// 3. .push the label to the task
// 4. Update the task to remove the labels from the name
// 4.1. The task update processes all labels belonging to that task, changing attributes of our
// label in the process. Because this is a reference, it is also "updated" in the store.
// 5. Get an api response back. The service handler now creates a new label object for all labels
// returned from the api. It will throw away all references to the old label in the process.
// 6. Now we have two objects with the same label data: The old one we originally obtained from
// the store and the one that was created when parsing the api response. The old one was
// modified before sending the api request and thus, our store which still holds a reference
// to the old label now contains old data.
// I guess this is the point where normally the GC would come in and collect the old label
// object if the store wouldn't still hold a reference to it.
//
// Now, as a workaround, I'm putting all new labels added to that task in this separate variable to
// add them only after the task was updated to circumvent the task update service processing the
// label before sending it. Feels more hacky than it probably is.
const newLabels = []
// Check if the task has words starting with ~ in the title and make them to labels
const parts = task.title.split(' ~')
// The first element will always contain the title, even if there is no occurrence of ~
if (parts.length > 1) {
// First, create an unresolved promise for each entry in the array to wait
// until all labels are added to update the task title once again
let labelAddings = []
let labelAddsToWaitFor = []
parts.forEach((p, index) => {
if (index < 1) {
return
}
labelAddsToWaitFor.push(new Promise((resolve, reject) => {
labelAddings.push({resolve: resolve, reject: reject})
}))
})
// Then do everything that is involved in finding, creating and adding the label to the task
parts.forEach((p, index) => {
if (index < 1) {
return
}
// The part up until the next space
const labelTitle = p.split(' ')[0]
// Don't create an empty label
if (labelTitle === '') {
return
}
// Check if the label exists
const label = Object.values(this.$store.state.labels.labels).find(l => {
return l.title.toLowerCase() === labelTitle.toLowerCase()
})
// Label found, use it
if (typeof label !== 'undefined') {
const labelTask = new LabelTask({
taskId: task.id,
labelId: label.id,
})
this.labelTaskService.create(labelTask)
.then(result => {
newLabels.push(label)
// Remove the label text from the task title
task.title = task.title.replace(` ~${labelTitle}`, '')
// Make the promise done (the one with the index 0 does not exist)
labelAddings[index - 1].resolve(result)
})
.catch(e => {
this.error(e)
})
} else {
// label not found, create it
const label = new LabelModel({title: labelTitle})
this.$store.dispatch('labels/createLabel', label)
.then(res => {
const labelTask = new LabelTask({
taskId: task.id,
labelId: res.id,
})
this.labelTaskService.create(labelTask)
.then(result => {
newLabels.push(res)
// Remove the label text from the task title
task.title = task.title.replace(` ~${labelTitle}`, '')
// Make the promise done (the one with the index 0 does not exist)
labelAddings[index - 1].resolve(result)
})
.catch(e => {
this.error(e)
})
})
.catch(e => {
this.error(e)
})
}
})
// This waits to update the task until all labels have been added and the title has
// been modified to remove each label text
Promise.all(labelAddsToWaitFor)
.then(() => {
this.taskService.update(task)
.then(updatedTask => {
updatedTask.labels = newLabels
this.updateTasks(updatedTask)
this.$store.commit(HAS_TASKS, true)
})
.catch(e => {
this.error(e)
})
})
}
})
.catch(e => {
this.error(e)

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

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

824
yarn.lock

File diff suppressed because it is too large Load diff