Merge branch 'main' into feature/ganttastic

# Conflicts:
#	pnpm-lock.yaml
#	src/components/tasks/gantt-component.vue
This commit is contained in:
kolaente 2022-10-02 00:24:10 +02:00
commit 1bbdd3b117
No known key found for this signature in database
GPG key ID: F40E70337AB24C9B
60 changed files with 1421 additions and 1435 deletions

58
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View file

@ -0,0 +1,58 @@
name: Bug Report
description: Found something you weren't expecting? Report it here!
labels: kind/bug
body:
- type: markdown
attributes:
value: |
NOTE: If your issue is a security concern, please send an email to security@vikunja.io instead of opening a public issue.
- type: markdown
attributes:
value: |
Please fill out this issue template to report a bug.
1. If you want to propose a new feature, please open a discussion thread in the forum: https://community.vikunja.io
2. Please ask questions or configuration/deploy problems on our [Matrix Room](https://matrix.to/#/#vikunja:matrix.org) or forum (https://community.vikunja.io).
3. Make sure you are using the latest release and
take a moment to check that your issue hasn't been reported before.
4. Please give all relevant information below for bug reports, because
incomplete details will be handled as an invalid report and closed.
- type: textarea
id: description
attributes:
label: Description
description: |
Please provide a description of your issue here, with a URL if you were able to reproduce the issue (see below).
- type: input
id: frontend-version
attributes:
label: Vikunja Frontend Version
description: Vikunja frontend version (or commit reference) of your instance
validations:
required: true
- type: input
id: api-version
attributes:
label: Vikunja API Version
description: Vikunja API version (or commit reference) of your instance
validations:
required: true
- type: input
id: browser-version
attributes:
label: Browser and version
description: If your issue is related to a frontend problem, please provide the browser and version you used to reproduce it.
- type: dropdown
id: can-reproduce
attributes:
label: Can you reproduce the bug on the Vikunja demo site?
options:
- "Yes"
- "No"
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If this issue involves the Web Interface, please provide one or more screenshots

17
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,17 @@
blank_issues_enabled: false
contact_links:
- name: API issues
url: https://code.vikunja.io/api/issues
about: This is the frontend repo. Please open api-related bug reports and discussions in the api 0repo. Not sure if your issue is frontend or api? Ask in Matrix or the forum first.
- name: Forum
url: https://community.vikunja.io/
about: Feature Requests, Questions, configuration or deployment problems should be discussed in the forum.
- name: Security-related issues
url: https://vikunja.io/contact/#security
about: For security concerns, please send a mail to security@vikunja.io instead of opening a public issue.
- name: Chat on Matrix
url: https://matrix.to/#/#vikunja:matrix.org
about: Please ask any quick questions here.
- name: Translations
url: https://crowdin.com/project/vikunja
about: Any problems or requests for new languages about translations should be handled in crowdin.

View file

@ -6,34 +6,24 @@ WORKDIR /build
ARG USE_RELEASE=false
ARG RELEASE_VERSION=main
ENV PNPM_CACHE_FOLDER .cache/pnpm/
ADD . ./
RUN \
if [ $USE_RELEASE = true ]; then \
wget https://dl.vikunja.io/frontend/vikunja-frontend-$RELEASE_VERSION.zip -O frontend-release.zip && \
unzip frontend-release.zip -d dist/ && \
exit 0; \
fi
ENV PNPM_CACHE_FOLDER .cache/pnpm/
# pnpm fetch does require only lockfile
COPY pnpm-lock.yaml ./
RUN \
fi && \
# https://pnpm.io/installation#using-corepack
corepack enable && \
# we don't use corepack prepare here by intend since
# we have renovate to keep our dependencies up to date
# we don't use corepack prepare here by intend since
# we have renovate to keep our dependencies up to date
# Build the frontend
pnpm fetch --prod
ADD . ./
RUN apk add --no-cache git
RUN \
pnpm install -r --offline --prod && \
echo '{"VERSION": "'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'"}' > src/version.json && \
pnpm run build
pnpm install && \
apk add --no-cache git && \
echo '{"VERSION": "'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'"}' > src/version.json && \
pnpm run build
# Stage 2: copy
FROM nginx:alpine

View file

@ -6,7 +6,7 @@ import '../../support/authenticateUser'
describe('List View Gantt', () => {
prepareLists()
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/gantt')
@ -16,10 +16,12 @@ describe('List View Gantt', () => {
})
it('Shows tasks from the current and next month', () => {
const now = new Date()
const nextMonth = now
const now = Date.UTC(2022, 8, 25)
cy.clock(now, ['Date'])
const nextMonth = new Date(now)
nextMonth.setDate(1)
nextMonth.setMonth(now.getMonth() + 1)
nextMonth.setMonth(9)
cy.visit('/lists/1/gantt')
@ -32,7 +34,7 @@ describe('List View Gantt', () => {
const now = new Date()
const tasks = TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4))
end_date: formatISO(now.setDate(now.getDate() + 4)),
})
cy.visit('/lists/1/gantt')
@ -64,7 +66,7 @@ describe('List View Gantt', () => {
const now = new Date()
TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4))
end_date: formatISO(now.setDate(now.getDate() + 4)),
})
cy.visit('/lists/1/gantt')

View file

@ -46,7 +46,7 @@
"is-touch-device": "1.0.1",
"lodash.clonedeep": "4.5.0",
"lodash.debounce": "4.0.8",
"marked": "4.1.0",
"marked": "4.1.1",
"minimist": "1.2.6",
"pinia": "2.0.22",
"register-service-worker": "1.7.2",
@ -59,7 +59,6 @@
"vue-flatpickr-component": "9.0.6",
"vue-i18n": "9.2.2",
"vue-router": "4.1.5",
"vuex": "4.0.2",
"workbox-precaching": "6.5.4",
"zhyswan-vuedraggable": "4.1.3"
},

View file

@ -52,7 +52,7 @@ specifiers:
is-touch-device: 1.0.1
lodash.clonedeep: 4.5.0
lodash.debounce: 4.0.8
marked: 4.1.0
marked: 4.1.1
minimist: 1.2.6
netlify-cli: 11.8.3
pinia: 2.0.22
@ -77,7 +77,6 @@ specifiers:
vue-i18n: 9.2.2
vue-router: 4.1.5
vue-tsc: 0.40.13
vuex: 4.0.2
wait-on: 6.0.1
workbox-cli: 6.5.4
workbox-precaching: 6.5.4
@ -112,7 +111,7 @@ dependencies:
is-touch-device: 1.0.1
lodash.clonedeep: 4.5.0
lodash.debounce: 4.0.8
marked: 4.1.0
marked: 4.1.1
minimist: 1.2.6
pinia: 2.0.22_bfjwoga25wxjazzogo7o372nwq
register-service-worker: 1.7.2
@ -125,7 +124,6 @@ dependencies:
vue-flatpickr-component: 9.0.6_vue@3.2.40
vue-i18n: 9.2.2_vue@3.2.40
vue-router: 4.1.5_vue@3.2.40
vuex: 4.0.2_vue@3.2.40
workbox-precaching: 6.5.4
zhyswan-vuedraggable: 4.1.3_vue@3.2.40
@ -5727,7 +5725,7 @@ packages:
'@types/marked': 4.0.7
codemirror: 5.65.9
codemirror-spell-checker: 1.1.2
marked: 4.1.0
marked: 4.1.1
dev: false
/ecc-jsbn/0.1.2:
@ -8858,8 +8856,8 @@ packages:
object-visit: 1.0.1
dev: true
/marked/4.1.0:
resolution: {integrity: sha512-+Z6KDjSPa6/723PQYyc1axYZpYYpDnECDaU6hkaf5gqBieBkMKYReL5hteF2QizhlMbgbo8umXl/clZ67+GlsA==}
/marked/4.1.1:
resolution: {integrity: sha512-0cNMnTcUJPxbA6uWmCmjWz4NJRe/0Xfk2NhXCUHjew9qJzFN20krFnsUe7QynwqOwa5m1fZ4UDg0ycKFVC0ccw==}
engines: {node: '>= 12'}
hasBin: true
dev: false
@ -12768,15 +12766,6 @@ packages:
'@vue/server-renderer': 3.2.40_vue@3.2.40
'@vue/shared': 3.2.40
/vuex/4.0.2_vue@3.2.40:
resolution: {integrity: sha512-M6r8uxELjZIK8kTKDGgZTYX/ahzblnzC4isU1tpmEuOIIKmV+TRdc+H4s8ds2NuZ7wpUTdGRzJRtoj+lI+pc0Q==}
peerDependencies:
vue: ^3.0.2
dependencies:
'@vue/devtools-api': 6.2.1
vue: 3.2.40
dev: false
/wait-on/6.0.1:
resolution: {integrity: sha512-zht+KASY3usTY5u2LgaNqn/Cd8MukxLGjdcZxT2ns5QzDmTFc4XoWBgC+C/na+sMRZTuVygQoMYwdcVjHnYIVw==}
engines: {node: '>=10.0.0'}

View file

@ -18,7 +18,6 @@
import {computed, watch, type Ref} from 'vue'
import {useRouter} from 'vue-router'
import {useRouteQuery} from '@vueuse/router'
import {useStore} from '@/store'
import {useI18n} from 'vue-i18n'
import isTouchDevice from 'is-touch-device'
import {success} from '@/message'
@ -34,16 +33,17 @@ import Ready from '@/components/misc/ready.vue'
import {setLanguage} from './i18n'
import AccountDeleteService from '@/services/accountDelete'
import {useBaseStore} from '@/stores/base'
import {useColorScheme} from '@/composables/useColorScheme'
import {useBodyClass} from '@/composables/useBodyClass'
import {useAuthStore} from './stores/auth'
const store = useStore()
const baseStore = useBaseStore()
const authStore = useAuthStore()
const router = useRouter()
useBodyClass('is-touch', isTouchDevice())
const keyboardShortcutsActive = computed(() => store.state.keyboardShortcutsActive)
const keyboardShortcutsActive = computed(() => baseStore.keyboardShortcutsActive)
const authUser = computed(() => authStore.authUser)
const authLinkShare = computed(() => authStore.authLinkShare)

View file

@ -1,8 +1,8 @@
<template>
<BaseButton
class="menu-show-button"
@click="$store.commit('toggleMenu')"
@shortkey="() => $store.commit('toggleMenu')"
@click="baseStore.toggleMenu()"
@shortkey="() => baseStore.toggleMenu()"
v-shortcut="'Control+e'"
:title="$t('keyboardShortcuts.toggleMenu')"
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
@ -11,12 +11,12 @@
<script setup lang="ts">
import {computed} from 'vue'
import {useStore} from '@/store'
import {useBaseStore} from '@/stores/base'
import BaseButton from '@/components/base/BaseButton.vue'
const store = useStore()
const menuActive = computed(() => store.state.menuActive)
const baseStore = useBaseStore()
const menuActive = computed(() => baseStore.menuActive)
</script>
<style lang="scss" scoped>

View file

@ -70,7 +70,7 @@
{{ $t('navigation.privacy') }}
</dropdown-item>
<dropdown-item
@click="$store.commit('keyboardShortcutsActive', true)"
@click="baseStore.setKeyboardShortcutsActive(true)"
>
{{ $t('keyboardShortcuts.title') }}
</dropdown-item>
@ -92,9 +92,7 @@
<script setup lang="ts">
import {ref, computed, onMounted, nextTick} from 'vue'
import {useStore} from '@/store'
import {QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
import {RIGHTS as Rights} from '@/constants/rights'
import Update from '@/components/home/update.vue'
@ -107,21 +105,24 @@ import BaseButton from '@/components/base/BaseButton.vue'
import MenuButton from '@/components/home/MenuButton.vue'
import {getListTitle} from '@/helpers/getListTitle'
import {useBaseStore} from '@/stores/base'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
const store = useStore()
const authStore = useAuthStore()
const configStore = useConfigStore()
const baseStore = useBaseStore()
const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background)
const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Rights.READ)
const menuActive = computed(() => baseStore.menuActive)
const authStore = useAuthStore()
const userInfo = computed(() => authStore.info)
const userAvatar = computed(() => authStore.avatarUrl)
const currentList = computed(() => store.state.currentList)
const background = computed(() => store.state.background)
const configStore = useConfigStore()
const imprintUrl = computed(() => configStore.legal.imprintUrl)
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
const canWriteCurrentList = computed(() => store.state.currentList.maxRight > Rights.READ)
const menuActive = computed(() => store.state.menuActive)
const usernameDropdown = ref()
const listTitle = ref()
@ -140,7 +141,7 @@ function logout() {
}
function openQuickActions() {
store.commit(QUICK_ACTIONS_ACTIVE, true)
baseStore.setQuickActionsActive(true)
}
</script>

View file

@ -2,7 +2,7 @@
<div class="content-auth">
<BaseButton
v-if="menuActive"
@click="$store.commit('menuActive', false)"
@click="baseStore.setMenuActive(false)"
class="menu-hide-button d-print-none"
>
<icon icon="times"/>
@ -26,7 +26,7 @@
>
<BaseButton
v-if="menuActive"
@click="$store.commit('menuActive', false)"
@click="baseStore.setMenuActive(false)"
class="mobile-overlay d-print-none"
/>
@ -61,11 +61,10 @@
<script lang="ts" setup>
import {watch, computed, shallowRef, watchEffect, type VNode, h} from 'vue'
import {useStore} from '@/store'
import {useBaseStore} from '@/stores/base'
import {useRoute, useRouter} from 'vue-router'
import {useEventListener} from '@vueuse/core'
import {CURRENT_LIST, KEYBOARD_SHORTCUTS_ACTIVE, MENU_ACTIVE} from '@/store/mutation-types'
import {useLabelStore} from '@/stores/labels'
import Navigation from '@/components/home/navigation.vue'
import QuickActions from '@/components/quick-actions/quick-actions.vue'
@ -123,20 +122,19 @@ function useRouteWithModal() {
const {routeWithModal, currentModal, closeModal} = useRouteWithModal()
const store = useStore()
const background = computed(() => store.state.background)
const blurHash = computed(() => store.state.blurHash)
const menuActive = computed(() => store.state.menuActive)
const baseStore = useBaseStore()
const background = computed(() => baseStore.background)
const blurHash = computed(() => baseStore.blurHash)
const menuActive = computed(() => baseStore.menuActive)
function showKeyboardShortcuts() {
store.commit(KEYBOARD_SHORTCUTS_ACTIVE, true)
baseStore.setKeyboardShortcutsActive(true)
}
const route = useRoute()
// hide menu on mobile
watch(() => route.fullPath, () => window.innerWidth < 769 && store.commit(MENU_ACTIVE, false))
watch(() => route.fullPath, () => window.innerWidth < 769 && baseStore.setMenuActive(false))
// FIXME: this is really error prone
// Reset the current list highlight in menu if the current route is not list related.
@ -158,7 +156,7 @@ watch(() => route.name as string, (routeName) => {
routeName.startsWith('user.settings')
)
) {
store.dispatch(CURRENT_LIST, {list: null})
baseStore.handleSetCurrentList({list: null})
}
})

View file

@ -24,15 +24,16 @@
<script lang="ts" setup>
import {computed} from 'vue'
import {useStore} from '@/store'
import {useBaseStore} from '@/stores/base'
import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue'
const store = useStore()
const currentList = computed(() => store.state.currentList)
const background = computed(() => store.state.background)
const logoVisible = computed(() => store.state.logoVisible)
const baseStore = useBaseStore()
const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background)
const logoVisible = computed(() => baseStore.logoVisible)
</script>
<style lang="scss" scoped>

View file

