Merge branch 'main' into feature/login-pages
continuous-integration/drone/pr Build is passing Details

# Conflicts:
#	src/components/misc/no-auth-wrapper.vue
#	src/i18n/lang/en.json
This commit is contained in:
kolaente 2021-12-11 18:21:17 +01:00
commit 8ba8be103a
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
51 changed files with 1983 additions and 1939 deletions

View File

@ -19,16 +19,16 @@
"dependencies": {
"@github/hotkey": "1.6.0",
"@kyvg/vue3-notification": "2.3.4",
"@sentry/tracing": "6.15.0",
"@sentry/vue": "6.15.0",
"@vue/compat": "3.2.23",
"@vueuse/core": "7.1.2",
"@sentry/tracing": "6.16.0",
"@sentry/vue": "6.16.0",
"@vue/compat": "3.2.24",
"@vueuse/core": "7.2.2",
"bulma-css-variables": "0.9.33",
"camel-case": "4.1.2",
"codemirror": "5.64.0",
"copy-to-clipboard": "3.3.1",
"date-fns": "2.26.0",
"dompurify": "2.3.3",
"date-fns": "2.27.0",
"dompurify": "2.3.4",
"easymde": "2.15.0",
"flatpickr": "4.6.9",
"flexsearch": "0.7.21",
@ -36,20 +36,20 @@
"is-touch-device": "1.0.1",
"lodash.clonedeep": "4.5.0",
"lodash.debounce": "4.0.8",
"marked": "4.0.5",
"marked": "4.0.7",
"register-service-worker": "1.7.2",
"snake-case": "3.0.4",
"ufo": "0.7.9",
"v-tooltip": "4.0.0-beta.2",
"vue": "3.2.23",
"vue": "3.2.24",
"vue-advanced-cropper": "2.7.0",
"vue-drag-resize": "2.0.3",
"vue-flatpickr-component": "9.0.5",
"vue-i18n": "9.2.0-beta.22",
"vue-i18n": "9.2.0-beta.23",
"vue-router": "4.0.12",
"vuedraggable": "4.1.0",
"vuex": "4.0.2",
"workbox-precaching": "6.4.1"
"workbox-precaching": "6.4.2"
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.1.0",
@ -59,44 +59,45 @@
"@fortawesome/vue-fontawesome": "3.0.0-5",
"@types/flexsearch": "0.7.2",
"@types/jest": "27.0.3",
"@typescript-eslint/eslint-plugin": "5.4.0",
"@typescript-eslint/parser": "5.4.0",
"@vitejs/plugin-legacy": "1.6.3",
"@vitejs/plugin-vue": "1.10.1",
"@typescript-eslint/eslint-plugin": "5.6.0",
"@typescript-eslint/parser": "5.6.0",
"@vitejs/plugin-legacy": "1.6.4",
"@vitejs/plugin-vue": "1.10.2",
"@vue/eslint-config-typescript": "9.1.0",
"autoprefixer": "10.4.0",
"axios": "0.24.0",
"browserslist": "4.18.1",
"cypress": "8.7.0",
"cypress-file-upload": "5.0.8",
"esbuild": "0.14.0",
"eslint": "8.3.0",
"eslint-plugin-vue": "8.1.1",
"esbuild": "0.14.2",
"eslint": "8.4.1",
"eslint-plugin-vue": "8.2.0",
"express": "4.17.1",
"faker": "5.5.3",
"jest": "27.3.1",
"netlify-cli": "8.0.3",
"jest": "27.4.4",
"netlify-cli": "8.0.20",
"postcss": "8.4.4",
"postcss-preset-env": "7.0.1",
"rollup": "2.60.1",
"rollup": "2.61.0",
"rollup-plugin-visualizer": "5.5.2",
"sass": "1.43.5",
"sass": "1.44.0",
"slugify": "1.6.3",
"ts-jest": "27.0.7",
"typescript": "4.5.2",
"vite": "2.6.14",
"vite-plugin-pwa": "0.11.7",
"vite-svg-loader": "3.1.0",
"vue-tsc": "0.29.6",
"ts-jest": "27.1.1",
"typescript": "4.5.3",
"vite": "2.7.1",
"vite-plugin-pwa": "0.11.11",
"vite-svg-loader": "3.1.1",
"vue-tsc": "0.29.8",
"wait-on": "6.0.0",
"workbox-cli": "6.4.1"
"workbox-cli": "6.4.2"
},
"eslintConfig": {
"root": true,
"env": {
"browser": true,
"es2021": true,
"node": true
"node": true,
"vue/setup-compiler-macros": true
},
"extends": [
"eslint:recommended",
@ -120,6 +121,7 @@
"error",
"never"
],
"vue/script-setup-uses-vars": "error",
"vue/multi-word-component-names": 0
},
"parser": "vue-eslint-parser",

2
run.sh
View File

@ -4,7 +4,7 @@
VIKUNJA_API_URL="${VIKUNJA_API_URL:-"/api/v1"}"
VIKUNJA_SENTRY_ENABLED="${VIKUNJA_SENTRY_ENABLED:-"false"}"
VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://7e684483a06a4225b3e05cc47cae7a11@sentry.kolaente.de/2"}"
VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480"}"
VIKUNJA_HTTP_PORT="${VIKUNJA_HTTP_PORT:-80}"
VIKUNJA_HTTPS_PORT="${VIKUNJA_HTTPS_PORT:-443}"

View File

@ -82,7 +82,7 @@ export default {
this.$route.name === 'labels.index' ||
this.$route.name === 'migrate.start' ||
this.$route.name === 'migrate.wunderlist' ||
this.$route.name === 'user.settings' ||
this.$route.name.startsWith('user.settings') ||
this.$route.name === 'namespaces.index'
) {
return this.$store.dispatch(CURRENT_LIST, null)

View File

@ -20,64 +20,59 @@
: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/>
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']" />
</span>
</div>
<div class="title">{{ list.title }}</div>
</router-link>
</template>
<script>
<script lang="ts" setup>
import {ref, watch} from 'vue'
import {useStore} from 'vuex'
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: {
handler: 'loadBackground',
immediate: true,
},
},
methods: {
async loadBackground() {
if (this.list === null || !this.list.backgroundInformation || this.backgroundLoading) {
return
}
const background = ref(null)
const backgroundLoading = ref(false)
this.backgroundLoading = true
const listService = new ListService()
try {
this.background = await listService.background(this.list)
} 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)
},
const props = defineProps({
list: {
type: Object,
required: true,
},
showArchived: {
default: false,
type: Boolean,
},
})
watch(props.list, loadBackground, { immediate: true })
async function loadBackground() {
if (props.list === null || !props.list.backgroundInformation || backgroundLoading.value) {
return
}
backgroundLoading.value = true
const listService = new ListService()
try {
background.value = await listService.background(props.list)
} finally {
backgroundLoading.value = false
}
}
const store = useStore()
function toggleFavoriteList(list) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
return
}
store.dispatch('lists/toggleListFavorite', list)
}
</script>

View File

