Quick add magic for tasks (#570)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#570
Co-authored-by: konrad <konrad@kola-entertainments.de>
Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2021-07-05 10:29:04 +00:00
parent bc73d75a9b
commit c8209c6c10
18 changed files with 1136 additions and 220 deletions

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

@ -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">
@ -53,12 +55,13 @@
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'
const TYPE_LIST = 'list'
const TYPE_TASK = 'task'
@ -77,6 +80,7 @@ const SEARCH_MODE_TEAMS = 'teams'
export default {
name: 'quick-actions',
components: {QuickAddMagic},
data() {
return {
query: '',
@ -91,6 +95,9 @@ export default {
teamService: null,
}
},
mixins: [
createTask,
],
computed: {
active() {
const active = this.$store.state[QUICK_ACTIONS_ACTIVE]
@ -222,6 +229,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 +358,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

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

@ -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 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,409 @@
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()}`)
expect(+new Date(result.date)).toBeGreaterThan(+new Date() - 10) // In on thursdays, this may be different by one second and thus fails the test
})
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')
expect(+new Date(result.date)).toBeGreaterThan(+new Date() - 10) // In on thursdays, this may be different by one second and thus fails the test
})
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,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

@ -597,6 +597,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": {

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

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

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

@ -71,7 +71,9 @@
</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
@ -264,7 +266,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 +282,7 @@ 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'
export default {
name: 'Kanban',
@ -328,6 +330,9 @@ export default {
filtersChanged: false, // To trigger a reload of the board
}
},
mixins: [
createTask,
],
created() {
this.taskService = new TaskService()
this.loadBuckets()
@ -488,24 +493,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 +502,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() {

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)