@ -141,7 +141,6 @@
<script setup lang="ts">
import {ref, computed, onMounted, onBeforeMount} from 'vue'
import {useStore} from '@/store'
import draggable from 'zhyswan-vuedraggable'
import type {SortableEvent} from 'sortablejs'
@ -151,7 +150,6 @@ import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings
import PoweredByLink from '@/components/home/PoweredByLink.vue'
import Logo from '@/components/home/Logo.vue'
import {MENU_ACTIVE} from '@/store/mutation-types'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
import {getListTitle} from '@/helpers/getListTitle'
@ -159,6 +157,8 @@ import {useEventListener} from '@vueuse/core'
import type {IList} from '@/modelTypes/IList'
import type {INamespace} from '@/modelTypes/INamespace'
import ColorBubble from '@/components/misc/colorBubble.vue'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
@ -168,10 +168,10 @@ const dragOptions = {
ghostClass: 'ghost',
}
const store = useStore()
const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore()
const currentList = computed(() => store.state.currentList)
const menuActive = computed(() => store.state.menuActive)
const currentList = computed(() => baseStore.currentList)
const menuActive = computed(() => baseStore.menuActive)
const loading = computed(() => namespaceStore.isLoading)
@ -202,7 +202,7 @@ const listStore = useListStore()
function resize() {
// Hide the menu by default on mobile
store.commit(MENU_ACTIVE, window.innerWidth >= 770)
baseStore.setMenuActive(window.innerWidth >= 770)
}
function toggleLists(namespaceId: INamespace['id']) {
@ -262,7 +262,7 @@ async function saveListPosition(e: SortableEvent) {
)
try {
// create a copy of the list in order to not violate vuex mutations
// create a copy of the list in order to not violate pinia manipulation
await listStore.updateList({
...list,
position,

View file

@ -88,12 +88,10 @@
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue'
<script setup lang="ts">
import {ref, onMounted, onBeforeUnmount, toRef, watch, computed, type PropType} from 'vue'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {i18n} from '@/i18n'
import BaseButton from '@/components/base/BaseButton.vue'
@ -102,146 +100,140 @@ import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {mapState} from 'pinia'
import {useAuthStore} from '@/stores/auth'
import {useI18n} from 'vue-i18n'
export default defineComponent({
name: 'datepicker',
data() {
return {
date: null,
show: false,
changed: false,
}
const props = defineProps({
modelValue: {
type: [Date, null, String] as PropType<Date | null | string>,
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
default: null,
},
components: {
flatPickr,
BaseButton,
},
props: {
modelValue: {
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
},
chooseDateLabel: {
type: String,
default() {
return i18n.global.t('input.datepicker.chooseDate')
},
},
disabled: {
type: Boolean,
default: false,
chooseDateLabel: {
type: String,
default() {
const {t} = useI18n({useScope: 'global'})
return t('input.datepicker.chooseDate')
},
},
emits: ['update:modelValue', 'close', 'close-on-change'],
mounted() {
document.addEventListener('click', this.hideDatePopup)
},
beforeUnmount() {
document.removeEventListener('click', this.hideDatePopup)
},
watch: {
modelValue: {
handler: 'setDateValue',
immediate: true,
},
},
computed: {
...mapState(useAuthStore, {
weekStart: (state) => state.settings.weekStart,
}),
flatPickerConfig() {
return {
altFormat: this.$t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: this.weekStart,
},
}
},
// Since flatpickr dates are strings, we need to convert them to native date objects.
// To make that work, we need a separate variable since flatpickr does not have a change event.
flatPickrDate: {
set(newValue) {
this.date = createDateFromString(newValue)
this.updateData()
},
get() {
if (!this.date) {
return ''
}
return formatDate(this.date, 'yyy-LL-dd H:mm')
},
},
},
methods: {
formatDateShort,
setDateValue(newVal) {
if (newVal === null) {
this.date = null
return
}
this.date = createDateFromString(newVal)
},
updateData() {
this.changed = true
this.$emit('update:modelValue', this.date)
},
toggleDatePopup() {
if (this.disabled) {
return
}
this.show = !this.show
},
hideDatePopup(e) {
if (this.show) {
closeWhenClickedOutside(e, this.$refs.datepickerPopup, this.close)
}
},
close() {
// Kind of dirty, but the timeout allows us to enter a time and click on "confirm" without
// having to click on another input field before it is actually used.
setTimeout(() => {
this.show = false
this.$emit('close', this.changed)
if (this.changed) {
this.changed = false
this.$emit('close-on-change', this.changed)
}
}, 200)
},
setDate(date) {
if (this.date === null) {
this.date = new Date()
}
const interval = calculateDayInterval(date)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
newDate.setHours(calculateNearestHours(newDate))
newDate.setMinutes(0)
newDate.setSeconds(0)
this.date = newDate
this.flatPickrDate = newDate
this.updateData()
},
getDayIntervalFromString(date) {
return calculateDayInterval(date)
},
getWeekdayFromStringInterval(date) {
const interval = calculateDayInterval(date)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
return formatDate(newDate, 'E')
},
disabled: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue', 'close', 'close-on-change'])
const {t} = useI18n({useScope: 'global'})
const date = ref<Date | null>()
const show = ref(false)
const changed = ref(false)
onMounted(() => document.addEventListener('click', hideDatePopup))
onBeforeUnmount(() =>document.removeEventListener('click', hideDatePopup))
const modelValue = toRef(props, 'modelValue')
watch(
modelValue,
setDateValue,
{immediate: true},
)
const authStore = useAuthStore()
const weekStart = computed(() => authStore.settings.weekStart)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: weekStart.value,
},
}))
// Since flatpickr dates are strings, we need to convert them to native date objects.
// To make that work, we need a separate variable since flatpickr does not have a change event.
const flatPickrDate = computed({
set(newValue: string | Date) {
date.value = createDateFromString(newValue)
updateData()
},
get() {
if (!date.value) {
return ''
}
return formatDate(date.value, 'yyy-LL-dd H:mm')
},
})
function setDateValue(dateString: string | Date | null) {
if (dateString === null) {
date.value = null
return
}
date.value = createDateFromString(dateString)
}
function updateData() {
changed.value = true
emit('update:modelValue', date.value)
}
function toggleDatePopup() {
if (props.disabled) {
return
}
show.value = !show.value
}
const datepickerPopup = ref<HTMLElement | null>(null)
function hideDatePopup(e) {
if (show.value) {
closeWhenClickedOutside(e, datepickerPopup.value, close)
}
}
function close() {
// Kind of dirty, but the timeout allows us to enter a time and click on "confirm" without
// having to click on another input field before it is actually used.
setTimeout(() => {
show.value = false
emit('close', changed.value)
if (changed.value) {
changed.value = false
emit('close-on-change', changed.value)
}
}, 200)
}
function setDate(dateString: string) {
if (date.value === null) {
date.value = new Date()
}
const interval = calculateDayInterval(dateString)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
newDate.setHours(calculateNearestHours(newDate))
newDate.setMinutes(0)
newDate.setSeconds(0)
date.value = newDate
flatPickrDate.value = newDate
updateData()
}
function getWeekdayFromStringInterval(dateString: string) {
const interval = calculateDayInterval(dateString)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
return formatDate(newDate, 'E')
}
</script>
<style lang="scss" scoped>

View file

@ -1,21 +1,25 @@
<template>
<card class="filters has-overflow" :title="hasTitle ? $t('filters.title') : ''">
<div class="field">
<fancycheckbox v-model="params.filter_include_nulls">
<div class="field is-flex is-flex-direction-column">
<fancycheckbox
v-model="params.filter_include_nulls"
@update:model-value="change()"
>
{{ $t('filters.attributes.includeNulls') }}
</fancycheckbox>
<fancycheckbox
v-model="filters.requireAllFilters"
@change="setFilterConcat()"
@update:model-value="setFilterConcat()"
>
{{ $t('filters.attributes.requireAll') }}
</fancycheckbox>
<fancycheckbox @change="setDoneFilter" v-model="filters.done">
<fancycheckbox v-model="filters.done" @update:model-value="setDoneFilter">
{{ $t('filters.attributes.showDoneTasks') }}
</fancycheckbox>
<fancycheckbox
v-if="!$route.name.includes('list.kanban') || !$route.name.includes('list.table')"
v-model="sortAlphabetically"
@update:model-value="change()"
>
{{ $t('filters.attributes.sortAlphabetically') }}
</fancycheckbox>
@ -42,7 +46,7 @@
/>
<fancycheckbox
v-model="filters.usePriority"
@change="setPriority"
@update:model-value="setPriority"
>
{{ $t('filters.attributes.enablePriority') }}
</fancycheckbox>
@ -58,7 +62,7 @@
/>
<fancycheckbox
v-model="filters.usePercentDone"
@change="setPercentDoneFilter"
@update:model-value="setPercentDoneFilter"
>
{{ $t('filters.attributes.enablePercentDone') }}
</fancycheckbox>
@ -534,6 +538,7 @@ export default defineComponent({
} else {
this.params.filter_concat = 'or'
}
this.change()
},
setPriority() {
this.setSingleValueFilter('priority', 'priority', 'usePriority')

View file

@ -33,18 +33,15 @@
</template>
<script lang="ts" setup>
import {useStore} from '@/store'
import {useBaseStore} from '@/stores/base'
import Shortcut from '@/components/misc/shortcut.vue'
import Message from '@/components/misc/message.vue'
import {KEYBOARD_SHORTCUTS_ACTIVE} from '@/store/mutation-types'
import {KEYBOARD_SHORTCUTS as shortcuts} from './shortcuts'
const store = useStore()
function close() {
store.commit(KEYBOARD_SHORTCUTS_ACTIVE, false)
useBaseStore().setKeyboardShortcutsActive(false)
}
</script>

View file

@ -42,7 +42,7 @@
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {useStore} from '@/store'
import {useRouter, useRoute} from 'vue-router'
import Logo from '@/assets/logo.svg?component'
import ApiConfig from '@/components/misc/api-config.vue'
@ -52,13 +52,14 @@ import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
import {useOnline} from '@/composables/useOnline'
import {useRouter, useRoute} from 'vue-router'
import {getAuthForRoute} from '@/router'
import {useBaseStore} from '@/stores/base'
const router = useRouter()
const route = useRoute()
const store = useStore()
const baseStore = useBaseStore()
const ready = ref(false)
const online = useOnline()
@ -68,7 +69,7 @@ const showLoading = computed(() => !ready.value && error.value === '')
async function load() {
try {
await store.dispatch('loadApp')
await baseStore.loadApp()
const redirectTo = getAuthForRoute(route)
if (typeof redirectTo !== 'undefined') {
await router.push(redirectTo)

View file

@ -61,7 +61,6 @@ import TeamService from '@/services/team'
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 BaseButton from '@/components/base/BaseButton.vue'
@ -70,6 +69,8 @@ import {getHistory} from '@/modules/listHistory'
import {parseTaskText, PrefixMode} from '@/modules/parseTaskText'
import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
import {PREFIXES} from '@/modules/parseTaskText'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import {useLabelStore} from '@/stores/labels'
@ -112,8 +113,10 @@ export default defineComponent({
},
computed: {
active() {
const active = this.$store.state[QUICK_ACTIONS_ACTIVE]
const active = useBaseStore().quickActionsActive
if (!active) {
// FIXME: computeds shouldn't have side effects.
// create a watcher instead
this.reset()
}
return active
@ -181,8 +184,7 @@ export default defineComponent({
},
loading() {
return this.taskService.loading ||
(this.$store.state[LOADING] && this.$store.state[LOADING_MODULE] === 'namespaces') ||
(this.$store.state[LOADING] && this.$store.state[LOADING_MODULE] === 'lists') ||
useNamespaceStore().isLoading || useListStore().isLoading ||
this.teamService.loading
},
placeholder() {
@ -219,7 +221,8 @@ export default defineComponent({
return this.$t('quickActions.hint', prefixes)
},
currentList() {
return Object.keys(this.$store.state[CURRENT_LIST]).length === 0 ? null : this.$store.state[CURRENT_LIST]
const currentList = useBaseStore().currentList
return Object.keys(currentList).length === 0 ? null : currentList
},
availableCmds() {
const cmds = []
@ -360,7 +363,7 @@ export default defineComponent({
}, 150)
},
closeQuickActions() {
this.$store.commit(QUICK_ACTIONS_ACTIVE, false)
useBaseStore().setQuickActionsActive(false)
},
doAction(type, item) {
switch (type) {

View file

@ -162,7 +162,9 @@ async function addTask() {
}
const taskTitleBackup = newTaskTitle.value
const createdTasks: ITask[] = []
// This allows us to find the tasks with the title they had before being parsed
// by quick add magic.
const createdTasks: { [key: ITask['title']]: ITask } = {}
const tasksToCreate = parseSubtasksViaIndention(newTaskTitle.value)
const newTasks = tasksToCreate.map(async ({title}) => {
if (title === '') {
@ -174,7 +176,7 @@ async function addTask() {
listId: authStore.settings.defaultListId,
position: props.defaultPosition,
})
createdTasks.push(task)
createdTasks[title] = task
return task
})
@ -184,7 +186,7 @@ async function addTask() {
const taskRelationService = new TaskRelationService()
const relations = tasksToCreate.map(async t => {
const createdTask = createdTasks.find(ct => ct.title === t.title)
const createdTask = createdTasks[t.title]
if (typeof createdTask === 'undefined') {
return
}
@ -194,7 +196,7 @@ async function addTask() {
return
}
const createdParentTask = createdTasks.find(ct => ct.title === t.parent)
const createdParentTask = createdTasks[t.parent]
if (typeof createdTask === 'undefined' || typeof createdParentTask === 'undefined') {
return
}

View file

@ -41,13 +41,13 @@ import {ref, computed, type PropType} from 'vue'
import {useRouter} from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
import ColorBubble from '@/components/misc/colorBubble.vue'
import Done from '@/components/misc/Done.vue'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {useTaskStore} from '@/stores/tasks'
import type {ITask} from '@/modelTypes/ITask'
import ColorBubble from '@/components/misc/colorBubble.vue'
import {useTaskStore} from '@/stores/tasks'
const props = defineProps({
task: {

View file

@ -35,7 +35,7 @@
{{ task.title }}
</span>
<labels class="labels ml-2 mr-1" :labels="task.labels" v-if="task.labels.length > 0"/>
<labels class="labels ml-2 mr-1" :labels="task.labels" v-if="task.labels.length > 0" />
<user
:avatar-size="27"
:is-inline="true"
@ -119,6 +119,7 @@ import {formatDateSince, formatISO, formatDateLong} from '@/helpers/time/formatD
import ColorBubble from '@/components/misc/colorBubble.vue'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
export default defineComponent({
@ -188,10 +189,11 @@ export default defineComponent({
return list !== null ? list.hexColor : ''
},
currentList() {
return typeof this.$store.state.currentList === 'undefined' ? {
const baseStore = useBaseStore()
return typeof baseStore.currentList === 'undefined' ? {
id: 0,
title: '',
} : this.$store.state.currentList
} : baseStore.currentList
},
taskDetailRoute() {
return {
@ -238,8 +240,7 @@ export default defineComponent({
this.task.isFavorite = !this.task.isFavorite
this.task = await this.taskService.update(this.task)
this.$emit('task-updated', this.task)
const namespaceStore = useNamespaceStore()
namespaceStore.loadNamespacesIfFavoritesDontExist()
useNamespaceStore().loadNamespacesIfFavoritesDontExist()
},
hideDeferDueDatePopup(e) {
if (!this.showDefer) {

View file

@ -1,14 +1,9 @@
import {createRandomID} from '@/helpers/randomId'
import {parseURL} from 'ufo'
export interface Provider {
name: string
key: string
authUrl: string
clientId: string
}
import {createRandomID} from '@/helpers/randomId'
import type {IProvider} from '@/types/IProvider'
export const redirectToProvider = (provider: Provider, redirectUrl: string = '') => {
export const redirectToProvider = (provider: IProvider, redirectUrl: string = '') => {
// We're not using the redirect url provided by the server to allow redirects when using the electron app.
// The implications are not quite clear yet hence the logic to pass in another redirect url still exists.

View file

@ -1,5 +1,5 @@
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
// We use local storage and not a store here to make it persistent across reloads.
export const saveListView = (listId, routeName) => {
if (routeName.includes('settings.')) {
return

View file

@ -0,0 +1,19 @@
export function scrollIntoView(el: HTMLElement | null | undefined) {
if (!el) {
return
}
const boundingRect = el.getBoundingClientRect()
const scrollY = window.scrollY
if (
boundingRect.top > (scrollY + window.innerHeight) ||
boundingRect.top < scrollY
) {
el.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
})
}
}

View file

@ -1,5 +1,5 @@
export function calculateDayInterval(date, currentDay = (new Date().getDay())) {
switch (date) {
export function calculateDayInterval(dateString: string, currentDay = (new Date().getDay())) {
switch (dateString) {
case 'today':
return 0
case 'tomorrow':

View file

@ -14,8 +14,6 @@ import Notifications from '@kyvg/vue3-notification'
// PWA
import './registerServiceWorker'
// Vuex
import { store, key } from './store'
// i18n
import {i18n} from './i18n'
@ -106,8 +104,6 @@ if (window.SENTRY_ENABLED) {
}
app.use(pinia)
app.use(store, key) // pass the injection key
app.use(router)
app.use(i18n)

View file

@ -1,7 +1,7 @@
import AbstractModel from './abstractModel'
import UserSettingsModel from '@/models/userSettings'
import { AUTH_TYPES, type IUser } from '@/modelTypes/IUser'
import { AUTH_TYPES, type IUser, type AuthType } from '@/modelTypes/IUser'
import type { IUserSettings } from '@/modelTypes/IUserSettings'
export function getAvatarUrl(user: IUser, size = 50) {
@ -22,7 +22,7 @@ export default class UserModel extends AbstractModel<IUser> implements IUser {
username = ''
name = ''
exp = 0
type = AUTH_TYPES.UNKNOWN
type: AuthType = AUTH_TYPES.UNKNOWN
created: Date
updated: Date

View file

@ -22,7 +22,8 @@ import DataExportDownload from '../views/user/DataExportDownload.vue'
import UpcomingTasksComponent from '../views/tasks/ShowTasks.vue'
import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth.vue'
import ListNamespaces from '../views/namespaces/ListNamespaces.vue'
import TaskDetailView from '../views/tasks/TaskDetailView.vue'
const TaskDetailView = () => import('../views/tasks/TaskDetailView.vue')
// Team Handling
import ListTeamsComponent from '../views/teams/ListTeams.vue'
// Label Handling

View file

@ -1,53 +0,0 @@
import type { ActionContext } from 'vuex'
import type { StoreDefinition } from 'pinia'
import {LOADING, LOADING_MODULE} from './mutation-types'
import type { RootStoreState } from './types'
/**
* This helper sets the loading state with a 100ms delay to avoid flickering.
*
* @param {*} context The vuex module context.
* @param {null|String} module The module that is loading. This parameter allows components to listen for specific parts of the application loading.
* @param {null|function} loadFunc If not null, this function will be executed instead of the default setting loading.
*/
export function setLoading<State>(
context : ActionContext<State, RootStoreState>,
module : string | null = null,
loadFunc : (() => void) | null = null,
) {
const timeout = setTimeout(() => {
if (loadFunc === null) {
context.commit(LOADING, true, {root: true})
context.commit(LOADING_MODULE, module, {root: true})
} else {
loadFunc(true)
}
}, 100)
return () => {
clearTimeout(timeout)
if (loadFunc === null) {
context.commit(LOADING, false, {root: true})
context.commit(LOADING_MODULE, null, {root: true})
} else {
loadFunc(false)
}
}
}
export const setLoadingPinia = (store: StoreDefinition, loadFunc : ((isLoading: boolean) => void) | null = null) => {
const timeout = setTimeout(() => {
if (loadFunc === null) {
store.isLoading = true
} else {
loadFunc(true)
}
}, 100)
return () => {
clearTimeout(timeout)
if (loadFunc === null) {
store.isLoading = false
} else {
loadFunc(false)
}
}
}

View file

@ -1,138 +0,0 @@
import type {InjectionKey} from 'vue'
import {createStore, useStore as baseUseStore, Store} from 'vuex'
import {getBlobFromBlurHash} from '../helpers/getBlobFromBlurHash'
import {
BACKGROUND,
BLUR_HASH,
CURRENT_LIST,
HAS_TASKS,
KEYBOARD_SHORTCUTS_ACTIVE,
LOADING,
LOADING_MODULE, LOGO_VISIBLE,
MENU_ACTIVE,
QUICK_ACTIONS_ACTIVE,
} from './mutation-types'
import kanban from './modules/kanban'
import ListModel from '@/models/list'
import ListService from '../services/list'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
import type { RootStoreState, StoreState } from './types'
import pinia from '@/pinia'
import {useAuthStore} from '@/stores/auth'
export const key: InjectionKey<Store<StoreState>> = Symbol()
// define your own `useStore` composition function
export function useStore () {
return baseUseStore(key)
}
export const store = createStore<RootStoreState>({
strict: import.meta.env.DEV,
modules: {
kanban,
},
state: () => ({
loading: false,
loadingModule: null,
// This is used to highlight the current list in menu for all list related views
currentList: new ListModel({
id: 0,
isArchived: false,
}),
background: '',
blurHash: '',
hasTasks: false,
menuActive: true,
keyboardShortcutsActive: false,
quickActionsActive: false,
logoVisible: true,
}),
mutations: {
[LOADING](state, loading) {
state.loading = loading
},
[LOADING_MODULE](state, module) {
state.loadingModule = module
},
[CURRENT_LIST](state, currentList) {
// Server updates don't return the right. Therefore, the right is reset after updating the list which is
// confusing because all the buttons will disappear in that case. To prevent this, we're keeping the right
// when updating the list in global state.
if (typeof state.currentList.maxRight !== 'undefined' && (typeof currentList.maxRight === 'undefined' || currentList.maxRight === null)) {
currentList.maxRight = state.currentList.maxRight
}
state.currentList = currentList
},
[HAS_TASKS](state, hasTasks) {
state.hasTasks = hasTasks
},
[MENU_ACTIVE](state, menuActive) {
state.menuActive = menuActive
},
toggleMenu(state) {
state.menuActive = !state.menuActive
},
[KEYBOARD_SHORTCUTS_ACTIVE](state, active) {
state.keyboardShortcutsActive = active
},
[QUICK_ACTIONS_ACTIVE](state, active) {
state.quickActionsActive = active
},
[BACKGROUND](state, background) {
state.background = background
},
[BLUR_HASH](state, blurHash) {
state.blurHash = blurHash
},
[LOGO_VISIBLE](state, visible: boolean) {
state.logoVisible = visible
},
},
actions: {
async [CURRENT_LIST]({state, commit}, {list, forceUpdate = false}) {
if (list === null) {
commit(CURRENT_LIST, {})
commit(BACKGROUND, null)
commit(BLUR_HASH, null)
return
}
// The forceUpdate parameter is used only when updating a list background directly because in that case
// the current list stays the same, but we want to show the new background right away.
if (list.id !== state.currentList.id || forceUpdate) {
if (list.backgroundInformation) {
try {
const blurHash = await getBlobFromBlurHash(list.backgroundBlurHash)
if (blurHash) {
commit(BLUR_HASH, window.URL.createObjectURL(blurHash))
}
const listService = new ListService()
const background = await listService.background(list)
commit(BACKGROUND, background)
} catch (e) {
console.error('Error getting background image for list', list.id, e)
}
}
}
if (typeof list.backgroundInformation === 'undefined' || list.backgroundInformation === null) {
commit(BACKGROUND, null)
commit(BLUR_HASH, null)
}
commit(CURRENT_LIST, list)
},
async loadApp() {
await checkAndSetApiUrl(window.API_URL)
const authStore = useAuthStore(pinia)
await authStore.checkAuth()
},
},
})

View file

@ -1,12 +0,0 @@
export const LOADING = 'loading'
export const LOADING_MODULE = 'loadingModule'
export const CURRENT_LIST = 'currentList'
export const HAS_TASKS = 'hasTasks'
export const MENU_ACTIVE = 'menuActive'
export const KEYBOARD_SHORTCUTS_ACTIVE = 'keyboardShortcutsActive'
export const QUICK_ACTIONS_ACTIVE = 'quickActionsActive'
export const BACKGROUND = 'background'
export const BLUR_HASH = 'blurHash'
export const LOGO_VISIBLE = 'logoVisible'
export const CONFIG = 'config'

View file

@ -1,111 +0,0 @@
import type { IBucket } from '@/modelTypes/IBucket'
import type { IUserSettings } from '@/modelTypes/IUserSettings'
import type { IList } from '@/modelTypes/IList'
import type { IAttachment } from '@/modelTypes/IAttachment'
import type { ILabel } from '@/modelTypes/ILabel'
import type { INamespace } from '@/modelTypes/INamespace'
import type { IUser } from '@/modelTypes/IUser'
export interface RootStoreState {
loading: boolean,
loadingModule: null,
currentList: IList,
background: string,
blurHash: string,
hasTasks: boolean,
menuActive: boolean,
keyboardShortcutsActive: boolean,
quickActionsActive: boolean,
logoVisible: boolean,
}
export interface AttachmentState {
attachments: IAttachment[],
}
export interface AuthState {
authenticated: boolean,
isLinkShareAuth: boolean,
info: IUser | null,
needsTotpPasscode: boolean,
avatarUrl: string,
lastUserInfoRefresh: Date | null,
settings: IUserSettings,
isLoading: boolean,
isLoadingGeneralSettings: boolean
}
export interface ConfigState {
version: string,
frontendUrl: string,
motd: string,
linkSharingEnabled: boolean,
maxFileSize: '20MB',
registrationEnabled: boolean,
availableMigrators: [],
taskAttachmentsEnabled: boolean,
totpEnabled: boolean,
enabledBackgroundProviders: [],
legal: {
imprintUrl: string,
privacyPolicyUrl: string,
},
caldavEnabled: boolean,
userDeletionEnabled: boolean,
taskCommentsEnabled: boolean,
auth: {
local: {
enabled: boolean,
},
openidConnect: {
enabled: boolean,
redirectUrl: string,
providers: [],
},
},
}
export interface KanbanState {
buckets: IBucket[],
listId: IList['id'],
bucketLoading: {},
taskPagesPerBucket: {
[id: IBucket['id']]: number
},
allTasksLoadedForBucket: {
[id: IBucket['id']]: boolean
},
}
export interface LabelState {
labels: {
[id: ILabel['id']]: ILabel
},
isLoading: boolean,
}
export interface ListState {
lists: { [id: IList['id']]: IList },
isLoading: boolean,
}
export interface NamespaceState {
namespaces: INamespace[]
isLoading: boolean,
}
export interface TaskState {
isLoading: boolean,
}
export type StoreState = RootStoreState & {
config: ConfigState,
auth: AuthState,
namespaces: NamespaceState,
kanban: KanbanState,
tasks: TaskState,
lists: ListState,
attachments: AttachmentState,
labels: LabelState,
}

View file

@ -1,9 +1,12 @@
import {defineStore, acceptHMRUpdate} from 'pinia'
import {findIndexById} from '@/helpers/utils'
import type {AttachmentState} from '@/store/types'
import type {IAttachment} from '@/modelTypes/IAttachment'
export interface AttachmentState {
attachments: IAttachment[],
}
export const useAttachmentStore = defineStore('attachment', {
state: (): AttachmentState => ({
attachments: [],

View file

@ -6,16 +6,26 @@ import {objectToSnakeCase} from '@/helpers/case'
import UserModel, { getAvatarUrl } from '@/models/user'
import UserSettingsService from '@/services/userSettings'
import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth'
import {setLoadingPinia} from '@/store/helper'
import {setModuleLoading} from '@/stores/helper'
import {success} from '@/message'
import {redirectToProvider} from '@/helpers/redirectToProvider'
import {AUTH_TYPES, type IUser} from '@/modelTypes/IUser'
import type {AuthState} from '@/store/types'
import type {IUserSettings} from '@/modelTypes/IUserSettings'
import router from '@/router'
import {useConfigStore} from '@/stores/config'
import UserSettingsModel from '@/models/userSettings'
import {store} from '@/store'
export interface AuthState {
authenticated: boolean,
isLinkShareAuth: boolean,
info: IUser | null,
needsTotpPasscode: boolean,
avatarUrl: string,
lastUserInfoRefresh: Date | null,
settings: IUserSettings,
isLoading: boolean,
isLoadingGeneralSettings: boolean
}
export const useAuthStore = defineStore('auth', {
state: () : AuthState => ({
@ -93,7 +103,6 @@ export const useAuthStore = defineStore('auth', {
// Logs a user in with a set of credentials.
async login(credentials) {
const HTTP = HTTPFactory()
store.commit('loading', true)
this.setIsLoading(true)
// Delete an eventually preexisting old token
@ -117,7 +126,6 @@ export const useAuthStore = defineStore('auth', {
throw e
} finally {
store.commit('loading', false)
this.setIsLoading(false)
}
},
@ -126,7 +134,6 @@ export const useAuthStore = defineStore('auth', {
// Not sure if this is the right place to put the logic in, maybe a seperate js component would be better suited.
async register(credentials) {
const HTTP = HTTPFactory()
store.commit('loading', true)
this.setIsLoading(true)
try {
await HTTP.post('register', credentials)
@ -138,14 +145,12 @@ export const useAuthStore = defineStore('auth', {
throw e
} finally {
store.commit('loading', false)
this.setIsLoading(false)
}
},
async openIdAuth({provider, code}) {
const HTTP = HTTPFactory()
store.commit('loading', true)
this.setIsLoading(true)
const data = {
@ -162,7 +167,6 @@ export const useAuthStore = defineStore('auth', {
// Tell others the user is autheticated
this.checkAuth()
} finally {
store.commit('loading', false)
this.setIsLoading(false)
}
},
@ -271,6 +275,27 @@ export const useAuthStore = defineStore('auth', {
}
},
/**
* Try to verify the email
* @returns {Promise<boolean>} if the email was successfully confirmed
*/
async verifyEmail() {
const emailVerifyToken = localStorage.getItem('emailConfirmToken')
if (emailVerifyToken) {
const stopLoading = setModuleLoading(this)
try {
await HTTPFactory().post('user/confirm', {token: emailVerifyToken})
localStorage.removeItem('emailConfirmToken')
return true
} catch(e) {
throw new Error(e.response.data.message)
} finally {
stopLoading()
}
}
return false
},
async saveUserSettings({
settings,
showMessage = true,
@ -280,8 +305,7 @@ export const useAuthStore = defineStore('auth', {
}) {
const userSettingsService = new UserSettingsService()
// FIXME
const cancel = setLoadingPinia(this, this.setIsLoadingGeneralSettings)
const cancel = setModuleLoading(this, this.setIsLoadingGeneralSettings)
try {
saveLanguage(settings.language)
await userSettingsService.update(settings)

143
src/stores/base.ts Normal file
View file

@ -0,0 +1,143 @@
import {defineStore, acceptHMRUpdate} from 'pinia'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import ListModel from '@/models/list'
import ListService from '../services/list'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
import {useAuthStore} from '@/stores/auth'
import type {IList} from '@/modelTypes/IList'
export interface RootStoreState {
loading: boolean,
currentList: IList,
background: string,
blurHash: string,
hasTasks: boolean,
menuActive: boolean,
keyboardShortcutsActive: boolean,
quickActionsActive: boolean,
logoVisible: boolean,
}
export const useBaseStore = defineStore('base', {
state: () : RootStoreState => ({
loading: false,
// This is used to highlight the current list in menu for all list related views
currentList: new ListModel({
id: 0,
isArchived: false,
}),
background: '',
blurHash: '',
hasTasks: false,
menuActive: true,
keyboardShortcutsActive: false,
quickActionsActive: false,
logoVisible: true,
}),
actions: {
setLoading(loading: boolean) {
this.loading = loading
},
setCurrentList(currentList: IList) {
// Server updates don't return the right. Therefore, the right is reset after updating the list which is
// confusing because all the buttons will disappear in that case. To prevent this, we're keeping the right
// when updating the list in global state.
if (
typeof this.currentList.maxRight !== 'undefined' &&
(
typeof currentList.maxRight === 'undefined' ||
currentList.maxRight === null
)
) {
currentList.maxRight = this.currentList.maxRight
}
this.currentList = currentList
},
setHasTasks(hasTasks: boolean) {
this.hasTasks = hasTasks
},
setMenuActive(menuActive: boolean) {
this.menuActive = menuActive
},
toggleMenu() {
this.menuActive = !this.menuActive
},
setKeyboardShortcutsActive(active: boolean) {
this.keyboardShortcutsActive = active
},
setQuickActionsActive(active: boolean) {
this.quickActionsActive = active
},
setBackground(background: string) {
this.background = background
},
setBlurHash(blurHash: string) {
this.blurHash = blurHash
},
setLogoVisible(visible: boolean) {
this.logoVisible = visible
},
async handleSetCurrentList({list, forceUpdate = false} : {list: IList, forceUpdate: boolean}) {
if (list === null) {
this.setCurrentList({})
this.setBackground('')
this.setBlurHash('')
return
}
// The forceUpdate parameter is used only when updating a list background directly because in that case
// the current list stays the same, but we want to show the new background right away.
if (list.id !== this.currentList.id || forceUpdate) {
if (list.backgroundInformation) {
try {
const blurHash = await getBlobFromBlurHash(list.backgroundBlurHash)
if (blurHash) {
this.setBlurHash(window.URL.createObjectURL(blurHash))
}
const listService = new ListService()
const background = await listService.background(list)
this.setBackground(background)
} catch (e) {
console.error('Error getting background image for list', list.id, e)
}
}
}
if (typeof list.backgroundInformation === 'undefined' || list.backgroundInformation === null) {
this.setBackground('')
this.setBlurHash('')
}
this.setCurrentList(list)
},
async loadApp() {
await checkAndSetApiUrl(window.API_URL)
useAuthStore().checkAuth()
},
},
})
// support hot reloading
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useBaseStore, import.meta.hot))
}

View file

@ -1,10 +1,40 @@
import {defineStore, acceptHMRUpdate} from 'pinia'
import {parseURL} from 'ufo'
import {CONFIG} from '../store/mutation-types'
import {HTTPFactory} from '@/http-common'
import {objectToCamelCase} from '@/helpers/case'
import type {ConfigState} from '@/store/types'
import type {IProvider} from '@/types/IProvider'
export interface ConfigState {
version: string,
frontendUrl: string,
motd: string,
linkSharingEnabled: boolean,
maxFileSize: string,
registrationEnabled: boolean,
availableMigrators: [],
taskAttachmentsEnabled: boolean,
totpEnabled: boolean,
enabledBackgroundProviders: [],
legal: {
imprintUrl: string,
privacyPolicyUrl: string,
},
caldavEnabled: boolean,
userDeletionEnabled: boolean,
taskCommentsEnabled: boolean,
auth: {
local: {
enabled: boolean,
},
openidConnect: {
enabled: boolean,
redirectUrl: string,
providers: IProvider[],
},
},
}
export const useConfigStore = defineStore('config', {
state: (): ConfigState => ({
@ -45,13 +75,13 @@ export const useConfigStore = defineStore('config', {
},
},
actions: {
[CONFIG](config: ConfigState) {
setConfig(config: ConfigState) {
Object.assign(this, config)
},
async update() {
const HTTP = HTTPFactory()
const {data: config} = await HTTP.get('info')
this[CONFIG](objectToCamelCase(config))
this.setConfig(objectToCamelCase(config))
return config
},
},

25
src/stores/helper.ts Normal file
View file

@ -0,0 +1,25 @@
import type { StoreDefinition } from 'pinia'
export interface LoadingState {
isLoading: boolean
}
const LOADING_TIMEOUT = 100
export const setModuleLoading = <LoadingStore extends StoreDefinition<string, LoadingState>>(store: LoadingStore, loadFunc : ((isLoading: boolean) => void) | null = null) => {
const timeout = setTimeout(() => {
if (loadFunc === null) {
store.isLoading = true
} else {
loadFunc(true)
}
}, LOADING_TIMEOUT)
return () => {
clearTimeout(timeout)
if (loadFunc === null) {
store.isLoading = false
} else {
loadFunc(false)
}
}
}

View file

@ -1,14 +1,15 @@
import type { Module } from 'vuex'
import {defineStore, acceptHMRUpdate} from 'pinia'
import cloneDeep from 'lodash.clonedeep'
import {findById, findIndexById} from '@/helpers/utils'
import {i18n} from '@/i18n'
import {success} from '@/message'
import BucketService from '../../services/bucket'
import {setLoading} from '../helper'
import BucketService from '@/services/bucket'
import TaskCollectionService from '@/services/taskCollection'
import type { RootStoreState, KanbanState } from '@/store/types'
import {setModuleLoading} from '@/stores/helper'
import type { ITask } from '@/modelTypes/ITask'
import type { IList } from '@/modelTypes/IList'
import type { IBucket } from '@/modelTypes/IBucket'
@ -37,192 +38,35 @@ const addTaskToBucketAndSort = (state: KanbanState, task: ITask) => {
state.buckets[bucketIndex].tasks.sort((a, b) => a.kanbanPosition > b.kanbanPosition ? 1 : -1)
}
export interface KanbanState {
buckets: IBucket[],
listId: IList['id'],
bucketLoading: {
[id: IBucket['id']]: boolean
},
taskPagesPerBucket: {
[id: IBucket['id']]: number
},
allTasksLoadedForBucket: {
[id: IBucket['id']]: boolean
},
isLoading: boolean,
}
/**
* This store is intended to hold the currently active kanban view.
* It should hold only the current buckets.
*/
const kanbanStore : Module<KanbanState, RootStoreState> = {
namespaced: true,
state: () => ({
export const useKanbanStore = defineStore('kanban', {
state: () : KanbanState => ({
buckets: [],
listId: 0,
bucketLoading: {},
taskPagesPerBucket: {},
allTasksLoadedForBucket: {},
isLoading: false,
}),
mutations: {
setListId(state, listId: IList['id']) {
state.listId = parseInt(listId)
},
setBuckets(state, buckets: IBucket[]) {
state.buckets = buckets
buckets.forEach(b => {
state.taskPagesPerBucket[b.id] = 1
state.allTasksLoadedForBucket[b.id] = false
})
},
addBucket(state, bucket: IBucket) {
state.buckets.push(bucket)
},
removeBucket(state, bucket: IBucket) {
const bucketIndex = findIndexById(state.buckets, bucket.id)
state.buckets.splice(bucketIndex, 1)
},
setBucketById(state, bucket: IBucket) {
const bucketIndex = findIndexById(state.buckets, bucket.id)
state.buckets[bucketIndex] = bucket
},
setBucketByIndex(state, {
bucketIndex,
bucket,
} : {
bucketIndex: number,
bucket: IBucket
}) {
state.buckets[bucketIndex] = bucket
},
setTaskInBucketByIndex(state, {
bucketIndex,
taskIndex,
task,
} : {
bucketIndex: number,
taskIndex: number,
task: ITask
}) {
const bucket = state.buckets[bucketIndex]
bucket.tasks[taskIndex] = task
state.buckets[bucketIndex] = bucket
},
setTasksInBucketByBucketId(state, {
bucketId,
tasks,
} : {
bucketId: IBucket['id'],
tasks: ITask[],
}) {
const bucketIndex = findIndexById(state.buckets, bucketId)
state.buckets[bucketIndex] = {
...state.buckets[bucketIndex],
tasks,
}
},
setTaskInBucket(state, task: ITask) {
// If this gets invoked without any tasks actually loaded, we can save the hassle of finding the task
if (state.buckets.length === 0) {
return
}
let found = false
const findAndUpdate = b => {
for (const t in state.buckets[b].tasks) {
if (state.buckets[b].tasks[t].id === task.id) {
const bucket = state.buckets[b]
bucket.tasks[t] = task
if (bucket.id !== task.bucketId) {
bucket.tasks.splice(t, 1)
addTaskToBucketAndSort(state, task)
}
state.buckets[b] = bucket
found = true
return
}
}
}
for (const b in state.buckets) {
if (state.buckets[b].id === task.bucketId) {
findAndUpdate(b)
if (found) {
return
}
}
}
for (const b in state.buckets) {
findAndUpdate(b)
if (found) {
return
}
}
},
addTaskToBucket(state, task: ITask) {
const bucketIndex = findIndexById(state.buckets, task.bucketId)
const oldBucket = state.buckets[bucketIndex]
const newBucket = {
...oldBucket,
tasks: [
...oldBucket.tasks,
task,
],
}
state.buckets[bucketIndex] = newBucket
},
addTasksToBucket(state, {tasks, bucketId}: {
tasks: ITask[];
bucketId: IBucket['id'];
}) {
const bucketIndex = findIndexById(state.buckets, bucketId)
const oldBucket = state.buckets[bucketIndex]
const newBucket = {
...oldBucket,
tasks: [
...oldBucket.tasks,
...tasks,
],
}
state.buckets[bucketIndex] = newBucket
},
removeTaskInBucket(state, task: ITask) {
// If this gets invoked without any tasks actually loaded, we can save the hassle of finding the task
if (state.buckets.length === 0) {
return
}
const { bucketIndex, taskIndex } = getTaskIndicesById(state, task.id)
if (
!bucketIndex ||
state.buckets[bucketIndex]?.id !== task.bucketId ||
!taskIndex ||
(state.buckets[bucketIndex]?.tasks[taskIndex]?.id !== task.id)
) {
return
}
state.buckets[bucketIndex].tasks.splice(taskIndex, 1)
},
setBucketLoading(state, {bucketId, loading}) {
state.bucketLoading[bucketId] = loading
},
setTasksLoadedForBucketPage(state: KanbanState, {bucketId, page}) {
state.taskPagesPerBucket[bucketId] = page
},
setAllTasksLoadedForBucket(state: KanbanState, bucketId) {
state.allTasksLoadedForBucket[bucketId] = true
},
},
getters: {
getBucketById(state) {
return (bucketId: IBucket['id']) => findById(state.buckets, bucketId)
@ -243,40 +87,216 @@ const kanbanStore : Module<KanbanState, RootStoreState> = {
},
actions: {
async loadBucketsForList(ctx, {listId, params}) {
const cancel = setLoading(ctx, 'kanban')
setIsLoading(isLoading: boolean) {
this.isLoading = isLoading
},
setListId(listId: IList['id']) {
this.listId = Number(listId)
},
setBuckets(buckets: IBucket[]) {
this.buckets = buckets
buckets.forEach(b => {
this.taskPagesPerBucket[b.id] = 1
this.allTasksLoadedForBucket[b.id] = false
})
},
addBucket(bucket: IBucket) {
this.buckets.push(bucket)
},
removeBucket(bucket: IBucket) {
const bucketIndex = findIndexById(this.buckets, bucket.id)
this.buckets.splice(bucketIndex, 1)
},
setBucketById(bucket: IBucket) {
const bucketIndex = findIndexById(this.buckets, bucket.id)
this.buckets[bucketIndex] = bucket
},
setBucketByIndex({
bucketIndex,
bucket,
} : {
bucketIndex: number,
bucket: IBucket
}) {
this.buckets[bucketIndex] = bucket
},
setTaskInBucketByIndex({
bucketIndex,
taskIndex,
task,
} : {
bucketIndex: number,
taskIndex: number,
task: ITask
}) {
const bucket = this.buckets[bucketIndex]
bucket.tasks[taskIndex] = task
this.buckets[bucketIndex] = bucket
},
setTasksInBucketByBucketId({
bucketId,
tasks,
} : {
bucketId: IBucket['id'],
tasks: ITask[],
}) {
const bucketIndex = findIndexById(this.buckets, bucketId)
this.buckets[bucketIndex] = {
...this.buckets[bucketIndex],
tasks,
}
},
setTaskInBucket(task: ITask) {
// If this gets invoked without any tasks actually loaded, we can save the hassle of finding the task
if (this.buckets.length === 0) {
return
}
let found = false
const findAndUpdate = b => {
for (const t in this.buckets[b].tasks) {
if (this.buckets[b].tasks[t].id === task.id) {
const bucket = this.buckets[b]
bucket.tasks[t] = task
if (bucket.id !== task.bucketId) {
bucket.tasks.splice(t, 1)
addTaskToBucketAndSort(this, task)
}
this.buckets[b] = bucket
found = true
return
}
}
}
for (const b in this.buckets) {
if (this.buckets[b].id === task.bucketId) {
findAndUpdate(b)
if (found) {
return
}
}
}
for (const b in this.buckets) {
findAndUpdate(b)
if (found) {
return
}
}
},
addTaskToBucket(task: ITask) {
const bucketIndex = findIndexById(this.buckets, task.bucketId)
const oldBucket = this.buckets[bucketIndex]
const newBucket = {
...oldBucket,
tasks: [
...oldBucket.tasks,
task,
],
}
this.buckets[bucketIndex] = newBucket
},
addTasksToBucket({tasks, bucketId}: {
tasks: ITask[];
bucketId: IBucket['id'];
}) {
const bucketIndex = findIndexById(this.buckets, bucketId)
const oldBucket = this.buckets[bucketIndex]
const newBucket = {
...oldBucket,
tasks: [
...oldBucket.tasks,
...tasks,
],
}
this.buckets[bucketIndex] = newBucket
},
removeTaskInBucket(task: ITask) {
// If this gets invoked without any tasks actually loaded, we can save the hassle of finding the task
if (this.buckets.length === 0) {
return
}
const { bucketIndex, taskIndex } = getTaskIndicesById(this, task.id)
if (
!bucketIndex ||
this.buckets[bucketIndex]?.id !== task.bucketId ||
!taskIndex ||
(this.buckets[bucketIndex]?.tasks[taskIndex]?.id !== task.id)
) {
return
}
this.buckets[bucketIndex].tasks.splice(taskIndex, 1)
},
setBucketLoading({bucketId, loading}: {bucketId: IBucket['id'], loading: boolean}) {
this.bucketLoading[bucketId] = loading
},
setTasksLoadedForBucketPage({bucketId, page}: {bucketId: IBucket['id'], page: number}) {
this.taskPagesPerBucket[bucketId] = page
},
setAllTasksLoadedForBucket(bucketId: IBucket['id']) {
this.allTasksLoadedForBucket[bucketId] = true
},
async loadBucketsForList({listId, params}: {listId: IList['id'], params}) {
const cancel = setModuleLoading(this)
// Clear everything to prevent having old buckets in the list if loading the buckets from this list takes a few moments
ctx.commit('setBuckets', [])
this.setBuckets([])
params.per_page = TASKS_PER_BUCKET
const bucketService = new BucketService()
try {
const response = await bucketService.getAll({listId}, params)
ctx.commit('setBuckets', response)
ctx.commit('setListId', listId)
return response
const buckets = await bucketService.getAll({listId}, params)
this.setBuckets(buckets)
this.setListId(listId)
return buckets
} finally {
cancel()
}
},
async loadNextTasksForBucket(ctx, {listId, ps = {}, bucketId}) {
const isLoading = ctx.state.bucketLoading[bucketId] ?? false
async loadNextTasksForBucket(
{listId, ps = {}, bucketId} :
{listId: IList['id'], ps, bucketId: IBucket['id']},
) {
const isLoading = this.bucketLoading[bucketId] ?? false
if (isLoading) {
return
}
const page = (ctx.state.taskPagesPerBucket[bucketId] ?? 1) + 1
const page = (this.taskPagesPerBucket[bucketId] ?? 1) + 1
const alreadyLoaded = ctx.state.allTasksLoadedForBucket[bucketId] ?? false
const alreadyLoaded = this.allTasksLoadedForBucket[bucketId] ?? false
if (alreadyLoaded) {
return
}
const cancel = setLoading(ctx, 'kanban')
ctx.commit('setBucketLoading', {bucketId: bucketId, loading: true})
const cancel = setModuleLoading(this)
this.setBucketLoading({bucketId: bucketId, loading: true})
const params = JSON.parse(JSON.stringify(ps))
@ -305,67 +325,67 @@ const kanbanStore : Module<KanbanState, RootStoreState> = {
const taskService = new TaskCollectionService()
try {
const tasks = await taskService.getAll({listId}, params, page)
ctx.commit('addTasksToBucket', {tasks, bucketId: bucketId})
ctx.commit('setTasksLoadedForBucketPage', {bucketId, page})
this.addTasksToBucket({tasks, bucketId: bucketId})
this.setTasksLoadedForBucketPage({bucketId, page})
if (taskService.totalPages <= page) {
ctx.commit('setAllTasksLoadedForBucket', bucketId)
this.setAllTasksLoadedForBucket(bucketId)
}
return tasks
} finally {
cancel()
ctx.commit('setBucketLoading', {bucketId, loading: false})
this.setBucketLoading({bucketId, loading: false})
}
},
async createBucket(ctx, bucket: IBucket) {
const cancel = setLoading(ctx, 'kanban')
async createBucket(bucket: IBucket) {
const cancel = setModuleLoading(this)
const bucketService = new BucketService()
try {
const createdBucket = await bucketService.create(bucket)
ctx.commit('addBucket', createdBucket)
this.addBucket(createdBucket)
return createdBucket
} finally {
cancel()
}
},
async deleteBucket(ctx, {bucket, params}) {
const cancel = setLoading(ctx, 'kanban')
async deleteBucket({bucket, params}: {bucket: IBucket, params}) {
const cancel = setModuleLoading(this)
const bucketService = new BucketService()
try {
const response = await bucketService.delete(bucket)
ctx.commit('removeBucket', bucket)
this.removeBucket(bucket)
// We reload all buckets because tasks are being moved from the deleted bucket
ctx.dispatch('loadBucketsForList', {listId: bucket.listId, params})
this.loadBucketsForList({listId: bucket.listId, params})
return response
} finally {
cancel()
}
},
async updateBucket(ctx, updatedBucketData) {
const cancel = setLoading(ctx, 'kanban')
async updateBucket(updatedBucketData: IBucket) {
const cancel = setModuleLoading(this)
const bucketIndex = findIndexById(ctx.state.buckets, updatedBucketData.id)
const oldBucket = cloneDeep(ctx.state.buckets[bucketIndex])
const bucketIndex = findIndexById(this.buckets, updatedBucketData.id)
const oldBucket = cloneDeep(this.buckets[bucketIndex])
const updatedBucket = {
...oldBucket,
...updatedBucketData,
}
ctx.commit('setBucketByIndex', {bucketIndex, bucket: updatedBucket})
this.setBucketByIndex({bucketIndex, bucket: updatedBucket})
const bucketService = new BucketService()
try {
const returnedBucket = await bucketService.update(updatedBucket)
ctx.commit('setBucketByIndex', {bucketIndex, bucket: returnedBucket})
this.setBucketByIndex({bucketIndex, bucket: returnedBucket})
return returnedBucket
} catch(e) {
// restore original state
ctx.commit('setBucketByIndex', {bucketIndex, bucket: oldBucket})
this.setBucketByIndex({bucketIndex, bucket: oldBucket})
throw e
} finally {
@ -373,23 +393,21 @@ const kanbanStore : Module<KanbanState, RootStoreState> = {
}
},
async updateBucketTitle(ctx, { id, title }) {
const bucket = findById(ctx.state.buckets, id)
async updateBucketTitle({ id, title }: { id: IBucket['id'], title: IBucket['title'] }) {
const bucket = findById(this.buckets, id)
if (bucket?.title === title) {
// bucket title has not changed
return
}
const updatedBucketData = {
id,
title,
}
await ctx.dispatch('updateBucket', updatedBucketData)
await this.updateBucket({ id, title })
success({message: i18n.global.t('list.kanban.bucketTitleSavedSuccess')})
},
},
}
})
export default kanbanStore
// support hot reloading
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useKanbanStore, import.meta.hot))
}

View file

@ -4,7 +4,7 @@ import LabelService from '@/services/label'
import {success} from '@/message'
import {i18n} from '@/i18n'
import {createNewIndexer} from '@/indexes'
import {setLoadingPinia} from '@/store/helper'
import {setModuleLoading} from '@/stores/helper'
import type {ILabel} from '@/modelTypes/ILabel'
const {add, remove, update, search} = createNewIndexer('labels', ['title', 'description'])
@ -20,7 +20,12 @@ async function getAllLabels(page = 1): Promise<ILabel[]> {
}
}
import type {LabelState} from '@/store/types'
export interface LabelState {
labels: {
[id: ILabel['id']]: ILabel
},
isLoading: boolean,
}
export const useLabelStore = defineStore('label', {
state: () : LabelState => ({
@ -80,7 +85,7 @@ export const useLabelStore = defineStore('label', {
return
}
const cancel = setLoadingPinia(this)
const cancel = setModuleLoading(this)
try {
const labels = await getAllLabels()
@ -92,7 +97,7 @@ export const useLabelStore = defineStore('label', {
},
async deleteLabel(label: ILabel) {
const cancel = setLoadingPinia(this)
const cancel = setModuleLoading(this)
const labelService = new LabelService()
try {
@ -106,7 +111,7 @@ export const useLabelStore = defineStore('label', {
},
async updateLabel(label: ILabel) {
const cancel = setLoadingPinia(this)
const cancel = setModuleLoading(this)
const labelService = new LabelService()
try {
@ -120,7 +125,7 @@ export const useLabelStore = defineStore('label', {
},
async createLabel(label: ILabel) {
const cancel = setLoadingPinia(this)
const cancel = setModuleLoading(this)
const labelService = new LabelService()
try {

View file

@ -3,12 +3,11 @@ import {acceptHMRUpdate, defineStore} from 'pinia'
import {useI18n} from 'vue-i18n'
import ListService from '@/services/list'
import {setLoadingPinia} from '@/store/helper'
import {setModuleLoading} from '@/stores/helper'
import {removeListFromHistory} from '@/modules/listHistory'
import {createNewIndexer} from '@/indexes'
import {useNamespaceStore} from './namespaces'
import type {ListState} from '@/store/types'
import type {IList} from '@/modelTypes/IList'
import type {MaybeRef} from '@vueuse/core'
@ -20,6 +19,11 @@ const {add, remove, search, update} = createNewIndexer('lists', ['title', 'descr
const FavoriteListsNamespace = -2
export interface ListState {
lists: { [id: IList['id']]: IList },
isLoading: boolean,
}
export const useListStore = defineStore('list', {
state: () : ListState => ({
isLoading: false,
@ -87,7 +91,7 @@ export const useListStore = defineStore('list', {
},
async createList(list: IList) {
const cancel = setLoadingPinia(this)
const cancel = setModuleLoading(this)
const listService = new ListService()
try {
@ -103,7 +107,7 @@ export const useListStore = defineStore('list', {
},
async updateList(list: IList) {
const cancel = setLoadingPinia(this)
const cancel = setModuleLoading(this)
const listService = new ListService()
try {
@ -113,7 +117,7 @@ export const useListStore = defineStore('list', {
namespaceStore.setListInNamespaceById(list)
// the returned list from listService.update is the same!
// in order to not validate vuex mutations we have to create a new copy
// in order to not create a manipulation in pinia store we have to create a new copy
const newList = {
...list,
namespaceId: FavoriteListsNamespace,
@ -139,7 +143,7 @@ export const useListStore = defineStore('list', {
},
async deleteList(list: IList) {
const cancel = setLoadingPinia(this)
const cancel = setModuleLoading(this)
const listService = new ListService()
try {

View file

@ -1,15 +1,19 @@
import {defineStore, acceptHMRUpdate} from 'pinia'
import NamespaceService from '../services/namespace'
import {setLoadingPinia} from '@/store/helper'
import {setModuleLoading} from '@/stores/helper'
import {createNewIndexer} from '@/indexes'
import type {NamespaceState} from '@/store/types'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IList} from '@/modelTypes/IList'
import {useListStore} from '@/stores/lists'
const {add, remove, search, update} = createNewIndexer('namespaces', ['title', 'description'])
export interface NamespaceState {
namespaces: INamespace[]
isLoading: boolean,
}
export const useNamespaceStore = defineStore('namespace', {
state: (): NamespaceState => ({
isLoading: false,
@ -135,7 +139,7 @@ export const useNamespaceStore = defineStore('namespace', {
},
async loadNamespaces() {
const cancel = setLoadingPinia(this)
const cancel = setModuleLoading(this)
const namespaceService = new NamespaceService()
try {
@ -170,7 +174,7 @@ export const useNamespaceStore = defineStore('namespace', {
},
async deleteNamespace(namespace: INamespace) {
const cancel = setLoadingPinia(this)
const cancel = setModuleLoading(this)
const namespaceService = new NamespaceService()
try {
@ -183,7 +187,7 @@ export const useNamespaceStore = defineStore('namespace', {
},
async createNamespace(namespace: INamespace) {
const cancel = setLoadingPinia(this)
const cancel = setModuleLoading(this)
const namespaceService = new NamespaceService()
try {

View file

@ -7,8 +7,7 @@ import TaskAssigneeService from '@/services/taskAssignee'
import LabelTaskService from '@/services/labelTask'
import UserService from '@/services/user'
import {HAS_TASKS} from '../store/mutation-types'
import {setLoadingPinia} from '../store/helper'
import {playPop} from '@/helpers/playPop'
import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
import {parseTaskText} from '@/modules/parseTaskText'
@ -24,12 +23,11 @@ import type {IUser} from '@/modelTypes/IUser'
import type {IAttachment} from '@/modelTypes/IAttachment'
import type {IList} from '@/modelTypes/IList'
import type {TaskState} from '@/store/types'
import {setModuleLoading} from '@/stores/helper'
import {useLabelStore} from '@/stores/labels'
import {useListStore} from '@/stores/lists'
import {useAttachmentStore} from '@/stores/attachments'
import {playPop} from '@/helpers/playPop'
import {store} from '@/store'
import {useKanbanStore} from '@/stores/kanban'
// IDEA: maybe use a small fuzzy search here to prevent errors
function findPropertyByValue(object, key, value) {
@ -38,9 +36,14 @@ function findPropertyByValue(object, key, value) {
)
}
// Check if the user exists
function validateUsername(users: IUser[], username: IUser['username']) {
return findPropertyByValue(users, 'username', username)
// Check if the user exists in the search results
function validateUser(
users: IUser[],
query: IUser['username'] | IUser['name'] | IUser['email'],
) {
return findPropertyByValue(users, 'username', query) ||
findPropertyByValue(users, 'name', query) ||
findPropertyByValue(users, 'email', query)
}
// Check if the label exists
@ -67,39 +70,54 @@ async function findAssignees(parsedTaskAssignees: string[]) {
const userService = new UserService()
const assignees = parsedTaskAssignees.map(async a => {
const users = await userService.getAll({}, {s: a})
return validateUsername(users, a)
return validateUser(users, a)
})
const validatedUsers = await Promise.all(assignees)
return validatedUsers.filter((item) => Boolean(item))
}
export interface TaskState {
tasks: { [id: ITask['id']]: ITask }
isLoading: boolean,
}
export const useTaskStore = defineStore('task', {
state: () : TaskState => ({
tasks: {},
isLoading: false,
}),
getters: {
hasTasks(state) {
return Object.keys(state.tasks).length > 0
},
},
actions: {
setTasks(tasks: ITask[]) {
tasks.forEach(task => {
this.tasks[task.id] = task
})
},
async loadTasks(params) {
const taskService = new TaskService()
const cancel = setLoadingPinia(this)
const cancel = setModuleLoading(this)
try {
const tasks = await taskService.getAll({}, params)
store.commit(HAS_TASKS, tasks.length > 0)
return tasks
this.tasks = await taskService.getAll({}, params)
return this.tasks
} finally {
cancel()
}
},
async update(task: ITask) {
const cancel = setLoadingPinia(this)
const cancel = setModuleLoading(this)
const taskService = new TaskService()
try {
const updatedTask = await taskService.update(task)
store.commit('kanban/setTaskInBucket', updatedTask)
useKanbanStore().setTaskInBucket(updatedTask)
if (task.done) {
playPop()
}
@ -112,7 +130,7 @@ export const useTaskStore = defineStore('task', {
async delete(task: ITask) {
const taskService = new TaskService()
const response = await taskService.delete(task)
store.commit('kanban/removeTaskInBucket', task)
useKanbanStore().removeTaskInBucket(task)
return response
},
@ -125,7 +143,8 @@ export const useTaskStore = defineStore('task', {
taskId: ITask['id']
attachment: IAttachment
}) {
const t = store.getters['kanban/getTaskById'](taskId)
const kanbanStore = useKanbanStore()
const t = kanbanStore.getTaskById(taskId)
if (t.task !== null) {
const attachments = [
...t.task.attachments,
@ -139,7 +158,7 @@ export const useTaskStore = defineStore('task', {
attachments,
},
}
store.commit('kanban/setTaskInBucketByIndex', newTask)
kanbanStore.setTaskInBucketByIndex(newTask)
}
const attachmentStore = useAttachmentStore()
attachmentStore.add(attachment)
@ -152,12 +171,13 @@ export const useTaskStore = defineStore('task', {
user: IUser,
taskId: ITask['id']
}) {
const kanbanStore = useKanbanStore()
const taskAssigneeService = new TaskAssigneeService()
const r = await taskAssigneeService.create(new TaskAssigneeModel({
userId: user.id,
taskId: taskId,
}))
const t = store.getters['kanban/getTaskById'](taskId)
const t = kanbanStore.getTaskById(taskId)
if (t.task === null) {
// Don't try further adding a label if the task is not in kanban
// Usually this means the kanban board hasn't been accessed until now.
@ -166,7 +186,7 @@ export const useTaskStore = defineStore('task', {
return r
}
store.commit('kanban/setTaskInBucketByIndex', {
kanbanStore.setTaskInBucketByIndex({
...t,
task: {
...t.task,
@ -186,12 +206,13 @@ export const useTaskStore = defineStore('task', {
user: IUser,
taskId: ITask['id']
}) {
const kanbanStore = useKanbanStore()
const taskAssigneeService = new TaskAssigneeService()
const response = await taskAssigneeService.delete(new TaskAssigneeModel({
userId: user.id,
taskId: taskId,
}))
const t = store.getters['kanban/getTaskById'](taskId)
const t = kanbanStore.getTaskById(taskId)
if (t.task === null) {
// Don't try further adding a label if the task is not in kanban
// Usually this means the kanban board hasn't been accessed until now.
@ -202,7 +223,7 @@ export const useTaskStore = defineStore('task', {
const assignees = t.task.assignees.filter(({ id }) => id !== user.id)
store.commit('kanban/setTaskInBucketByIndex', {
kanbanStore.setTaskInBucketByIndex({
...t,
task: {
...t.task,
@ -220,12 +241,13 @@ export const useTaskStore = defineStore('task', {
label: ILabel,
taskId: ITask['id']
}) {
const kanbanStore = useKanbanStore()
const labelTaskService = new LabelTaskService()
const r = await labelTaskService.create(new LabelTaskModel({
taskId,
labelId: label.id,
}))
const t = store.getters['kanban/getTaskById'](taskId)
const t = kanbanStore.getTaskById(taskId)
if (t.task === null) {
// Don't try further adding a label if the task is not in kanban
// Usually this means the kanban board hasn't been accessed until now.
@ -234,7 +256,7 @@ export const useTaskStore = defineStore('task', {
return r
}
store.commit('kanban/setTaskInBucketByIndex', {
kanbanStore.setTaskInBucketByIndex({
...t,
task: {
...t.task,
@ -252,12 +274,13 @@ export const useTaskStore = defineStore('task', {
{label, taskId}:
{label: ILabel, taskId: ITask['id']},
) {
const kanbanStore = useKanbanStore()
const labelTaskService = new LabelTaskService()
const response = await labelTaskService.delete(new LabelTaskModel({
taskId, labelId:
label.id,
}))
const t = store.getters['kanban/getTaskById'](taskId)
const t = kanbanStore.getTaskById(taskId)
if (t.task === null) {
// Don't try further adding a label if the task is not in kanban
// Usually this means the kanban board hasn't been accessed until now.
@ -269,7 +292,7 @@ export const useTaskStore = defineStore('task', {
// Remove the label from the list
const labels = t.task.labels.filter(({ id }) => id !== label.id)
store.commit('kanban/setTaskInBucketByIndex', {
kanbanStore.setTaskInBucketByIndex({
...t,
task: {
...t.task,
@ -346,7 +369,7 @@ export const useTaskStore = defineStore('task', {
} :
Partial<ITask>,
) {
const cancel = setLoadingPinia(this)
const cancel = setModuleLoading(this)
const parsedTask = parseTaskText(title, getQuickAddMagicMode())
const foundListId = await this.findListId({
@ -375,13 +398,16 @@ export const useTaskStore = defineStore('task', {
task.repeatAfter = parsedTask.repeats
const taskService = new TaskService()
const createdTask = await taskService.create(task)
const result = await this.addLabelsToTask({
task: createdTask,
parsedLabels: parsedTask.labels,
})
cancel()
return result
try {
const createdTask = await taskService.create(task)
const result = await this.addLabelsToTask({
task: createdTask,
parsedLabels: parsedTask.labels,
})
return result
} finally {
cancel()
}
},
},
})

6
src/types/IProvider.ts Normal file
View file

@ -0,0 +1,6 @@
export interface IProvider {
name: string;
key: string;
authUrl: string;
clientId: string;
}

View file

@ -1,30 +0,0 @@
// https://next.vuex.vuejs.org/guide/migrating-to-4-0-from-3-x.html#typescript-support
import { Store } from 'vuex'
import type {
RootStoreState,
AttachmentState,
AuthState,
ConfigState,
KanbanState,
LabelState,
ListState,
NamespaceState,
TaskState,
} from '@/store/types'
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$store: Store<RootStoreState & {
config: ConfigState,
auth: AuthState,
namespaces: NamespaceState,
kanban: KanbanState,
tasks: TaskState,
lists: ListState,
attachments: AttachmentState,
labels: LabelState,
}>
}
}

View file

@ -60,7 +60,6 @@
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {useStore} from '@/store'
import Message from '@/components/misc/message.vue'
import ShowTasks from '@/views/tasks/ShowTasks.vue'
@ -71,18 +70,23 @@ import {getHistory} from '@/modules/listHistory'
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
import {formatDateShort, formatDateSince} from '@/helpers/time/formatDate'
import {useDateTimeSalutation} from '@/composables/useDateTimeSalutation'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
import {useConfigStore} from '@/stores/config'
import {useNamespaceStore} from '@/stores/namespaces'
import {useAuthStore} from '@/stores/auth'
import {useTaskStore} from '@/stores/tasks'
const welcome = useDateTimeSalutation()
const store = useStore()
const baseStore = useBaseStore()
const authStore = useAuthStore()
const configStore = useConfigStore()
const namespaceStore = useNamespaceStore()
const listStore = useListStore()
const taskStore = useTaskStore()
const listHistory = computed(() => {
// If we don't check this, it tries to load the list background right after logging out
if(!authStore.authenticated) {
@ -96,15 +100,15 @@ const listHistory = computed(() => {
const migratorsEnabled = computed(() => configStore.availableMigrators?.length > 0)
const userInfo = computed(() => authStore.info)
const hasTasks = computed(() => store.state.hasTasks)
const hasTasks = computed(() => baseStore.hasTasks)
const defaultListId = computed(() => authStore.settings.defaultListId)
const defaultNamespaceId = computed(() => namespaceStore.namespaces?.[0]?.id || 0)
const hasLists = computed(() => namespaceStore.namespaces?.[0]?.lists.length > 0)
const loading = computed(() => store.state.loading && store.state.loadingModule === 'tasks')
const loading = computed(() => taskStore.isLoading)
const deletionScheduledAt = computed(() => parseDateOrNull(authStore.info?.deletionScheduledAt))
// This is to reload the tasks list after adding a new task through the global task add.
// FIXME: Should use vuex (somehow?)
// FIXME: Should use pinia (somehow?)
const showTasksKey = ref(0)
function updateTaskList() {

View file

@ -68,9 +68,12 @@ import SavedFilterService from '@/services/savedFilter'
import {objectToSnakeCase} from '@/helpers/case'
import {getSavedFilterIdFromListId} from '@/helpers/savedFilter'
import type {IList} from '@/modelTypes/IList'
import {useBaseStore} from '@/stores/base'
import {useNamespaceStore} from '@/stores/namespaces'
const {t} = useI18n({useScope: 'global'})
const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore()
function useSavedFilter(listId: MaybeRef<IList['id']>) {
@ -126,6 +129,7 @@ const router = useRouter()
async function saveSavedFilter() {
await save()
await baseStore.setCurrentList({list: filter})
router.back()
}
</script>

View file

@ -227,11 +227,10 @@
import {defineComponent} from 'vue'
import draggable from 'zhyswan-vuedraggable'
import cloneDeep from 'lodash.clonedeep'
import {mapState} from 'pinia'
import BucketModel from '../../models/bucket'
import {mapState} from 'vuex'
import {RIGHTS as Rights} from '@/constants/rights'
import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
import ListWrapper from './ListWrapper.vue'
import FilterPopup from '@/components/list/partials/filter-popup.vue'
import Dropdown from '@/components/misc/dropdown.vue'
@ -240,7 +239,10 @@ import {calculateItemPosition} from '../../helpers/calculateItemPosition'
import KanbanCard from '@/components/tasks/partials/kanban-card.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import {isSavedFilter} from '@/helpers/savedFilter'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
import {useKanbanStore} from '@/stores/kanban'
const DRAG_OPTIONS = {
// sortable options
@ -342,16 +344,18 @@ export default defineComponent({
],
}
},
buckets() {
return this.$store.state.kanban.buckets
},
...mapState({
loadedListId: state => state.kanban.listId,
loading: state => state[LOADING] && state[LOADING_MODULE] === 'kanban',
taskLoading: state => state[LOADING] && state[LOADING_MODULE] === 'tasks',
...mapState(useBaseStore, {
canWrite: state => state.currentList.maxRight > Rights.READ,
list: state => state.currentList,
}),
...mapState(useKanbanStore, {
buckets: state => state.buckets,
loadedListId: state => state.listId,
loading: state => state.isLoading,
}),
...mapState(useTaskStore, {
taskLoading: state => state.isLoading,
}),
},
methods: {
@ -364,7 +368,7 @@ export default defineComponent({
console.debug(`Loading buckets, loadedListId = ${this.loadedListId}, $attrs = ${this.$attrs} $route.params =`, this.$route.params)
this.$store.dispatch('kanban/loadBucketsForList', {listId, params})
useKanbanStore().loadBucketsForList({listId, params})
},
setTaskContainerRef(id, el) {
@ -382,7 +386,7 @@ export default defineComponent({
return
}
this.$store.dispatch('kanban/loadNextTasksForBucket', {
useKanbanStore().loadNextTasksForBucket({
listId: listId,
params: this.params,
bucketId: id,
@ -390,12 +394,13 @@ export default defineComponent({
},
updateTasks(bucketId, tasks) {
const kanbanStore = useKanbanStore()
const newBucket = {
...this.$store.getters['kanban/getBucketById'](bucketId),
...kanbanStore.getBucketById(bucketId),
tasks,
}
this.$store.commit('kanban/setBucketById', newBucket)
kanbanStore.setBucketById(newBucket)
},
async updateTaskPosition(e) {
@ -426,7 +431,7 @@ export default defineComponent({
const taskAfter = newBucket.tasks[newTaskIndex + 1] ?? null
this.taskUpdating[task.id] = true
const newTask = cloneDeep(task) // cloning the task to avoid vuex store mutations
const newTask = cloneDeep(task) // cloning the task to avoid pinia store manipulation
newTask.bucketId = newBucket.id
newTask.kanbanPosition = calculateItemPosition(
taskBefore !== null ? taskBefore.kanbanPosition : null,
@ -440,7 +445,7 @@ export default defineComponent({
// Make sure the first and second task don't both get position 0 assigned
if(newTaskIndex === 0 && taskAfter !== null && taskAfter.kanbanPosition === 0) {
const taskAfterAfter = newBucket.tasks[newTaskIndex + 2] ?? null
const newTaskAfter = cloneDeep(taskAfter) // cloning the task to avoid vuex store mutations
const newTaskAfter = cloneDeep(taskAfter) // cloning the task to avoid pinia store manipulation
newTaskAfter.bucketId = newBucket.id
newTaskAfter.kanbanPosition = calculateItemPosition(
0,
@ -472,7 +477,7 @@ export default defineComponent({
listId: this.listId,
})
this.newTaskText = ''
this.$store.commit('kanban/addTaskToBucket', task)
useKanbanStore().addTaskToBucket(task)
this.scrollTaskContainerToBottom(bucketId)
},
@ -494,7 +499,7 @@ export default defineComponent({
listId: this.listId,
})
await this.$store.dispatch('kanban/createBucket', newBucket)
await useKanbanStore().createBucket(newBucket)
this.newBucketTitle = ''
this.showNewBucketInput = false
},
@ -515,7 +520,7 @@ export default defineComponent({
})
try {
await this.$store.dispatch('kanban/deleteBucket', {
await useKanbanStore().deleteBucket({
bucket,
params: this.params,
})
@ -537,13 +542,13 @@ export default defineComponent({
title: bucketTitle,
}
await this.$store.dispatch('kanban/updateBucketTitle', updatedBucketData)
await useKanbanStore().updateBucketTitle(updatedBucketData)
this.bucketTitleEditable = false
},
updateBuckets(value) {
// (1) buckets get updated in store and tasks positions get invalidated
this.$store.commit('kanban/setBuckets', value)
useKanbanStore().setBuckets(value)
},
updateBucketPosition(e) {
@ -562,7 +567,7 @@ export default defineComponent({
),
}
this.$store.dispatch('kanban/updateBucket', updatedData)
useKanbanStore().updateBucket(updatedData)
},
async setBucketLimit(bucketId, limit) {
@ -570,12 +575,14 @@ export default defineComponent({
return
}
const kanbanStore = useKanbanStore()
const newBucket = {
...this.$store.getters['kanban/getBucketById'](bucketId),
...kanbanStore.getBucketById(bucketId),
limit,
}
await this.$store.dispatch('kanban/updateBucket', newBucket)
await kanbanStore.updateBucket(newBucket)
this.$message.success({message: this.$t('list.kanban.bucketLimitSavedSuccess')})
},
@ -600,7 +607,7 @@ export default defineComponent({
...bucket,
isDoneBucket: !bucket.isDoneBucket,
}
await this.$store.dispatch('kanban/updateBucket', newBucket)
await useKanbanStore().updateBucket(newBucket)
this.$message.success({message: this.$t('list.kanban.doneBucketSavedSuccess')})
},

View file

@ -118,7 +118,7 @@
v-if="isTaskEdit"
class="taskedit mt-0"
:title="$t('list.list.editTask')"
@close="() => isTaskEdit = false"
@close="closeTaskEditPane()"
:shadow="false"
:task="taskEditTask"
/>
@ -139,7 +139,7 @@ export default { name: 'List' }
</script>
<script setup lang="ts">
import {ref, computed, toRef, nextTick, onMounted, type PropType} from 'vue'
import {ref, computed, toRef, nextTick, onMounted, type PropType, watch} from 'vue'
import draggable from 'zhyswan-vuedraggable'
import {useRoute, useRouter} from 'vue-router'
@ -154,13 +154,13 @@ import Nothing from '@/components/misc/nothing.vue'
import Pagination from '@/components/misc/pagination.vue'
import {ALPHABETICAL_SORT} from '@/components/list/partials/filters.vue'
import {useStore} from '@/store'
import {HAS_TASKS} from '@/store/mutation-types'
import {useTaskList} from '@/composables/taskList'
import {RIGHTS as Rights} from '@/constants/rights'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import type {ITask} from '@/modelTypes/ITask'
import {isSavedFilter} from '@/helpers/savedFilter'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
import type {IList} from '@/modelTypes/IList'
@ -203,6 +203,16 @@ const DRAG_OPTIONS = {
const taskEditTask = ref<ITask | null>(null)
const isTaskEdit = ref(false)
function closeTaskEditPane() {
isTaskEdit.value = false
taskEditTask.value = null
}
watch(
() => props.listId,
closeTaskEditPane,
)
const {
tasks,
loading,
@ -228,8 +238,8 @@ const firstNewPosition = computed(() => {
})
const taskStore = useTaskStore()
const store = useStore()
const list = computed(() => store.state.currentList)
const baseStore = useBaseStore()
const list = computed(() => baseStore.currentList)
const canWrite = computed(() => {
return list.value.maxRight > Rights.READ && list.value.id > 0
@ -283,7 +293,7 @@ function updateTaskList(task: ITask) {
]
}
store.commit(HAS_TASKS, true)
baseStore.setHasTasks(true)
}
function editTask(id: ITask['id']) {

View file

@ -55,13 +55,13 @@ import Message from '@/components/misc/message.vue'
import ListModel from '@/models/list'
import ListService from '@/services/list'
import {BACKGROUND, BLUR_HASH, CURRENT_LIST} from '@/store/mutation-types'
import {getListTitle} from '@/helpers/getListTitle'
import {saveListToHistory} from '@/modules/listHistory'
import {useTitle} from '@/composables/useTitle'
import {useStore} from '@/store'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
import {useKanbanStore} from '@/stores/kanban'
const props = defineProps({
listId: {
@ -75,19 +75,20 @@ const props = defineProps({
})
const route = useRoute()
const store = useStore()
const baseStore = useBaseStore()
const kanbanStore = useKanbanStore()
const listStore = useListStore()
const listService = ref(new ListService())
const loadedListId = ref(0)
const currentList = computed(() => {
return typeof store.state.currentList === 'undefined' ? {
return typeof baseStore.currentList === 'undefined' ? {
id: 0,
title: '',
isArchived: false,
maxRight: null,
} : store.state.currentList
} : baseStore.currentList
})
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
@ -116,7 +117,7 @@ async function loadList(listIdToLoad: number) {
props.viewName === 'list.list' ||
props.viewName === 'list.gantt'
) {
store.commit('kanban/setListId', 0)
kanbanStore.setListId(0)
}
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
@ -139,16 +140,16 @@ async function loadList(listIdToLoad: number) {
loadedListId.value = 0
const listFromStore = listStore.getListById(listData.id)
if (listFromStore !== null) {
store.commit(BACKGROUND, null)
store.commit(BLUR_HASH, null)
store.dispatch(CURRENT_LIST, {list: listFromStore})
baseStore.setBackground(null)
baseStore.setBlurHash(null)
baseStore.handleSetCurrentList({list: listFromStore})
}
// We create an extra list object instead of creating it in list.value because that would trigger a ui update which would result in bad ux.
const list = new ListModel(listData)
try {
const loadedList = await listService.value.get(list)
await store.dispatch(CURRENT_LIST, {list: loadedList})
await baseStore.handleSetCurrentList({list: loadedList})
} finally {
loadedListId.value = props.listId
}

View file

@ -17,16 +17,16 @@ export default {name: 'list-setting-archive'}
<script setup lang="ts">
import {computed} from 'vue'
import {useStore} from '@/store'
import {useRouter, useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import { success } from '@/message'
import { useTitle } from '@/composables/useTitle'
import { useListStore } from '@/stores/lists'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
const {t} = useI18n({useScope: 'global'})
const store = useStore()
const listStore = useListStore()
const router = useRouter()
const route = useRoute()
@ -40,7 +40,7 @@ async function archiveList() {
...list.value,
isArchived: !list.value.isArchived,
})
store.commit('currentList', newList)
useBaseStore().setCurrentList(newList)
success({message: t('list.archive.success')})
} finally {
router.back()

View file

@ -100,10 +100,11 @@ export default { name: 'list-setting-background' }
<script setup lang="ts">
import {ref, computed, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import {useStore} from '@/store'
import {useRoute, useRouter} from 'vue-router'
import debounce from 'lodash.debounce'
import BaseButton from '@/components/base/BaseButton.vue'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import {useConfigStore} from '@/stores/config'
@ -115,7 +116,6 @@ import type BackgroundImageModel from '@/models/backgroundImage'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import {useTitle} from '@/composables/useTitle'
import {CURRENT_LIST} from '@/store/mutation-types'
import CreateEdit from '@/components/misc/create-edit.vue'
import {success} from '@/message'
@ -123,7 +123,7 @@ import {success} from '@/message'
const SEARCH_DEBOUNCE = 300
const {t} = useI18n({useScope: 'global'})
const store = useStore()
const baseStore = useBaseStore()
const route = useRoute()
const router = useRouter()
@ -149,8 +149,8 @@ const configStore = useConfigStore()
const unsplashBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('unsplash'))
const uploadBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('upload'))
const currentList = computed(() => store.state.currentList)
const hasBackground = computed(() => store.state.background !== null)
const currentList = computed(() => baseStore.currentList)
const hasBackground = computed(() => baseStore.background !== null)
// Show the default collection of backgrounds
newBackgroundSearch()
@ -188,8 +188,11 @@ async function setBackground(backgroundId: string) {
return
}
const list = await backgroundService.update({id: backgroundId, listId: route.params.listId})
await store.dispatch(CURRENT_LIST, {list, forceUpdate: true})
const list = await backgroundService.update({
id: backgroundId,
listId: route.params.listId,
})
await baseStore.handleSetCurrentList({list, forceUpdate: true})
namespaceStore.setListInNamespaceById(list)
listStore.setList(list)
success({message: t('list.background.success')})
@ -201,8 +204,11 @@ async function uploadBackground() {
return
}
const list = await backgroundUploadService.value.create(route.params.listId, backgroundUploadInput.value?.files[0])
await store.dispatch(CURRENT_LIST, {list, forceUpdate: true})
const list = await backgroundUploadService.value.create(
route.params.listId,
backgroundUploadInput.value?.files[0],
)
await baseStore.handleSetCurrentList({list, forceUpdate: true})
namespaceStore.setListInNamespaceById(list)
listStore.setList(list)
success({message: t('list.background.success')})
@ -210,7 +216,7 @@ async function uploadBackground() {
async function removeBackground() {
const list = await listService.value.removeBackground(currentList.value)
await store.dispatch(CURRENT_LIST, {list, forceUpdate: true})
await baseStore.handleSetCurrentList({list, forceUpdate: true})
namespaceStore.setListInNamespaceById(list)
listStore.setList(list)
success({message: t('list.background.removeSuccess')})

View file

@ -72,17 +72,17 @@ export default { name: 'list-setting-edit' }
<script setup lang="ts">
import type {PropType} from 'vue'
import {useRouter} from 'vue-router'
import {useStore} from '@/store'
import {useI18n} from 'vue-i18n'
import Editor from '@/components/input/AsyncEditor'
import ColorPicker from '@/components/input/colorPicker.vue'
import CreateEdit from '@/components/misc/create-edit.vue'
import {CURRENT_LIST} from '@/store/mutation-types'
import type {IList} from '@/modelTypes/IList'
import {useBaseStore} from '@/stores/base'
import {useList} from '@/stores/lists'
import {useTitle} from '@/composables/useTitle'
const props = defineProps({
@ -93,7 +93,6 @@ const props = defineProps({
})
const router = useRouter()
const store = useStore()
const {t} = useI18n({useScope: 'global'})
@ -103,7 +102,7 @@ useTitle(() => list?.title ? t('list.edit.title', {list: list.title}) : '')
async function save() {
await saveList()
await store.dispatch(CURRENT_LIST, {list})
await useBaseStore().handleSetCurrentList({list})
router.back()
}
</script>

View file

@ -28,18 +28,18 @@ export default {name: 'list-setting-share'}
<script lang="ts" setup>
import {ref, computed, watchEffect} from 'vue'
import {useStore} from '@/store'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@vueuse/core'
import ListService from '@/services/list'
import ListModel from '@/models/list'
import {CURRENT_LIST} from '@/store/mutation-types'
import CreateEdit from '@/components/misc/create-edit.vue'
import LinkSharing from '@/components/sharing/linkSharing.vue'
import userTeam from '@/components/sharing/userTeam.vue'
import {useBaseStore} from '@/stores/base'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
@ -52,7 +52,6 @@ const title = computed(() => list.value?.title
)
useTitle(title)
const store = useStore()
const authStore = useAuthStore()
const configStore = useConfigStore()
@ -62,7 +61,7 @@ const userIsAdmin = computed(() => 'owner' in list.value && list.value.owner.id
async function loadList(listId: number) {
const listService = new ListService()
const newList = await listService.get(new ListModel({id: listId}))
await store.dispatch(CURRENT_LIST, {list: newList})
await useBaseStore().handleSetCurrentList({list: newList})
list.value = newList
}

View file

@ -71,7 +71,6 @@
<script setup lang="ts">
import {computed} from 'vue'
import {useI18n} from 'vue-i18n'
import {useStore} from '@/store'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import ListCard from '@/components/list/partials/list-card.vue'
@ -79,16 +78,16 @@ import ListCard from '@/components/list/partials/list-card.vue'
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
import {useTitle} from '@/composables/useTitle'
import {useStorage} from '@vueuse/core'
import {useNamespaceStore} from '@/stores/namespaces'
const {t} = useI18n()
const store = useStore()
const namespaceStore = useNamespaceStore()
useTitle(() => t('namespace.title'))
const showArchived = useStorage('showArchived', false)
const loading = computed(() => store.state.loading)
const loading = computed(() => namespaceStore.isLoading)
const namespaces = computed(() => {
return namespaceStore.namespaces.filter(n => showArchived.value ? true : !n.isArchived)
// return namespaceStore.namespaces.filter(n => showArchived.value ? true : !n.isArchived).map(n => {

View file

@ -34,21 +34,21 @@
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {useStore} from '@/store'
import {useRoute, useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@vueuse/core'
import Message from '@/components/misc/message.vue'
import {LOGO_VISIBLE} from '@/store/mutation-types'
import {LIST_VIEWS, type ListView} from '@/types/ListView'
import {useBaseStore} from '@/stores/base'
import {useAuthStore} from '@/stores/auth'
const {t} = useI18n({useScope: 'global'})
useTitle(t('sharing.authenticating'))
function useAuth() {
const store = useStore()
const baseStore = useBaseStore()
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
@ -81,7 +81,7 @@ function useAuth() {
const logoVisible = route.query.logoVisible
? route.query.logoVisible === 'true'
: true
store.commit(LOGO_VISIBLE, logoVisible)
baseStore.setLogoVisible(logoVisible)
const view = route.query.view && Object.values(LIST_VIEWS).includes(route.query.view as ListView)
? route.query.view

View file

@ -45,7 +45,6 @@
<script setup lang="ts">
import {computed, ref, watchEffect} from 'vue'
import {useStore} from '@/store'
import {useRoute, useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
@ -56,13 +55,11 @@ import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import SingleTaskInList from '@/components/tasks/partials/singleTaskInList.vue'
import DatepickerWithRange from '@/components/date/datepickerWithRange.vue'
import {DATE_RANGES} from '@/components/date/dateRanges'
import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
import LlamaCool from '@/assets/llama-cool.svg?component'
import type {ITask} from '@/modelTypes/ITask'
import {useAuthStore} from '@/stores/auth'
import {useTaskStore} from '@/stores/tasks'
const store = useStore()
const authStore = useAuthStore()
const taskStore = useTaskStore()
const route = useRoute()
@ -109,7 +106,7 @@ const pageTitle = computed(() => {
})
const hasTasks = computed(() => tasks.value && tasks.value.length > 0)
const userAuthenticated = computed(() => authStore.authenticated)
const loading = computed(() => store.state[LOADING] && store.state[LOADING_MODULE] === 'tasks')
const loading = computed(() => taskStore.isLoading)
interface dateStrings {
dateFrom: string,

View file

@ -1,7 +1,7 @@
<template>
<div :class="{ 'is-loading': taskService.loading, 'visible': visible}" class="loader-container task-view-container">
<div class="task-view">
<heading v-model:task="task" :can-write="canWrite" ref="heading"/>
<Heading v-model:task="task" :can-write="canWrite" ref="heading"/>
<h6 class="subtitle" v-if="parent && parent.namespace && parent.list">
{{ getNamespaceTitle(parent.namespace) }} >
<router-link :to="{ name: 'list.index', params: { listId: parent.list.id } }">
@ -260,7 +260,11 @@
<comments :can-write="canWrite" :task-id="taskId"/>
</div>
<div class="column is-one-third action-buttons d-print-none" v-if="canWrite || shouldShowClosePopup">
<BaseButton @click="$router.back()" class="is-fullwidth is-block has-text-centered mb-4 has-text-primary" v-if="shouldShowClosePopup">
<BaseButton
v-if="shouldShowClosePopup"
@click="$router.back()"
class="is-fullwidth is-block has-text-centered mb-4 has-text-primary"
>
<icon icon="arrow-left"/>
{{ $t('task.detail.closePopup') }}
</BaseButton>
@ -425,348 +429,336 @@
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue'
<script lang="ts" setup>
import {ref, reactive, toRef, shallowReactive, computed, watch, watchEffect, nextTick, type PropType} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {unrefElement} from '@vueuse/core'
import cloneDeep from 'lodash.clonedeep'
import TaskService from '../../services/task'
import TaskService from '@/services/task'
import TaskModel, {TASK_DEFAULT_COLOR} from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import type {IList} from '@/modelTypes/IList'
import { PRIORITIES as priorites } from '@/constants/priorities'
import {RIGHTS as rights} from '@/constants/rights'
import {PRIORITIES} from '@/constants/priorities'
import {RIGHTS} from '@/constants/rights'
import PrioritySelect from '../../components/tasks/partials/prioritySelect.vue'
import PercentDoneSelect from '../../components/tasks/partials/percentDoneSelect.vue'
import EditLabels from '../../components/tasks/partials/editLabels.vue'
import EditAssignees from '../../components/tasks/partials/editAssignees.vue'
import Attachments from '../../components/tasks/partials/attachments.vue'
import RelatedTasks from '../../components/tasks/partials/relatedTasks.vue'
import RepeatAfter from '../../components/tasks/partials/repeatAfter.vue'
import Reminders from '../../components/tasks/partials/reminders.vue'
import Comments from '../../components/tasks/partials/comments.vue'
import ListSearch from '../../components/tasks/partials/listSearch.vue'
import description from '@/components/tasks/partials/description.vue'
import ColorPicker from '../../components/input/colorPicker.vue'
import heading from '@/components/tasks/partials/heading.vue'
import Datepicker from '@/components/input/datepicker.vue'
import BaseButton from '@/components/base/BaseButton.vue'
// partials
import Attachments from '@/components/tasks/partials/attachments.vue'
import ChecklistSummary from '@/components/tasks/partials/checklist-summary.vue'
import ColorPicker from '@/components/input/colorPicker.vue'
import Comments from '@/components/tasks/partials/comments.vue'
import CreatedUpdated from '@/components/tasks/partials/createdUpdated.vue'
import Datepicker from '@/components/input/datepicker.vue'
import Description from '@/components/tasks/partials/description.vue'
import EditAssignees from '@/components/tasks/partials/editAssignees.vue'
import EditLabels from '@/components/tasks/partials/editLabels.vue'
import Heading from '@/components/tasks/partials/heading.vue'
import ListSearch from '@/components/tasks/partials/listSearch.vue'
import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue'
import PrioritySelect from '@/components/tasks/partials/prioritySelect.vue'
import RelatedTasks from '@/components/tasks/partials/relatedTasks.vue'
import Reminders from '@/components/tasks/partials/reminders.vue'
import RepeatAfter from '@/components/tasks/partials/repeatAfter.vue'
import TaskSubscription from '@/components/misc/subscription.vue'
import {CURRENT_LIST} from '@/store/mutation-types'
import {uploadFile} from '@/helpers/attachments'
import ChecklistSummary from '../../components/tasks/partials/checklist-summary.vue'
import CreatedUpdated from '@/components/tasks/partials/createdUpdated.vue'
import { setTitle } from '@/helpers/setTitle'
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
import {getListTitle} from '@/helpers/getListTitle'
import type {IList} from '@/modelTypes/IList'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import {scrollIntoView} from '@/helpers/scrollIntoView'
import {useBaseStore} from '@/stores/base'
import {useNamespaceStore} from '@/stores/namespaces'
import {useAttachmentStore} from '@/stores/attachments'
import {useTaskStore} from '@/stores/tasks'
import {useKanbanStore} from '@/stores/kanban'
function scrollIntoView(el) {
if (!el) {
import {useTitle} from '@/composables/useTitle'
import {success} from '@/message'
const props = defineProps({
taskId: {
type: Number as PropType<ITask['id']>,
required: true,
},
})
defineEmits(['close'])
const route = useRoute()
const router = useRouter()
const {t} = useI18n({useScope: 'global'})
const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore()
const attachmentStore = useAttachmentStore()
const taskStore = useTaskStore()
const kanbanStore = useKanbanStore()
const task = reactive(new TaskModel())
useTitle(toRef(task, 'title'))
// We doubled the task color property here because verte does not have a real change property, leading
// to the color property change being triggered when the # is removed from it, leading to an update,
// which leads in turn to a change... This creates an infinite loop in which the task is updated, changed,
// updated, changed, updated and so on.
// To prevent this, we put the task color property in a seperate value which is set to the task color
// when it is saved and loaded.
const taskColor = ref<ITask['hexColor']>('')
// Used to avoid flashing of empty elements if the task content is not yet loaded.
const visible = ref(false)
const taskId = toRef(props, 'taskId')
const parent = computed(() => {
if (!task.listId) {
return {
namespace: null,
list: null,
}
}
if (!namespaceStore.getListAndNamespaceById) {
return null
}
return namespaceStore.getListAndNamespaceById(task.listId)
})
watch(
parent,
(parent) => {
const parentList = parent !== null ? parent.list : null
if (parentList !== null) {
baseStore.handleSetCurrentList({list: parentList})
}
},
{immediate: true },
)
const canWrite = computed(() => (
task.maxRight !== null &&
task.maxRight > RIGHTS.READ
))
const color = computed(() => {
const color = task.getHexColor
? task.getHexColor()
: false
return color === TASK_DEFAULT_COLOR
? ''
: color
})
const hasAttachments = computed(() => attachmentStore.attachments.length > 0)
// HACK:
const shouldShowClosePopup = computed(() => (route.name as string).includes('kanban'))
function attachmentUpload(...args: any[]) {
return uploadFile(taskId.value, ...args)
}
const heading = ref<HTMLElement | null>(null)
async function scrollToHeading() {
scrollIntoView(unrefElement(heading))
}
const taskService = shallowReactive(new TaskService())
async function loadTask(taskId: ITask['id']) {
if (taskId === undefined) {
return
}
const boundingRect = el.getBoundingClientRect()
const scrollY = window.scrollY
if (
boundingRect.top > (scrollY + window.innerHeight) ||
boundingRect.top < scrollY
) {
el.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
})
try {
Object.assign(task, await taskService.get({id: taskId}))
attachmentStore.set(task.attachments)
taskColor.value = task.hexColor
setActiveFields()
} finally {
await nextTick()
scrollToHeading()
visible.value = true
}
}
export default defineComponent({
name: 'TaskDetailView',
components: {
BaseButton,
CreatedUpdated,
ChecklistSummary,
TaskSubscription,
Datepicker,
ColorPicker,
ListSearch,
Reminders,
RepeatAfter,
RelatedTasks,
Attachments,
EditAssignees,
EditLabels,
PercentDoneSelect,
PrioritySelect,
Comments,
description,
heading,
},
watchEffect(() => taskId.value !== undefined && loadTask(taskId.value))
props: {
taskId: {
type: Number,
required: true,
},
},
type FieldType =
| 'assignees'
| 'attachments'
| 'color'
| 'dueDate'
| 'endDate'
| 'labels'
| 'moveList'
| 'percentDone'
| 'priority'
| 'relatedTasks'
| 'reminders'
| 'repeatAfter'
| 'startDate'
data() {
return {
taskService: new TaskService(),
task: new TaskModel(),
// We doubled the task color property here because verte does not have a real change property, leading
// to the color property change being triggered when the # is removed from it, leading to an update,
// which leads in turn to a change... This creates an infinite loop in which the task is updated, changed,
// updated, changed, updated and so on.
// To prevent this, we put the task color property in a seperate value which is set to the task color
// when it is saved and loaded.
taskColor: '',
const activeFields : {[type in FieldType]: boolean} = reactive({
assignees: false,
attachments: false,
color: false,
dueDate: false,
endDate: false,
labels: false,
moveList: false,
percentDone: false,
priority: false,
relatedTasks: false,
reminders: false,
repeatAfter: false,
startDate: false,
})
showDeleteModal: false,
// Used to avoid flashing of empty elements if the task content is not yet loaded.
visible: false,
TASK_DEFAULT_COLOR,
function setActiveFields() {
// FIXME: are these lines necessary?
// task.startDate = task.startDate || null
// task.endDate = task.endDate || null
activeFields: {
assignees: false,
priority: false,
dueDate: false,
percentDone: false,
startDate: false,
endDate: false,
reminders: false,
repeatAfter: false,
labels: false,
attachments: false,
relatedTasks: false,
moveList: false,
color: false,
},
// Set all active fields based on values in the model
activeFields.assignees = task.assignees.length > 0
activeFields.attachments = task.attachments.length > 0
activeFields.dueDate = task.dueDate !== null
activeFields.endDate = task.endDate !== null
activeFields.labels = task.labels.length > 0
activeFields.percentDone = task.percentDone > 0
activeFields.priority = task.priority !== PRIORITIES.UNSET
activeFields.relatedTasks = Object.keys(task.relatedTasks).length > 0
activeFields.reminders = task.reminderDates.length > 0
activeFields.repeatAfter = task.repeatAfter.amount > 0
activeFields.startDate = task.startDate !== null
}
const activeFieldElements : {[id in FieldType]: HTMLElement | null} = reactive({
assignees: null,
attachments: null,
color: null,
dueDate: null,
endDate: null,
labels: null,
moveList: null,
percentDone: null,
priority: null,
relatedTasks: null,
reminders: null,
repeatAfter: null,
startDate: null,
})
function setFieldActive(fieldName: keyof typeof activeFields) {
activeFields[fieldName] = true
nextTick(() => {
const el = unrefElement(activeFieldElements[fieldName])
if (!el) {
return
}
},
watch: {
taskId: {
handler: 'loadTask',
immediate: true,
},
parent: {
handler(parent) {
const parentList = parent !== null ? parent.list : null
if (parentList !== null) {
this.$store.dispatch(CURRENT_LIST, {list: parentList})
}
},
immediate: true,
},
// Using a watcher here because the header component handles saving the task with the api but we want to decouple
// it from the page title.
'task.title': {
handler(title) {
setTitle(title)
},
},
},
computed: {
currentList() {
return this.$store.state[CURRENT_LIST]
},
parent() {
if (!this.task.listId) {
return {
namespace: null,
list: null,
}
}
el.focus()
const namespaceStore = useNamespaceStore()
// scroll the field to the center of the screen if not in viewport already
scrollIntoView(el)
})
}
if (!namespaceStore.getListAndNamespaceById) {
return null
}
return namespaceStore.getListAndNamespaceById(this.task.listId)
},
canWrite() {
return typeof this.task !== 'undefined' && typeof this.task.maxRight !== 'undefined' && this.task.maxRight > rights.READ
},
hasAttachments() {
const attachmentsStore = useAttachmentStore()
return attachmentsStore.attachments.length > 0
},
shouldShowClosePopup() {
return this.$route.name.includes('kanban')
},
color() {
const color = this.task.getHexColor
? this.task.getHexColor()
: false
return color === TASK_DEFAULT_COLOR
? ''
: color
},
},
methods: {
getNamespaceTitle,
getListTitle,
attachmentUpload(...args) {
return uploadFile(this.taskId, ...args)
},
async loadTask(taskId: ITask['id']) {
if (taskId === undefined) {
return
}
try {
this.task = await this.taskService.get({id: taskId})
const attachmentStore = useAttachmentStore()
attachmentStore.set(this.task.attachments)
this.taskColor = this.task.hexColor
this.setActiveFields()
await this.$nextTick()
setTitle(this.task.title)
} finally {
this.scrollToHeading()
await this.$nextTick()
this.visible = true
}
},
scrollToHeading() {
if(!this.$refs?.heading?.$el) {
return
}
this.$refs.heading.$el.scrollIntoView({block: 'center'})
},
setActiveFields() {
this.task.startDate = this.task.startDate ? this.task.startDate : null
this.task.endDate = this.task.endDate ? this.task.endDate : null
// Set all active fields based on values in the model
this.activeFields.assignees = this.task.assignees.length > 0
this.activeFields.priority = this.task.priority !== priorites.UNSET
this.activeFields.dueDate = this.task.dueDate !== null
this.activeFields.percentDone = this.task.percentDone > 0
this.activeFields.startDate = this.task.startDate !== null
this.activeFields.endDate = this.task.endDate !== null
this.activeFields.reminders = this.task.reminderDates.length > 0
this.activeFields.repeatAfter = this.task.repeatAfter.amount > 0
this.activeFields.labels = this.task.labels.length > 0
this.activeFields.attachments = this.task.attachments.length > 0
this.activeFields.relatedTasks = Object.keys(this.task.relatedTasks).length > 0
},
async saveTask(args?: {
async function saveTask(args?: {
task: ITask,
showNotification?: boolean,
undoCallback?: () => void,
}) {
const {
task,
task: currentTask,
showNotification,
undoCallback,
} = {
...{
task: cloneDeep(this.task),
task: cloneDeep(task),
showNotification: true,
},
...args,
}
if (!canWrite.value) {
return
}
if (!this.canWrite) {
return
}
currentTask.hexColor = taskColor.value
// We're doing the whole update in a nextTick because sometimes race conditions can occur when
// setting the due date on mobile which leads to no due date change being saved.
await this.$nextTick()
// If no end date is being set, but a start date and due date,
// use the due date as the end date
if (
currentTask.endDate === null &&
currentTask.startDate !== null &&
currentTask.dueDate !== null
) {
currentTask.endDate = currentTask.dueDate
}
const newTask = await taskStore.update(currentTask) // TODO: markraw ?
Object.assign(task, newTask)
setActiveFields()
task.hexColor = this.taskColor
if (!showNotification) {
return
}
// If no end date is being set, but a start date and due date,
// use the due date as the end date
if (task.endDate === null && task.startDate !== null && task.dueDate !== null) {
task.endDate = task.dueDate
}
let actions = []
if (undoCallback !== null) {
actions = [{
title: 'Undo',
callback: undoCallback,
}]
}
success({message: t('task.detail.updateSuccess')}, actions)
}
this.task = await useTaskStore().update(task)
const showDeleteModal = ref(false)
async function deleteTask() {
await taskStore.delete(task)
success({message: t('task.detail.deleteSuccess')})
router.push({name: 'list.index', params: {listId: task.listId}})
}
if (!showNotification) {
return
}
function toggleTaskDone() {
const newTask = {
...task,
done: !task.done,
}
let actions = []
if (undoCallback !== undefined) {
actions = [{
title: 'Undo',
callback: undoCallback,
}]
}
this.$message.success({message: this.$t('task.detail.updateSuccess')}, actions)
saveTask({
task: newTask,
undoCallback: toggleTaskDone,
})
}
async function changeList(list: IList) {
kanbanStore.removeTaskInBucket(task)
await saveTask({
task: {
...task,
listId: list.id,
},
})
}
setFieldActive(fieldName) {
this.activeFields[fieldName] = true
this.$nextTick(() => {
const el = this.$refs[fieldName]?.$el
if (!el) {
return
}
el.focus()
// scroll the field to the center of the screen if not in viewport already
scrollIntoView(el)
})
},
async deleteTask() {
await useTaskStore().delete(this.task)
this.$message.success({message: this.$t('task.detail.deleteSuccess')})
this.$router.push({name: 'list.index', params: {listId: this.task.listId}})
},
toggleTaskDone() {
const newTask = {
...this.task,
done: !this.task.done,
}
this.saveTask({
task: newTask,
undoCallback: this.toggleTaskDone,
})
},
async changeList(list: IList) {
this.$store.commit('kanban/removeTaskInBucket', this.task)
await this.saveTask({
task: {
...this.task,
listId: list.id,
},
})
},
async toggleFavorite() {
this.task.isFavorite = !this.task.isFavorite
this.task = await this.taskService.update(this.task)
const namespaceStore = useNamespaceStore()
await namespaceStore.loadNamespacesIfFavoritesDontExist()
},
colorIsDark,
},
})
async function toggleFavorite() {
task.isFavorite = !task.isFavorite
const newTask = await taskService.update(task)
Object.assign(task, newTask)
await namespaceStore.loadNamespacesIfFavoritesDontExist()
}
</script>
<style lang="scss" scoped>

View file

@ -14,14 +14,14 @@
class="input" id="username"
name="username"
:placeholder="$t('user.auth.usernamePlaceholder')"
ref="username"
ref="usernameRef"
required
type="text"
autocomplete="username"
v-focus
@keyup.enter="submit"
tabindex="1"
@focusout="validateField('username')"
@focusout="validateUsernameField()"
/>
</div>
<p class="help is-danger" v-if="!usernameValid">
@ -39,7 +39,7 @@
{{ $t('user.auth.forgotPassword') }}
</router-link>
</div>
<password tabindex="2" @submit="submit" v-model="password" :validate-initially="validatePasswordInitially"/>
<Password tabindex="2" @submit="submit" v-model="password" :validate-initially="validatePasswordInitially"/>
</div>
<div class="field" v-if="needsTotpPasscode">
<label class="label" for="totpPasscode">{{ $t('user.auth.totpTitle') }}</label>
@ -68,7 +68,7 @@
<x-button
@click="submit"
:loading="loading"
:loading="isLoading"
tabindex="4"
>
{{ $t('user.auth.login') }}
@ -101,149 +101,112 @@
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue'
<script setup lang="ts">
import {computed, onBeforeMount, ref} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
import {useDebounceFn} from '@vueuse/core'
import {mapState as mapStateVuex} from 'vuex'
import {mapState} from 'pinia'
import {HTTPFactory} from '@/http-common'
import {LOADING} from '@/store/mutation-types'
import {getErrorText} from '@/message'
import Message from '@/components/misc/message.vue'
import {redirectToProvider} from '../../helpers/redirectToProvider'
import {getLastVisited, clearLastVisited} from '../../helpers/saveLastVisited'
import Password from '@/components/input/password.vue'
import { setTitle } from '@/helpers/setTitle'
import {useConfigStore} from '@/stores/config'
import {getErrorText} from '@/message'
import {redirectToProvider} from '@/helpers/redirectToProvider'
import {getLastVisited, clearLastVisited} from '@/helpers/saveLastVisited'
import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config'
export default defineComponent({
components: {
Password,
Message,
},
data() {
return {
confirmedEmailSuccess: false,
errorMessage: '',
usernameValid: true,
password: '',
validatePasswordInitially: false,
rememberMe: false,
import {useTitle} from '@/composables/useTitle'
const router = useRouter()
const {t} = useI18n({useScope: 'global'})
useTitle(() => t('user.auth.login'))
const authStore = useAuthStore()
const configStore = useConfigStore()
const registrationEnabled = computed(() => configStore.registrationEnabled)
const localAuthEnabled = computed(() => configStore.auth.local.enabled)
const openidConnect = computed(() => configStore.auth.openidConnect)
const hasOpenIdProviders = computed(() => openidConnect.value.enabled && openidConnect.value.providers?.length > 0)
const isLoading = computed(() => authStore.isLoading)
const confirmedEmailSuccess = ref(false)
const errorMessage = ref('')
const password = ref('')
const validatePasswordInitially = ref(false)
const rememberMe = ref(false)
const authenticated = computed(() => authStore.authenticated)
onBeforeMount(() => {
authStore.verifyEmail().then((confirmed) => {
confirmedEmailSuccess.value = confirmed
}).catch((e: Error) => {
errorMessage.value = e.message
})
// Check if the user is already logged in, if so, redirect them to the homepage
if (authenticated.value) {
const last = getLastVisited()
if (last !== null) {
router.push({
name: last.name,
params: last.params,
})
clearLastVisited()
} else {
router.push({name: 'home'})
}
},
beforeMount() {
const HTTP = HTTPFactory()
// Try to verify the email
// FIXME: Why is this here? Can we find a better place for this?
let emailVerifyToken = localStorage.getItem('emailConfirmToken')
if (emailVerifyToken) {
const stopLoading = this.setLoading()
HTTP.post('user/confirm', {token: emailVerifyToken})
.then(() => {
localStorage.removeItem('emailConfirmToken')
this.confirmedEmailSuccess = true
})
.catch(e => {
this.errorMessage = e.response.data.message
})
.finally(stopLoading)
}
// Check if the user is already logged in, if so, redirect them to the homepage
if (this.authenticated) {
const last = getLastVisited()
if (last !== null) {
this.$router.push({
name: last.name,
params: last.params,
})
clearLastVisited()
} else {
this.$router.push({name: 'home'})
}
}
},
created() {
setTitle(this.$t('user.auth.login'))
},
computed: {
hasOpenIdProviders() {
return this.openidConnect.enabled && this.openidConnect.providers?.length > 0
},
...mapStateVuex({
loading: LOADING,
}),
...mapState(useAuthStore, {
needsTotpPasscode: state => state.needsTotpPasscode,
authenticated: state => state.authenticated,
}),
...mapState(useConfigStore, {
registrationEnabled: state => state.registrationEnabled,
localAuthEnabled: state => state.auth.local.enabled,
openidConnect: state => state.auth.openidConnect,
}),
validateField() {
// using computed so that debounced function definition stays
return useDebounceFn((field) => {
this[`${field}Valid`] = this.$refs[field]?.value !== ''
}, 100)
},
},
methods: {
setLoading() {
const timeout = setTimeout(() => {
this.loading = true
}, 100)
return () => {
clearTimeout(timeout)
this.loading = false
}
},
async submit() {
this.errorMessage = ''
// Some browsers prevent Vue bindings from working with autofilled values.
// To work around this, we're manually getting the values here instead of relying on vue bindings.
// For more info, see https://kolaente.dev/vikunja/frontend/issues/78
const credentials = {
username: this.$refs.username.value,
password: this.password,
longToken: this.rememberMe,
}
if (credentials.username === '' || credentials.password === '') {
// Trigger the validation error messages
this.validateField('username')
this.validatePasswordInitially = true
return
}
if (this.needsTotpPasscode) {
credentials.totpPasscode = this.$refs.totpPasscode.value
}
try {
const authStore = useAuthStore()
await authStore.login(credentials)
authStore.setNeedsTotpPasscode(false)
} catch (e) {
if (e.response?.data.code === 1017 && !this.credentials.totpPasscode) {
return
}
const err = getErrorText(e)
this.errorMessage = typeof err[1] !== 'undefined' ? err[1] : err[0]
}
},
redirectToProvider,
},
}
})
const usernameValid = ref(true)
const usernameRef = ref<HTMLInputElement | null>(null)
const validateUsernameField = useDebounceFn(() => {
usernameValid.value = usernameRef.value?.value !== ''
}, 100)
const needsTotpPasscode = computed(() => authStore.needsTotpPasscode)
const totpPasscode = ref<HTMLInputElement | null>(null)
async function submit() {
errorMessage.value = ''
// Some browsers prevent Vue bindings from working with autofilled values.
// To work around this, we're manually getting the values here instead of relying on vue bindings.
// For more info, see https://kolaente.dev/vikunja/frontend/issues/78
const credentials = {
username: usernameRef.value?.value,
password: password.value,
longToken: rememberMe.value,
}
if (credentials.username === '' || credentials.password === '') {
// Trigger the validation error messages
validateUsernameField()
validatePasswordInitially.value = true
return
}
if (needsTotpPasscode.value) {
credentials.totpPasscode = totpPasscode.value?.value
}
try {
await authStore.login(credentials)
authStore.setNeedsTotpPasscode(false)
} catch (e) {
if (e.response?.data.code === 1017 && !credentials.totpPasscode) {
return
}
const err = getErrorText(e)
errorMessage.value = typeof err[1] !== 'undefined' ? err[1] : err[0]
}
}
</script>
<style lang="scss" scoped>

View file

@ -15,13 +15,13 @@ export default { name: 'Auth' }
<script setup lang="ts">
import {ref, computed, onMounted} from 'vue'
import {useStore} from '@/store'
import {useRoute, useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {getErrorText} from '@/message'
import Message from '@/components/misc/message.vue'
import {clearLastVisited, getLastVisited} from '@/helpers/saveLastVisited'
import {useAuthStore} from '@/stores/auth'
const {t} = useI18n({useScope: 'global'})
@ -29,10 +29,9 @@ const {t} = useI18n({useScope: 'global'})
const router = useRouter()
const route = useRoute()
const store = useStore()
const authStore = useAuthStore()
const loading = computed(() => store.state.loading)
const loading = computed(() => authStore.isLoading)
const errorMessage = ref('')
async function authenticateWithCode() {

View file

@ -50,7 +50,7 @@
</div>
<x-button
:loading="loading"
:loading="isLoading"
id="register-submit"
@click="submit"
class="mr-2"
@ -73,10 +73,10 @@ import {useDebounceFn} from '@vueuse/core'
import {ref, reactive, toRaw, computed, onBeforeMount} from 'vue'
import router from '@/router'
import {store} from '@/store'
import Message from '@/components/misc/message.vue'
import {isEmail} from '@/helpers/isEmail'
import Password from '@/components/input/password.vue'
import {useAuthStore} from '@/stores/auth'
const authStore = useAuthStore()
@ -95,7 +95,7 @@ const credentials = reactive({
password: '',
})
const loading = computed(() => store.state.loading)
const isLoading = computed(() => authStore.isLoading)
const errorMessage = ref('')
const validatePasswordInitially = ref(false)

View file

@ -147,19 +147,12 @@
</template>
<script lang="ts">
import {defineComponent} from 'vue'
import { useListStore } from '@/stores/lists'
import { useAuthStore } from '@/stores/auth'
export default defineComponent({
name: 'user-settings-general',
})
export default {name: 'user-settings-general'}
</script>
<script setup lang="ts">
import {computed, watch, ref} from 'vue'
import {useI18n} from 'vue-i18n'
import {useStore} from '@/store'
import {PrefixMode} from '@/modules/parseTaskText'
@ -169,12 +162,15 @@ import {availableLanguages} from '@/i18n'
import {playSoundWhenDoneKey, playPopSound} from '@/helpers/playPop'
import {getQuickAddMagicMode, setQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
import {createRandomID} from '@/helpers/randomId'
import {objectIsEmpty} from '@/helpers/objectIsEmpty'
import {success} from '@/message'
import {AuthenticatedHTTPFactory} from '@/http-common'
import {useColorScheme} from '@/composables/useColorScheme'
import {useTitle} from '@/composables/useTitle'
import {objectIsEmpty} from '@/helpers/objectIsEmpty'
import {useListStore} from '@/stores/lists'
import {useAuthStore} from '@/stores/auth'
const {t} = useI18n({useScope: 'global'})
useTitle(() => `${t('user.settings.general.title')} - ${t('user.settings.title')}`)
@ -227,7 +223,6 @@ function getPlaySoundWhenDoneSetting() {
const playSoundWhenDone = ref(getPlaySoundWhenDoneSetting())
const quickAddMagicMode = ref(getQuickAddMagicMode())
const store = useStore()
const authStore = useAuthStore()
const settings = ref({...authStore.settings})
const id = ref(createRandomID())
@ -256,7 +251,7 @@ const defaultList = computed({
settings.value.defaultListId = l ? l.id : DEFAULT_LIST_ID
},
})
const loading = computed(() => store.state.loading && store.state.loadingModule === 'general-settings')
const loading = computed(() => authStore.isLoadingGeneralSettings)
watch(
playSoundWhenDone,