@ -1,40 +1,27 @@
<template>
<div
v-if="isDone"
class="is-done"
:class="{ 'is-done--small': variant === variants.SMALL }"
>
{{ $t('task.attributes.done') }}
</div>
v-if="isDone"
class="is-done"
:class="{ 'is-done--small': variant === 'small' }"
>
{{ $t('task.attributes.done') }}
</div>
</template>
<script>
const VARIANTS = {
DEFAULT: 'default',
SMALL: 'small',
}
<script lang="ts" setup>
import {PropType} from 'vue'
type Variants = 'default' | 'small'
export default {
name: 'Done',
data() {
return {
variants: VARIANTS,
}
defineProps({
isDone: {
type: Boolean,
default: false,
},
props: {
isDone: {
type: Boolean,
default: false,
},
variant: {
type: String,
default: VARIANTS.DEFAULT,
validator: (variant) => Object.values(VARIANTS).includes(variant),
},
variant: {
type: String as PropType<Variants>,
default: 'default',
},
}
})
</script>

View File

@ -24,41 +24,39 @@
</div>
</template>
<script>
export default {
name: 'card',
props: {
title: {
type: String,
default: '',
},
padding: {
type: Boolean,
default: true,
},
hasClose: {
type: Boolean,
default: false,
},
closeIcon: {
type: String,
default: 'times',
},
shadow: {
type: Boolean,
default: true,
},
hasContent: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
},
<script setup lang="ts">
defineProps({
title: {
type: String,
default: '',
},
emits: ['close'],
}
padding: {
type: Boolean,
default: true,
},
hasClose: {
type: Boolean,
default: false,
},
closeIcon: {
type: String,
default: 'times',
},
shadow: {
type: Boolean,
default: true,
},
hasContent: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
},
})
defineEmits(['close'])
</script>
<style lang="scss" scoped>

View File

@ -11,18 +11,15 @@
</router-link>
</template>
<script>
export default {
name: 'dropdown-item',
props: {
to: {
required: true,
},
icon: {
type: String,
required: false,
default: '',
},
<script lang="ts" setup>
defineProps({
to: {
required: true,
},
}
icon: {
type: String,
required: false,
default: '',
},
})
</script>

View File

@ -7,16 +7,10 @@
</message>
</template>
<script>
import Message from '@/components/misc/message'
<script lang="ts" setup>
import Message from '@/components/misc/message.vue'
export default {
name: 'error',
components: {Message},
methods: {
reload() {
window.location.reload()
},
},
function reload() {
window.location.reload()
}
</script>

View File

@ -6,16 +6,14 @@
</div>
</template>
<script>
import {mapState} from 'vuex'
<script lang="ts" setup>
import {computed} from 'vue'
import {useStore} from 'vuex'
export default {
name: 'legal',
computed: mapState({
imprintUrl: state => state.config.legal.imprintUrl,
privacyPolicyUrl: state => state.config.legal.privacyPolicyUrl,
}),
}
const store = useStore()
const imprintUrl = computed(() => store.state.config.legal.imprintUrl)
const privacyPolicyUrl = computed(() => store.state.config.legal.privacyPolicyUrl)
</script>
<style lang="scss" scoped>

View File

@ -2,12 +2,6 @@
<div class="loader-container is-loading"></div>
</template>
<script>
export default {
name: 'loading',
}
</script>
<style scoped>
.loader-container {
height: 100%;

View File

@ -6,7 +6,7 @@
</div>
</template>
<script setup>
<script lang="ts" setup>
defineProps({
variant: {
type: String,

View File

@ -4,9 +4,9 @@
<div class="noauth-container">
<section class="image">
<div class="overlay" :class="{'has-message': motd !== ''}">
<message v-if="motd !== ''">
<Message v-if="motd !== ''">
{{ motd }}
</message>
</Message>
<h2 class="title">
{{ $t('misc.welcomeBack') }}
</h2>
@ -20,10 +20,10 @@
</div>
</template>
<script setup>
import Logo from '@/components/home/Logo.vue'
<script lang="ts" setup>
import Logo from '@/components/home/Logo'
import Message from '@/components/misc/message'
import Legal from '../../components/misc/legal'
import Legal from '@/components/misc/legal'
import {useStore} from 'vuex'
import {computed} from 'vue'
@ -41,7 +41,7 @@ const motd = computed(() => store.state.config.motd)
align-items: center;
@media screen and (min-width: $fullhd) {
background: url('@/assets/llama.svg') no-repeat bottom left fixed;
background: url('@/assets/llama.svg?url') no-repeat bottom left fixed;
}
}

View File

@ -21,11 +21,11 @@
<li :key="`page-${i}`" v-for="(p, i) in pages">
<span class="pagination-ellipsis" v-if="p.isEllipsis">&hellip;</span>
<router-link
v-else
class="pagination-link"
:aria-label="'Goto page ' + p.number"
:class="{ 'is-current': p.number === currentPage }"
:to="getRouteForPagination(p.number)"
class="pagination-link"
v-else
>
{{ p.number }}
</router-link>
@ -34,8 +34,10 @@
</nav>
</template>
<script>
function createPagination(totalPages, currentPage) {
<script lang="ts" setup>
import {computed} from 'vue'
function createPagination(totalPages: number, currentPage: number) {
const pages = []
for (let i = 0; i < totalPages; i++) {
@ -79,42 +81,30 @@ function getRouteForPagination(page = 1, type = 'list') {
}
}
export default {
name: 'Pagination',
props: {
totalPages: {
type: Number,
required: true,
},
currentPage: {
type: Number,
default: 0,
},
const props = defineProps({
totalPages: {
type: Number,
required: true,
},
computed: {
pages() {
return createPagination(this.totalPages, this.currentPage)
},
currentPage: {
type: Number,
default: 0,
},
})
methods: {
getRouteForPagination,
},
}
const pages = computed(() => createPagination(props.totalPages, props.currentPage))
</script>
<style lang="scss" scoped>
.pagination {
padding-bottom: 1rem;
}
.pagination-previous,
.pagination-next {
&:not(:disabled):hover {
background: $scheme-main;
cursor: pointer;
}
.pagination-previous,
.pagination-next {
&:not(:disabled):hover {
background: $scheme-main;
cursor: pointer;
}
}
</style>

View File

@ -13,7 +13,7 @@
<section v-else-if="error !== ''">
<no-auth-wrapper>
<card>
<p v-if="error === errorNoApiUrl">
<p v-if="error === ERROR_NO_API_URL">
{{ $t('ready.noApiUrlConfigured') }}
</p>
<message variant="danger" v-else>
@ -40,51 +40,34 @@
</transition>
</template>
<script>
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {useStore} from 'vuex'
import Logo from '@/assets/logo.svg?component'
import ApiConfig from '@/components/misc/api-config'
import Message from '@/components/misc/message'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper'
import {mapState} from 'vuex'
import ApiConfig from '@/components/misc/api-config.vue'
import Message from '@/components/misc/message.vue'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
export default {
name: 'ready',
components: {
Message,
Logo,
NoAuthWrapper,
ApiConfig,
},
data() {
return {
error: '',
errorNoApiUrl: ERROR_NO_API_URL,
}
},
created() {
this.load()
},
computed: {
ready() {
return this.$store.state.vikunjaReady
},
showLoading() {
return !this.ready && this.error === ''
},
...mapState([
'online',
]),
},
methods: {
load() {
this.$store.dispatch('loadApp')
.catch(e => {
this.error = e
})
},
},
const store = useStore()
const ready = computed(() => store.state.vikunjaReady)
const online = computed(() => store.state.online)
const error = ref('')
const showLoading = computed(() => !ready.value && error.value === '')
async function load() {
try {
await store.dispatch('loadApp')
} catch(e: any) {
error.value = e
}
}
load()
</script>
<style lang="scss" scoped>

View File

@ -7,24 +7,21 @@
</component>
</template>
<script>
export default {
name: 'shortcut',
props: {
keys: {
type: Array,
required: true,
},
combination: {
type: String,
default: '+',
},
is: {
type: String,
default: 'div',
},
<script lang="ts" setup>
defineProps({
keys: {
type: Array,
required: true,
},
}
combination: {
type: String,
default: '+',
},
is: {
type: String,
default: 'div',
},
})
</script>
<style lang="scss" scoped>

View File

@ -22,91 +22,89 @@
</a>
</template>
<script>
<script lang="ts" setup>
import {computed, shallowRef} from 'vue'
import {useI18n} from 'vue-i18n'
import SubscriptionService from '@/services/subscription'
import SubscriptionModel from '@/models/subscription'
export default {
name: 'task-subscription',
data() {
return {
subscriptionService: new SubscriptionService(),
}
},
props: {
entity: {
required: true,
type: String,
},
subscription: {
required: true,
},
entityId: {
required: true,
},
isButton: {
type: Boolean,
default: true,
},
},
emits: ['change'],
computed: {
tooltipText() {
if (this.disabled) {
return this.$t('task.subscription.subscribedThroughParent', {
entity: this.entity,
parent: this.subscription.entity,
})
}
import {success} from '@/message'
return this.subscription !== null ?
this.$t('task.subscription.subscribed', {entity: this.entity}) :
this.$t('task.subscription.notSubscribed', {entity: this.entity})
},
buttonText() {
return this.subscription !== null ? this.$t('task.subscription.unsubscribe') : this.$t('task.subscription.subscribe')
},
icon() {
return this.subscription !== null ? ['far', 'bell-slash'] : 'bell'
},
disabled() {
if (this.subscription === null) {
return false
}
return this.subscription.entity !== this.entity
},
const props = defineProps({
entity: {
required: true,
type: String,
},
methods: {
changeSubscription() {
if (this.disabled) {
return
}
if (this.subscription === null) {
this.subscribe()
} else {
this.unsubscribe()
}
},
async subscribe() {
const subscription = new SubscriptionModel({
entity: this.entity,
entityId: this.entityId,
})
await this.subscriptionService.create(subscription)
this.$emit('change', subscription)
this.$message.success({message: this.$t('task.subscription.subscribeSuccess', {entity: this.entity})})
},
async unsubscribe() {
const subscription = new SubscriptionModel({
entity: this.entity,
entityId: this.entityId,
})
await this.subscriptionService.delete(subscription)
this.$emit('change', null)
this.$message.success({message: this.$t('task.subscription.unsubscribeSuccess', {entity: this.entity})})
},
subscription: {
required: true,
},
entityId: {
required: true,
},
isButton: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['change'])
const subscriptionService = shallowRef(new SubscriptionService())
const {t} = useI18n()
const tooltipText = computed(() => {
if (disabled.value) {
return t('task.subscription.subscribedThroughParent', {
entity: props.entity,
parent: props.subscription.entity,
})
}
return props.subscription !== null ?
t('task.subscription.subscribed', {entity: props.entity}) :
t('task.subscription.notSubscribed', {entity: props.entity})
})
const buttonText = computed(() => props.subscription !== null ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe'))
const icon = computed(() => props.subscription !== null ? ['far', 'bell-slash'] : 'bell')
const disabled = computed(() => {
if (props.subscription === null) {
return false
}
return props.subscription.entity !== props.entity
})
function changeSubscription() {
if (disabled.value) {
return
}
if (props.subscription === null) {
subscribe()
} else {
unsubscribe()
}
}
async function subscribe() {
const subscription = new SubscriptionModel({
entity: props.entity,
entityId: props.entityId,
})
await subscriptionService.value.create(subscription)
emit('change', subscription)
success({message: t('task.subscription.subscribeSuccess', {entity: props.entity})})
}
async function unsubscribe() {
const subscription = new SubscriptionModel({
entity: props.entity,
entityId: props.entityId,
})
await subscriptionService.value.delete(subscription)
emit('change', null)
success({message: t('task.subscription.unsubscribeSuccess', {entity: props.entity})})
}
</script>

View File

@ -11,31 +11,28 @@
</div>
</template>
<script>
export default {
name: 'user',
props: {
user: {
required: true,
type: Object,
},
showUsername: {
required: false,
type: Boolean,
default: true,
},
avatarSize: {
required: false,
type: Number,
default: 50,
},
isInline: {
required: false,
type: Boolean,
default: false,
},
<script lang="ts" setup>
defineProps({
user: {
required: true,
type: Object,
},
}
showUsername: {
required: false,
type: Boolean,
default: true,
},
avatarSize: {
required: false,
type: Number,
default: 50,
},
isInline: {
required: false,
type: Boolean,
default: false,
},
})
</script>
<style lang="scss" scoped>

View File

@ -9,32 +9,23 @@
/>
</template>
<script>
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {useStore} from 'vuex'
import Multiselect from '@/components/input/multiselect.vue'
export default {
name: 'namespace-search',
emits: ['selected'],
data() {
return {
query: '',
}
},
components: {
Multiselect,
},
computed: {
namespaces() {
return this.$store.getters['namespaces/searchNamespace'](this.query)
},
},
methods: {
findNamespaces(query) {
this.query = query
},
select(namespace) {
this.$emit('selected', namespace)
},
},
const emit = defineEmits(['selected'])
const query = ref('')
const store = useStore()
const namespaces = computed(() => store.getters['namespaces/searchNamespace'](query.value))
function findNamespaces(newQuery: string) {
query.value = newQuery
}
function select(namespace) {
emit('selected', namespace)
}
</script>

View File

@ -52,30 +52,22 @@
</dropdown>
</template>
<script>
<script setup lang="ts">
import {ref, onMounted} from 'vue'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import TaskSubscription from '@/components/misc/subscription.vue'
export default {
name: 'namespace-settings-dropdown',
data() {
return {
subscription: null,
}
const props = defineProps({
namespace: {
type: Object, // NamespaceModel
required: true,
},
components: {
DropdownItem,
Dropdown,
TaskSubscription,
},
props: {
namespace: {
required: true,
},
},
mounted() {
this.subscription = this.namespace.subscription
},
}
})
const subscription = ref(null)
onMounted(() => {
subscription.value = props.namespace.subscription
})
</script>

View File

@ -3,17 +3,13 @@
<div class="field is-grouped">
<p class="control has-icons-left is-expanded">
<textarea
:disabled="taskService.loading || null"
class="input"
:disabled="taskService.loading || undefined"
class="add-task-textarea input"
:placeholder="$t('list.list.addPlaceholder')"
cols="1"
rows="1"
v-focus
v-model="newTaskTitle"
ref="newTaskInput"
:style="{
'minHeight': `${initialTextAreaHeight}px`,
'height': `calc(${textAreaHeight}px - 2px + 1rem)`
}"
@keyup="errorMessage = ''"
@keydown.enter="handleEnter"
/>
@ -23,7 +19,8 @@
</p>
<p class="control">
<x-button
:disabled="newTaskTitle === '' || taskService.loading || null"
class="add-task-button"
:disabled="newTaskTitle === '' || taskService.loading || undefined"
@click="addTask()"
icon="plus"
:loading="taskService.loading"
@ -35,121 +32,172 @@
<p class="help is-danger" v-if="errorMessage !== ''">
{{ errorMessage }}
</p>
<quick-add-magic v-if="errorMessage === ''"/>
<quick-add-magic v-else />
</div>
</template>
<script>
<script setup lang="ts">
import {ref, watch, unref, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import {useStore} from 'vuex'
import { tryOnMounted, debouncedWatch, useWindowSize, MaybeRef } from '@vueuse/core'
import TaskService from '../../services/task'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
const INPUT_BORDER_PX = 2
const LINE_HEIGHT = 1.5 // using getComputedStyles().lineHeight returns an (wrong) absolute pixel value, we need the factor to do calculations with it.
const cleanupTitle = title => {
function cleanupTitle(title: string) {
return title.replace(/^((\* |\+ |- )(\[ \] )?)/g, '')
}
export default {
name: 'add-task',
emits: ['taskAdded'],
data() {
return {
newTaskTitle: '',
taskService: new TaskService(),
errorMessage: '',
textAreaHeight: null,
initialTextAreaHeight: null,
function useAutoHeightTextarea(value: MaybeRef<string>) {
const textarea = ref<HTMLInputElement>()
const minHeight = ref(0)
// adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34
function resize(textareaEl: HTMLInputElement|undefined) {
if (!textareaEl) return
let empty
// the value here is the the attribute value
if (!textareaEl.value && textareaEl.placeholder) {
empty = true
textareaEl.value = textareaEl.placeholder
}
},
components: {
QuickAddMagic,
},
props: {
defaultPosition: {
type: Number,
required: false,
const cs = getComputedStyle(textareaEl)
textareaEl.style.minHeight = ''
textareaEl.style.height = '0'
const offset = textareaEl.offsetHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
const height = textareaEl.scrollHeight + offset + 'px'
textareaEl.style.height = height
// calculate min-height for the first time
if (!minHeight.value) {
minHeight.value = parseFloat(height)
}
textareaEl.style.minHeight = minHeight.value.toString()
if (empty) {
textareaEl.value = ''
}
}
tryOnMounted(() => {
if (textarea.value) {
// we don't want scrollbars
textarea.value.style.overflowY = 'hidden'
}
})
const { width: windowWidth } = useWindowSize()
debouncedWatch(
windowWidth,
() => resize(textarea.value),
{ debounce: 200 },
)
// It is not possible to get notified of a change of the value attribute of a textarea without workarounds (setTimeout)
// So instead we watch the value that we bound to it.
watch(
() => [textarea.value, unref(value)],
() => resize(textarea.value),
{
immediate: true, // calculate initial size
flush: 'post', // resize after value change is rendered to DOM
},
)
return textarea
}
const emit = defineEmits(['taskAdded'])
const props = defineProps({
defaultPosition: {
type: Number,
required: false,
},
watch: {
newTaskTitle(newVal) {
// Calculating the textarea height based on lines of input in it.
// That is more reliable when removing a line from the input.
const numberOfLines = newVal.split(/\r\n|\r|\n/).length
const fontSize = parseFloat(window.getComputedStyle(this.$refs.newTaskInput, null).getPropertyValue('font-size'))
})
this.textAreaHeight = numberOfLines * fontSize * LINE_HEIGHT + INPUT_BORDER_PX
},
},
mounted() {
this.initialTextAreaHeight = this.$refs.newTaskInput.scrollHeight + INPUT_BORDER_PX
},
methods: {
async addTask() {
if (this.newTaskTitle === '') {
this.errorMessage = this.$t('list.create.addTitleRequired')
return
}
this.errorMessage = ''
const taskService = shallowReactive(new TaskService())
const errorMessage = ref('')
if (this.taskService.loading) {
return
}
const newTaskTitle = ref('')
const newTaskInput = useAutoHeightTextarea(newTaskTitle)
const newTasks = this.newTaskTitle.split(/[\r\n]+/).map(async t => {
const title = cleanupTitle(t)
if (title === '') {
return
}
const { t } = useI18n()
const store = useStore()
const task = await this.$store.dispatch('tasks/createNewTask', {
title,
listId: this.$store.state.auth.settings.defaultListId,
position: this.defaultPosition,
})
this.$emit('taskAdded', task)
return task
})
async function addTask() {
if (newTaskTitle.value === '') {
errorMessage.value = t('list.create.addTitleRequired')
return
}
errorMessage.value = ''
try {
await Promise.all(newTasks)
this.newTaskTitle = ''
} catch (e) {
if (e.message === 'NO_LIST') {
this.errorMessage = this.$t('list.create.addListRequired')
return
}
throw e
}
},
handleEnter(e) {
// when pressing shift + enter we want to continue as we normally would. Otherwise, we want to create
// the new task(s). The vue event modifier don't allow this, hence this method.
if (e.shiftKey) {
return
}
if (taskService.loading) {
return
}
e.preventDefault()
this.addTask()
},
},
const taskTitleBackup = newTaskTitle.value
const newTasks = newTaskTitle.value.split(/[\r\n]+/).map(async uncleanedTitle => {
const title = cleanupTitle(uncleanedTitle)
if (title === '') {
return
}
const task = await store.dispatch('tasks/createNewTask', {
title,
listId: store.state.auth.settings.defaultListId,
position: props.defaultPosition,
})
emit('taskAdded', task)
return task
})
try {
newTaskTitle.value = ''
await Promise.all(newTasks)
} catch (e: any) {
newTaskTitle.value = taskTitleBackup
if (e?.message === 'NO_LIST') {
errorMessage.value = t('list.create.addListRequired')
return
}
throw e
}
}
function handleEnter(e: KeyboardEvent) {
// when pressing shift + enter we want to continue as we normally would. Otherwise, we want to create
// the new task(s). The vue event modifier don't allow this, hence this method.
if (e.shiftKey) {
return
}
e.preventDefault()
addTask()
}
</script>
<style lang="scss" scoped>
.task-add {
margin-bottom: 0;
.button {
height: 2.5rem;
}
}
.input, .textarea {
.add-task-button {
height: 2.5rem;
}
.add-task-textarea {
transition: border-color $transition;
}
.input {
resize: vertical;
resize: none;
}
</style>

View File

@ -2,15 +2,7 @@
<div class="gantt-chart">
<div class="filter-container">
<div class="items">
<x-button
@click.prevent.stop="showTaskFilter = !showTaskFilter"
type="secondary"
icon="filter"
>
{{ $t('filters.title') }}
</x-button>
<filter-popup
:visible="showTaskFilter"
v-model="params"
@update:modelValue="loadTasks()"
/>
@ -237,7 +229,6 @@ export default {
newTaskFieldActive: false,
priorities: priorities,
taskCollectionService: new TaskCollectionService(),
showTaskFilter: false,
params: {
sort_by: ['done', 'id'],

View File

@ -65,6 +65,21 @@
<li>17th ({{ $t('task.quickAddMagic.dateNth', {day: '17'}) }})</li>
</ul>
<p>{{ $t('task.quickAddMagic.dateTime', {time: 'at 17:00', timePM: '5pm'}) }}</p>
<h3>{{ $t('task.quickAddMagic.repeats') }}</h3>
<p>{{ $t('task.quickAddMagic.repeatsDescription', {suffix: 'every {amount} {type}'}) }}</p>
<p>{{ $t('misc.forExample') }}</p>
<ul>
<!-- Not localized because these only work in english -->
<li>Every day</li>
<li>Every 3 days</li>
<li>Every week</li>
<li>Every 2 weeks</li>
<li>Every month</li>
<li>Every 6 months</li>
<li>Every year</li>
<li>Every 2 years</li>
</ul>
</card>
</modal>
</div>

View File

@ -1,9 +1,9 @@
import {computed, watch, readonly} from 'vue'
import {useStorage, createSharedComposable, ColorSchemes, usePreferredColorScheme, tryOnMounted} from '@vueuse/core'
import {useStorage, createSharedComposable, ColorSchema, usePreferredColorScheme, tryOnMounted} from '@vueuse/core'
const STORAGE_KEY = 'color-scheme'
const DEFAULT_COLOR_SCHEME_SETTING: ColorSchemes = 'light'
const DEFAULT_COLOR_SCHEME_SETTING: ColorSchema = 'light'
const CLASS_DARK = 'dark'
const CLASS_LIGHT = 'light'
@ -16,7 +16,7 @@ const CLASS_LIGHT = 'light'
// - value is synced via `createSharedComposable`
// https://github.com/vueuse/vueuse/blob/main/packages/core/useDark/index.ts
export const useColorScheme = createSharedComposable(() => {
const store = useStorage<ColorSchemes>(STORAGE_KEY, DEFAULT_COLOR_SCHEME_SETTING)
const store = useStorage<ColorSchema>(STORAGE_KEY, DEFAULT_COLOR_SCHEME_SETTING)
const preferredColorScheme = usePreferredColorScheme()

View File

@ -21,6 +21,7 @@ Here's some text in between
expect(checkboxes[0]).toBe(0)
expect(checkboxes[1]).toBe(18)
expect(checkboxes[2]).toBe(69)
expect(checkboxes[3]).toBe(90)
})
it('should find one checkbox with *', () => {
const text: string = '* [ ] Lorem Ipsum'

View File

@ -40,7 +40,7 @@ export const findCheckboxesInText = (text: string): number[] => {
return [
...checkboxes.checked,
...checkboxes.unchecked,
].sort()
].sort((a, b) => a < b ? -1 : 1)
}
export const getChecklistStatistics = (text: string): CheckboxStatistics => {

View File

@ -135,18 +135,18 @@ export const getDateFromText = (text: string, now: Date = new Date()) => {
if (result === null) {
// 3. Try parsing the date as "27/01" or "01/27"
const monthNumericRegex:RegExp = /([0-9][0-9]?\/[0-9][0-9]?)/ig
const monthNumericRegex: RegExp = /([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]}`
if(result === null) {
if (result === null) {
return {
foundText,
date: null,
}
}
foundText = results === null ? '' : results[0]
if (result === null || isNaN(new Date(result).getTime())) {
result = results === null ? null : `${results[0]}/${now.getFullYear()}`
@ -280,7 +280,7 @@ const getDateFromWeekday = (text: string): dateFoundResult => {
if (foundText.endsWith(' ')) {
foundText = foundText.substr(0, foundText.length - 1)
}
return {
foundText: foundText,
date: date,
@ -297,19 +297,20 @@ const getDayFromText = (text: string) => {
}
}
const date = new Date()
const now = new Date()
const date = new Date(now)
const day = parseInt(results[0])
date.setDate(day)
// If the parsed day is the 31st but the next month only has 30 days, setting the day to 31 will "overflow" the
// date to the next month, but the first.
// This would look like a very weired bug. Now, to prevent that, we check if the day is the same as parsed after
// setting it for the first time and set it again if it isn't - that would mean the month overflowed.
if(day === 31 && date.getDate() !== day) {
if (day === 31 && date.getDate() !== day) {
date.setDate(day)
}
if (date < new Date()) {
if (date < now) {
date.setMonth(date.getMonth() + 1)
}

View File

@ -102,6 +102,7 @@
"title": "Avatar",
"initials": "Iniciály",
"gravatar": "Gravatar",
"marble": "Mramor",
"upload": "Nahrát",
"uploadAvatar": "Nahrát avatara",
"statusUpdateSuccess": "Stav avatara byl úspěšně aktualizován!",
@ -114,12 +115,12 @@
"vikunja": "Vikunja"
},
"appearance": {
"title": "Color Scheme",
"setSuccess": "Saved change of color scheme to {colorScheme}",
"title": "Barevné schéma",
"setSuccess": "Změna barevného schématu na {colorScheme} byla uložena",
"colorScheme": {
"light": "Light",
"system": "System",
"dark": "Dark"
"light": "Světlý",
"system": "Systém",
"dark": "Tmavý"
}
}
},
@ -471,7 +472,8 @@
"close": "Zavřít",
"download": "Stáhnout",
"showMenu": "Zobrazit nabídku",
"hideMenu": "Skrýt nabídku"
"hideMenu": "Skrýt nabídku",
"forExample": "Například:"
},
"input": {
"resetColor": "Obnovit barvu",
@ -556,7 +558,7 @@
"text2": "Tímto také odstraníte všechny přílohy, připomenutí a vztahy spojené s tímto úkolem a nelze je vrátit zpět!"
},
"actions": {
"assign": "Assign to a user",
"assign": "Přiřadit uživateli",
"label": "Přidat štítky",
"priority": "Nastavit prioritu",
"dueDate": "Nastavit termín",
@ -720,7 +722,9 @@
"dateWeekday": "každý pracovní den použije další datum s tímto datem",
"dateCurrentYear": "použije aktuální rok",
"dateNth": "použije {day}. den aktuálního měsíce",
"dateTime": "Pro nastavení času zkombinujte libovolný formát data s \"{time}\" (nebo {timePM})."
"dateTime": "Pro nastavení času zkombinujte libovolný formát data s \"{time}\" (nebo {timePM}).",
"repeats": "Opakující se úkoly",
"repeatsDescription": "Chcete-li nastavit úkol jako opakující se v intervalu, stačí přidat '{suffix}' do textu úkolu. Množství musí být číslo a lze ji vynechat, aby byl použit pouze typ (viz příklady)."
}
},
"team": {
@ -775,7 +779,7 @@
"task": {
"title": "Stránka úkolů",
"done": "Označit úkol jako hotový",
"assign": "Assign to a user",
"assign": "Přiřadit uživateli",
"labels": "Přidat štítky k tomuto úkolu",
"dueDate": "Změnit termín tohoto úkolu",
"attachment": "Přidat přílohu k tomuto úkolu",
@ -901,7 +905,7 @@
"5010": "Tento tým nemá k tomuto prostoru přístup.",
"5011": "Tento uživatel již má přístup k tomuto prostoru.",
"5012": "Prostor je archivován, a proto je přístupný pouze pro čtení.",
"6001": "The team name cannot be empty.",
"6001": "Název týmu nemůže být prázdný.",
"6002": "Tým neexistuje.",
"6004": "Tým již má přístup k tomuto prostoru nebo seznamu.",
"6005": "Uživatel je již členem tohoto týmu.",

View File

@ -102,6 +102,7 @@
"title": "Avatar",
"initials": "Initialen",
"gravatar": "Gravatar",
"marble": "Marble",
"upload": "Hochladen",
"uploadAvatar": "Avatar hochladen",
"statusUpdateSuccess": "Avatar-Status wurde erfolgreich aktualisiert.",
@ -471,7 +472,8 @@
"close": "Schließen",
"download": "Herunterladen",
"showMenu": "Menü anzeigen",
"hideMenu": "Menü ausblenden"
"hideMenu": "Menü ausblenden",
"forExample": "For example:"
},
"input": {
"resetColor": "Farbe zurücksetzen",
@ -720,7 +722,9 @@
"dateWeekday": "jeder Wochentag, wird das nächste Datum mit diesem Tag verwenden",
"dateCurrentYear": "wird das laufende Jahr nutzen",
"dateNth": "wird den {day}. des aktuellen Monats verwenden",
"dateTime": "Kombiniere eines der Datumsformate mit \"{time}\" (oder {timePM}), um eine Zeit festzulegen."
"dateTime": "Kombiniere eines der Datumsformate mit \"{time}\" (oder {timePM}), um eine Zeit festzulegen.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
}
},
"team": {

View File

@ -102,6 +102,7 @@
"title": "Herr Der Elemente",
"initials": "Initialä",
"gravatar": "Gravatar",
"marble": "Marble",
"upload": "Ufeladä",
"uploadAvatar": "Profiilbild ufeladä",
"statusUpdateSuccess": "Avatar Zuestand erfolgriich aktualisiert!",
@ -471,7 +472,8 @@
"close": "Schlüüse",
"download": "Herunterladen",
"showMenu": "Menü anzeigen",
"hideMenu": "Menü ausblenden"
"hideMenu": "Menü ausblenden",
"forExample": "For example:"
},
"input": {
"resetColor": "Farb zruggsetze",
@ -720,7 +722,9 @@
"dateWeekday": "jede Wuchetaag wird nimmt s'negste Datum mit dem Datum",
"dateCurrentYear": "nimmt das laufende Jahr",
"dateNth": "nimmt de {day}ti vom jetzige Monet",
"dateTime": "Kombiniere irgendeis vo dene Datumsformat mit \"{time}\" (oder {timePM}) um e Ziit z'setze."
"dateTime": "Kombiniere irgendeis vo dene Datumsformat mit \"{time}\" (oder {timePM}) um e Ziit z'setze.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
}
},
"team": {

View File

@ -103,6 +103,7 @@
"title": "Avatar",
"initials": "Initials",
"gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload",
"uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!",
@ -473,6 +474,7 @@
"download": "Download",
"showMenu": "Show the menu",
"hideMenu": "Hide the menu",
"forExample": "For example:",
"welcomeBack": "Welcome Back!"
},
"input": {
@ -722,7 +724,9 @@
"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."
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
}
},
"team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar",
"initials": "Initials",
"gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload",
"uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Close",
"download": "Download",
"showMenu": "Show the menu",
"hideMenu": "Hide the menu"
"hideMenu": "Hide the menu",
"forExample": "For example:"
},
"input": {
"resetColor": "Reset Color",
@ -720,7 +722,9 @@
"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."
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
}
},
"team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar",
"initials": "Initiales",
"gravatar": "Gravatar",
"marble": "Marble",
"upload": "Téléverser",
"uploadAvatar": "Téléverser lavatar",
"statusUpdateSuccess": "Statut de lavatar mis à jour.",
@ -471,7 +472,8 @@
"close": "Fermer",
"download": "Télécharger",
"showMenu": "Afficher le menu",
"hideMenu": "Masquer le menu"
"hideMenu": "Masquer le menu",
"forExample": "For example:"
},
"input": {
"resetColor": "Réinitialiser la couleur",
@ -720,7 +722,9 @@
"dateWeekday": "nimporte quel jour de la semaine, utilisera la date suivante avec cette date",
"dateCurrentYear": "utilisera lannée en cours",
"dateNth": "utilisera le {day}e du mois en cours",
"dateTime": "Combinez nimporte lequel des formats de date avec « {time} » (ou {timePM}) pour définir une heure."
"dateTime": "Combinez nimporte lequel des formats de date avec « {time} » (ou {timePM}) pour définir une heure.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
}
},
"team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar",
"initials": "Iniziali",
"gravatar": "Gravatar",
"marble": "Marble",
"upload": "Carica",
"uploadAvatar": "Carica Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Chiudi",
"download": "Scarica",
"showMenu": "Show the menu",
"hideMenu": "Hide the menu"
"hideMenu": "Hide the menu",
"forExample": "For example:"
},
"input": {
"resetColor": "Ripristina Colore",
@ -720,7 +722,9 @@
"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."
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
}
},
"team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar",
"initials": "Initials",
"gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload",
"uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Close",
"download": "Download",
"showMenu": "Show the menu",
"hideMenu": "Hide the menu"
"hideMenu": "Hide the menu",
"forExample": "For example:"
},
"input": {
"resetColor": "Reset Color",
@ -720,7 +722,9 @@
"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."
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
}
},
"team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar",
"initials": "Initials",
"gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload",
"uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Close",
"download": "Download",
"showMenu": "Show the menu",
"hideMenu": "Hide the menu"
"hideMenu": "Hide the menu",
"forExample": "For example:"
},
"input": {
"resetColor": "Reset Color",
@ -720,7 +722,9 @@
"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."
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
}
},
"team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar",
"initials": "Initials",
"gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload",
"uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Close",
"download": "Download",
"showMenu": "Show the menu",
"hideMenu": "Hide the menu"
"hideMenu": "Hide the menu",
"forExample": "For example:"
},
"input": {
"resetColor": "Reset Color",
@ -720,7 +722,9 @@
"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."
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
}
},
"team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar",
"initials": "Initials",
"gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload",
"uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Close",
"download": "Download",
"showMenu": "Show the menu",
"hideMenu": "Hide the menu"
"hideMenu": "Hide the menu",
"forExample": "For example:"
},
"input": {
"resetColor": "Reset Color",
@ -720,7 +722,9 @@
"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."
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
}
},
"team": {

View File

@ -102,6 +102,7 @@
"title": "Аватар",
"initials": "Инициалы",
"gravatar": "Gravatar",
"marble": "Marble",
"upload": "Загрузить файл",
"uploadAvatar": "Загрузить аватар",
"statusUpdateSuccess": "Статус аватара обновлён.",
@ -471,7 +472,8 @@
"close": "Закрыть",
"download": "Скачать",
"showMenu": "Show the menu",
"hideMenu": "Hide the menu"
"hideMenu": "Hide the menu",
"forExample": "For example:"
},
"input": {
"resetColor": "Сбросить цвет",
@ -720,7 +722,9 @@
"dateWeekday": "любой день недели, будет использовать следующую дату",
"dateCurrentYear": "текущий год",
"dateNth": "будет использовать {day}е текущего месяца",
"dateTime": "Комбинируй любой из этих форматов даты с «{time}» (или {timePM}), чтобы установить время."
"dateTime": "Комбинируй любой из этих форматов даты с «{time}» (или {timePM}), чтобы установить время.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
}
},
"team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar",
"initials": "Initials",
"gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload",
"uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Close",
"download": "Download",
"showMenu": "Show the menu",
"hideMenu": "Hide the menu"
"hideMenu": "Hide the menu",
"forExample": "For example:"
},
"input": {
"resetColor": "Reset Color",
@ -720,7 +722,9 @@
"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."
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
}
},
"team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar",
"initials": "Initials",
"gravatar": "Gravatar",
"marble": "Marble",
"upload": "Upload",
"uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!",
@ -471,7 +472,8 @@
"close": "Close",
"download": "Download",
"showMenu": "Show the menu",
"hideMenu": "Hide the menu"
"hideMenu": "Hide the menu",
"forExample": "For example:"
},
"input": {
"resetColor": "Reset Color",
@ -720,7 +722,9 @@
"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."
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
}
},
"team": {

View File

@ -102,6 +102,7 @@
"title": "Avatar",
"initials": "Chữ cái viết tắt",
"gravatar": "Gravatar",
"marble": "Marble",
"upload": "Tải lên",
"uploadAvatar": "Tải lên Avatar",
"statusUpdateSuccess": "Avatar đã được cập nhật!",
@ -471,7 +472,8 @@
"close": "Đóng",
"download": "Tải về",
"showMenu": "Hiển thị menu",
"hideMenu": "Ẩn menu"
"hideMenu": "Ẩn menu",
"forExample": "For example:"
},
"input": {
"resetColor": "Đặt lại màu",
@ -720,7 +722,9 @@
"dateWeekday": "bất kỳ ngày nào trong tuần, sẽ sử dụng ngày tiếp theo với ngày đó",
"dateCurrentYear": "sẽ sử dụng năm hiện tại",
"dateNth": "sẽ sử dụng ngày {day} của tháng hiện tại",
"dateTime": "Kết hợp bất kì đinh dạng ngày với \"{time}\" (hoặc {timePM}) để thiết lập thời gian."
"dateTime": "Kết hợp bất kì đinh dạng ngày với \"{time}\" (hoặc {timePM}) để thiết lập thời gian.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
}
},
"team": {

View File

@ -7,14 +7,14 @@ describe('Parse Task Text', () => {
it('should return text with no intents as is', () => {
expect(parseTaskText('Lorem Ipsum').text).toBe('Lorem Ipsum')
})
it('should not parse text when disabled', () => {
const text = 'Lorem Ipsum today *label +list !2 @user'
const result = parseTaskText(text, 'disabled')
expect(result.text).toBe(text)
})
it('should parse text in todoist mode when configured', () => {
const result = parseTaskText('Lorem Ipsum today @label #list !2 +user', 'todoist')
@ -208,7 +208,11 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum')
expect(result.date.getDate()).toBe(date.getDate())
expect(result.date.getMonth()).toBe(result.date.getDate() === 31 ? date.getMonth() + 2 : date.getMonth() + 1)
const nextMonthWithDate = result.date.getDate() === 31
? (date.getMonth() + 2) % 12
: (date.getMonth() + 1) % 12
expect(result.date.getMonth()).toBe(nextMonthWithDate)
})
it('should recognize dates of the month in the future', () => {
const nextDay = new Date(+new Date() + 60 * 60 * 24 * 1000)
@ -506,4 +510,54 @@ describe('Parse Task Text', () => {
expect(result.assignees[0]).toBe('user with long name')
})
})
describe('Recurring Dates', () => {
const cases = {
'every 1 hour': {type: 'hours', amount: 1},
'every hour': {type: 'hours', amount: 1},
'every 5 hours': {type: 'hours', amount: 5},
'every 12 hours': {type: 'hours', amount: 12},
'every day': {type: 'days', amount: 1},
'every 1 day': {type: 'days', amount: 1},
'every 2 days': {type: 'days', amount: 2},
'every week': {type: 'weeks', amount: 1},
'every 1 week': {type: 'weeks', amount: 1},
'every 3 weeks': {type: 'weeks', amount: 3},
'every month': {type: 'months', amount: 1},
'every 1 month': {type: 'months', amount: 1},
'every 2 months': {type: 'months', amount: 2},
'every year': {type: 'years', amount: 1},
'every 1 year': {type: 'years', amount: 1},
'every 4 years': {type: 'years', amount: 4},
'anually': {type: 'years', amount: 1},
'bianually': {type: 'months', amount: 6},
'semiannually': {type: 'months', amount: 6},
'biennially': {type: 'years', amount: 2},
'daily': {type: 'days', amount: 1},
'hourly': {type: 'hours', amount: 1},
'monthly': {type: 'months', amount: 1},
'weekly': {type: 'weeks', amount: 1},
'yearly': {type: 'years', amount: 1},
'every one hour': {type: 'hours', amount: 1}, // maybe unnesecary but better to include it for completeness sake
'every two hours': {type: 'hours', amount: 2},
'every three hours': {type: 'hours', amount: 3},
'every four hours': {type: 'hours', amount: 4},
'every five hours': {type: 'hours', amount: 5},
'every six hours': {type: 'hours', amount: 6},
'every seven hours': {type: 'hours', amount: 7},
'every eight hours': {type: 'hours', amount: 8},
'every nine hours': {type: 'hours', amount: 9},
'every ten hours': {type: 'hours', amount: 10},
}
for (const c in cases) {
it(`should parse ${c} as recurring date every ${cases[c].amount} ${cases[c].type}`, () => {
const result = parseTaskText(`Lorem Ipsum ${c}`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.repeats.type).toBe(cases[c].type)
expect(result.repeats.amount).toBe(cases[c].amount)
})
}
})
})

View File

@ -38,6 +38,24 @@ interface Priorites {
DO_NOW: number,
}
enum RepeatType {
Hours = 'hours',
Days = 'days',
Weeks = 'weeks',
Months = 'months',
Years = 'years',
}
interface Repeats {
type: RepeatType,
amount: number,
}
interface repeatParsedResult {
textWithoutMatched: string,
repeats: Repeats | null,
}
interface ParsedTaskText {
text: string,
date: Date | null,
@ -45,6 +63,7 @@ interface ParsedTaskText {
list: string | null,
priority: number | null,
assignees: string[],
repeats: Repeats | null,
}
interface Prefixes {
@ -67,6 +86,7 @@ export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMod
list: null,
priority: null,
assignees: [],
repeats: null,
}
const prefixes = PREFIXES[prefixesMode]
@ -83,7 +103,11 @@ export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMod
result.assignees = getItemsFromPrefix(text, prefixes.assignee)
const {newText, date} = parseDate(text)
const {textWithoutMatched, repeats} = getRepeats(text)
result.text = textWithoutMatched
result.repeats = repeats
const {newText, date} = parseDate(result.text)
result.text = newText
result.date = date
@ -132,6 +156,113 @@ const getPriority = (text: string, prefix: string): number | null => {
return null
}
const getRepeats = (text: string): repeatParsedResult => {
const regex = /((every|each) (([0-9]+|one|two|three|four|five|six|seven|eight|nine|ten) )?(hours?|days?|weeks?|months?|years?))|anually|bianually|semiannually|biennially|daily|hourly|monthly|weekly|yearly/ig
const results = regex.exec(text)
if (results === null) {
return {
textWithoutMatched: text,
repeats: null,
}
}
let amount = 1
switch (results[3] ? results[3].trim() : undefined) {
case 'one':
amount = 1
break
case 'two':
amount = 2
break
case 'three':
amount = 3
break
case 'four':
amount = 4
break
case 'five':
amount = 5
break
case 'six':
amount = 6
break
case 'seven':
amount = 7
break
case 'eight':
amount = 8
break
case 'nine':
amount = 9
break
case 'ten':
amount = 10
break
default:
amount = results[3] ? parseInt(results[3]) : 1
}
let type: RepeatType = RepeatType.Hours
switch (results[0]) {
case 'biennially':
type = RepeatType.Years
amount = 2
break
case 'bianually':
case 'semiannually':
type = RepeatType.Months
amount = 6
break
case 'yearly':
case 'anually':
type = RepeatType.Years
break
case 'daily':
type = RepeatType.Days
break
case 'hourly':
type = RepeatType.Hours
break
case 'monthly':
type = RepeatType.Months
break
case 'weekly':
type = RepeatType.Weeks
break
default:
switch (results[5]) {
case 'hour':
case 'hours':
type = RepeatType.Hours
break
case 'day':
case 'days':
type = RepeatType.Days
break
case 'week':
case 'weeks':
type = RepeatType.Weeks
break
case 'month':
case 'months':
type = RepeatType.Months
break
case 'year':
case 'years':
type = RepeatType.Years
break
}
}
return {
textWithoutMatched: text.replace(results[0], ''),
repeats: {
amount,
type,
},
}
}
const cleanupItemText = (text: string, items: string[], prefix: string): string => {
items.forEach(l => {
text = text

View File

@ -69,7 +69,7 @@ export default class TaskService extends AbstractService {
// Make the repeating amount to seconds
let repeatAfterSeconds = 0
if (model.repeatAfter.amount !== null || model.repeatAfter.amount !== 0) {
if (model.repeatAfter !== null && (model.repeatAfter.amount !== null || model.repeatAfter.amount !== 0)) {
switch (model.repeatAfter.type) {
case 'hours':
repeatAfterSeconds = model.repeatAfter.amount * 60 * 60

View File

@ -292,6 +292,7 @@ export default {
bucketId: bucketId || 0,
position,
})
task.repeatAfter = parsedTask.repeats
const taskService = new TaskService()
const createdTask = await taskService.create(task)

View File

@ -19,14 +19,12 @@ $mobile: math.div($tablet, 2);
$family-sans-serif: 'Open Sans', Helvetica, Arial, sans-serif;
$vikunja-font: 'Quicksand', sans-serif;
$thickness: 1px;
$pagination-current-border: var(--primary);
$navbar-item-active-color: var(--primary);
$dropdown-content-shadow: none;
$dropdown-item-hover-background-color: var(--grey-100);
$bulmaswatch-import-font: false !default;
$site-background: var(--grey-100);
$transition-duration: 150ms;

View File

@ -40,6 +40,7 @@
}
.select select {
$thickness: 1px;
border-width: $thickness;
&:not([multiple]) {

View File

@ -1,7 +1,7 @@
/* eslint-disable no-console */
/* eslint-disable no-undef */
const workboxVersion = 'v6.4.1'
const workboxVersion = 'v6.4.2'
importScripts( `/workbox-${workboxVersion}/workbox-sw.js`)
workbox.setConfig({
modulePathPrefix: `/workbox-${workboxVersion}`,

View File

@ -47,7 +47,6 @@
v-for="(l, k) in listHistory"
:key="`l${k}`"
:list="l"
:background-resolver="() => null"
/>
</div>
</div>
@ -55,92 +54,66 @@
</div>
</template>
<script>
import {mapState} from 'vuex'
import Message from '@/components/misc/message'
import ShowTasks from './tasks/ShowTasks.vue'
import {getHistory} from '../modules/listHistory'
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {useStore} from 'vuex'
import {useNow} from '@vueuse/core'
import Message from '@/components/misc/message.vue'
import ShowTasks from '@/views/tasks/ShowTasks.vue'
import ListCard from '@/components/list/partials/list-card.vue'
import AddTask from '../components/tasks/add-task.vue'
import {LOADING, LOADING_MODULE} from '../store/mutation-types'
import {parseDateOrNull} from '../helpers/parseDateOrNull'
import AddTask from '@/components/tasks/add-task.vue'
export default {
name: 'Home',
components: {
Message,
ListCard,
ShowTasks,
AddTask,
},
data() {
return {
currentDate: new Date(),
tasks: [],
showTasksKey: 0,
}
},
computed: {
welcome() {
const now = new Date()
import {getHistory} from '@/modules/listHistory'
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
import {formatDateShort, formatDateSince} from '@/helpers/time/formatDate'
if (now.getHours() < 5) {
return 'Night'
}
const now = useNow()
const welcome = computed(() => {
const hours = new Date(now.value).getHours()
if (now.getHours() < 11) {
return 'Morning'
}
if (hours < 5) {
return 'Night'
}
if (now.getHours() < 18) {
return 'Day'
}
if (hours < 11) {
return 'Morning'
}
if (now.getHours() < 23) {
return 'Evening'
}
if (hours < 18) {
return 'Day'
}
return 'Night'
},
listHistory() {
const history = getHistory()
return history.map(l => {
return this.$store.getters['lists/getListById'](l.id)
}).filter(l => l !== null)
},
...mapState({
migratorsEnabled: state =>
state.config.availableMigrators !== null &&
state.config.availableMigrators.length > 0,
authenticated: state => state.auth.authenticated,
userInfo: state => state.auth.info,
hasTasks: state => state.hasTasks,
defaultListId: state => state.auth.defaultListId,
defaultNamespaceId: state => {
if (state.namespaces.namespaces.length === 0) {
return 0
}
if (hours < 23) {
return 'Evening'
}
return state.namespaces.namespaces[0].id
},
hasLists: state => {
if (state.namespaces.namespaces.length === 0) {
return false
}
return 'Night'
})
return state.namespaces.namespaces[0].lists.length > 0
},
loading: state => state[LOADING] && state[LOADING_MODULE] === 'tasks',
deletionScheduledAt: state => parseDateOrNull(state.auth.info?.deletionScheduledAt),
}),
},
methods: {
// This is to reload the tasks list after adding a new task through the global task add.
// FIXME: Should use vuex (somehow?)
updateTaskList() {
this.showTasksKey++
},
},
const store = useStore()
const listHistory = computed(() => {
const history = getHistory()
return history.map(l => {
return store.getters['lists/getListById'](l.id)
}).filter(l => l !== null)
})
const migratorsEnabled = computed(() => store.state.config.availableMigrators?.length > 0)
const userInfo = computed(() => store.state.auth.info)
const hasTasks = computed(() => store.state.hasTasks)
const defaultListId = computed(() => store.state.auth.defaultListId)
const defaultNamespaceId = computed(() => store.state.namespaces.namespaces?.[0]?.id || 0)
const hasLists = computed (() => store.state.namespaces.namespaces?.[0]?.lists.length > 0)
const loading = computed(() => store.state.loading && store.state.loadingModule === 'tasks')
const deletionScheduledAt = computed(() => parseDateOrNull(store.state.auth.info?.deletionScheduledAt))
// This is to reload the tasks list after adding a new task through the global task add.
// FIXME: Should use vuex (somehow?)
const showTasksKey = ref(0)
function updateTaskList() {
showTasksKey.value++
}
</script>

View File

@ -13,6 +13,10 @@
<input name="avatarProvider" type="radio" v-model="avatarProvider" value="gravatar"/>
{{ $t('user.settings.avatar.gravatar') }}
</label>
<label class="radio">
<input name="avatarProvider" type="radio" v-model="avatarProvider" value="marble"/>
{{ $t('user.settings.avatar.marble') }}
</label>
<label class="radio">
<input name="avatarProvider" type="radio" v-model="avatarProvider" value="upload"/>
{{ $t('user.settings.avatar.upload') }}

2391
yarn.lock

File diff suppressed because it is too large Load Diff