Merge branch 'main' into translations
# Conflicts: # src/i18n/lang/en.json
This commit is contained in:
commit
86ca6c29c5
53 changed files with 2298 additions and 891 deletions
|
@ -1,5 +1,5 @@
|
|||
# Stage 1: Build application
|
||||
FROM node:16.3.0 AS compile-image
|
||||
FROM node:16 AS compile-image
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
35
cypress/integration/misc/home.spec.js
Normal file
35
cypress/integration/misc/home.spec.js
Normal 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')
|
||||
})
|
||||
})
|
|
@ -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}!`)
|
||||
})
|
||||
|
||||
|
|
|
@ -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}!`)
|
||||
})
|
||||
|
||||
|
|
17
package.json
17
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
89
src/components/list/partials/list-card.vue
Normal file
89
src/components/list/partials/list-card.vue
Normal 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>
|
|
@ -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')">
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}})
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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) {
|
||||
|
|
124
src/components/tasks/mixins/createTask.js
Normal file
124
src/components/tasks/mixins/createTask.js
Normal 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()
|
||||
},
|
||||
},
|
||||
}
|
|
@ -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>
|
||||
|
|
82
src/components/tasks/partials/quick-add-magic.vue
Normal file
82
src/components/tasks/partials/quick-add-magic.vue
Normal 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>
|
|
@ -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
|
||||
}
|
||||
|
|
3
src/helpers/generateAttachmentUrl.js
Normal file
3
src/helpers/generateAttachmentUrl.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const generateAttachmentUrl = (taskId, attachmentId) => {
|
||||
return `${window.API_URL}/tasks/${taskId}/attachments/${attachmentId}`
|
||||
}
|
6
src/helpers/getListTitle.js
Normal file
6
src/helpers/getListTitle.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export const getListTitle = (l, $t) => {
|
||||
if (l.id === -1) {
|
||||
return $t('list.pseudo.favorites.title');
|
||||
}
|
||||
return l.title;
|
||||
}
|
12
src/helpers/getNamespaceTitle.js
Normal file
12
src/helpers/getNamespaceTitle.js
Normal 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;
|
||||
}
|
|
@ -1,4 +1,8 @@
|
|||
export const parseDateOrNull = date => {
|
||||
if (date instanceof Date) {
|
||||
return date
|
||||
}
|
||||
|
||||
if (date && !date.startsWith('0001')) {
|
||||
return new Date(date)
|
||||
}
|
||||
|
|
103
src/helpers/parseTaskText.js
Normal file
103
src/helpers/parseTaskText.js
Normal 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
|
||||
}
|
407
src/helpers/parseTaskText.test.js
Normal file
407
src/helpers/parseTaskText.test.js
Normal 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
15
src/helpers/replaceAll.js
Normal 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);
|
||||
}
|
30
src/helpers/saveCollapsedBucketState.js
Normal file
30
src/helpers/saveCollapsedBucketState.js
Normal 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 {}
|
||||
}
|
|
@ -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})
|
||||
|
|
290
src/helpers/time/parseDate.js
Normal file
290
src/helpers/time/parseDate.js
Normal 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
|
||||
}
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
|
|
26
src/modules/listHistory.js
Normal file
26
src/modules/listHistory.js
Normal 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))
|
||||
}
|
67
src/modules/listHistory.test.js
Normal file
67
src/modules/listHistory.test.js
Normal 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}]')
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
})
|
|
@ -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
|
||||
|
@ -140,4 +137,4 @@ export const store = new Vuex.Store({
|
|||
state.quickActionsActive = active
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
|
@ -24,3 +24,4 @@
|
|||
@import 'datepicker';
|
||||
@import 'notifications';
|
||||
@import 'quick-actions';
|
||||
@import 'hint-modal';
|
||||
|
|
43
src/styles/components/hint-modal.scss
Normal file
43
src/styles/components/hint-modal.scss
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -144,3 +144,7 @@ button.table {
|
|||
box-shadow: $shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.is-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
|
51
src/views/About.vue
Normal file
51
src/views/About.vue
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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')">
|
||||
|
|
|
@ -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)
|
||||
|
@ -149,4 +155,4 @@ export default {
|
|||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
Loading…
Reference in a new issue