feat: implement modals with vue router 4 #816

Merged
konrad merged 62 commits from dpschen/frontend:feature/vue3-modals-with-router-4 into main 2022-02-05 16:49:04 +00:00
13 changed files with 314 additions and 411 deletions
Showing only changes of commit c70211ad32 - Show all commits

View File

@ -22,12 +22,9 @@
<router-view :route="routeWithModal"/> <router-view :route="routeWithModal"/>
<!-- TODO: is this still used? --> <transition name="modal">
dpschen marked this conversation as resolved Outdated

I don't think it is, we should make sure the modal views keep their transition though. Might make sense to include that in the modal component itself?

I don't think it is, we should make sure the modal views keep their transition though. Might make sense to include that in the modal component itself?

Either that or put the transition inside something like a provider component. In that we could use the new teleport component. I was always using portal-vue in vue 2 for this kind of stuff.

Either that or put the transition inside something like a provider component. In that we could use the [new teleport component](https://v3.vuejs.org/api/built-in-components.html#teleport). I was always using [portal-vue](https://github.com/LinusBorg/portal-vue) in vue 2 for this kind of stuff.

I think using the teleport component allows for a cleaner solution since there are situations where you want a transition handled by the route and others where you want to have it handled by the outer component (like delete modals).

I think using the teleport component allows for a cleaner solution since there are situations where you want a transition handled by the route and others where you want to have it handled by the outer component (like delete modals).
<router-view name="popup" v-slot="{ Component }"> <component v-if="currentModal" :is="currentModal" />
<transition name="modal"> </transition>
<component :is="Component" />
</transition>
</router-view>
<a <a
class="keyboard-shortcuts-button" class="keyboard-shortcuts-button"
@ -42,7 +39,7 @@
</template> </template>
dpschen marked this conversation as resolved Outdated

I suppose this is used because we don't have a modal wrapper? Using "TaskDetailView" in the general content with component feels a little out of place IMHO 😅

I suppose this is used because we don't have a modal wrapper? Using "TaskDetailView" in the general content with component feels a little out of place IMHO 😅

See it like the router-view conponent of vue router: it's also not a view itself. regardless I just wanted to make the most simple change. I guess it should be renamed to something like modal-view and maybe even merged with modal 🤔 - but that can also happen later.

See it like the router-view conponent of vue router: it's also not a view itself. regardless I just wanted to make the most simple change. I guess it should be renamed to something like modal-view and maybe even merged with modal 🤔 - but that can also happen later.

I removed the component task-detail-view-modal in 6827390b77 and was able to merge it with the modal itself.

As a result this is obsolete now =)

I removed the component `task-detail-view-modal` in https://kolaente.dev/vikunja/frontend/commit/6827390b77ae6e186e7b0163651c19ca9a247d2f and was able to merge it with the modal itself. As a result this is obsolete now =)
<script lang="ts" setup> <script lang="ts" setup>
import {watch, computed} from 'vue' import {watch, computed, shallowRef, watchEffect} from 'vue'
import {useStore} from 'vuex' import {useStore} from 'vuex'
import {useRoute, useRouter} from 'vue-router' import {useRoute, useRouter} from 'vue-router'
import {useEventListener} from '@vueuse/core' import {useEventListener} from '@vueuse/core'
@ -64,7 +61,16 @@ function useRouteWithModal() {
} }
}) })
return { routeWithModal }
const currentModal = shallowRef(null)
watchEffect(() => {
currentModal.value = historyState.value.backgroundView
? route.matched[0]?.components.default
: null
})
return { routeWithModal, currentModal }
} }
useRouteWithModal() useRouteWithModal()

View File

@ -2,21 +2,22 @@
<dropdown> <dropdown>
<template v-if="isSavedFilter"> <template v-if="isSavedFilter">
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.edit`, params: { listId: list.id } }" :to="{ name: 'filter.settings.edit', params: { listId: list.id } }"
icon="pen" icon="pen"
> >
{{ $t('menu.edit') }} {{ $t('menu.edit') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.delete`, params: { listId: list.id } }" :to="{ name: 'filter.settings.delete', params: { listId: list.id } }"
icon="trash-alt" icon="trash-alt"
> >
{{ $t('misc.delete') }} {{ $t('misc.delete') }}
</dropdown-item> </dropdown-item>
</template> </template>
<template v-else-if="list.isArchived"> <template v-else-if="list.isArchived">
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.archive`, params: { listId: list.id } }" :to="{ name: 'list.settings.archive', params: { listId: list.id } }"
icon="archive" icon="archive"
> >
{{ $t('menu.unarchive') }} {{ $t('menu.unarchive') }}
@ -24,32 +25,32 @@
</template> </template>
<template v-else> <template v-else>
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.edit`, params: { listId: list.id } }" :to="{ name: 'list.settings.edit', params: { listId: list.id } }"
icon="pen" icon="pen"
> >
{{ $t('menu.edit') }} {{ $t('menu.edit') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.background`, params: { listId: list.id } }"
v-if="backgroundsEnabled" v-if="backgroundsEnabled"
:to="{ name: 'list.settings.background', params: { listId: list.id } }"
icon="image" icon="image"
> >
{{ $t('menu.setBackground') }} {{ $t('menu.setBackground') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.share`, params: { listId: list.id } }" :to="{ name: 'list.settings.share', params: { listId: list.id } }"
icon="share-alt" icon="share-alt"
> >
{{ $t('menu.share') }} {{ $t('menu.share') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.duplicate`, params: { listId: list.id } }" :to="{ name: 'list.settings.duplicate', params: { listId: list.id } }"
icon="paste" icon="paste"
> >
{{ $t('menu.duplicate') }} {{ $t('menu.duplicate') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.archive`, params: { listId: list.id } }" :to="{ name: 'list.settings.archive', params: { listId: list.id } }"
icon="archive" icon="archive"
> >
{{ $t('menu.archive') }} {{ $t('menu.archive') }}
@ -63,7 +64,7 @@
@change="sub => subscription = sub" @change="sub => subscription = sub"
/> />
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.delete`, params: { listId: list.id } }" :to="{ name: 'list.settings.delete', params: { listId: list.id } }"
icon="trash-alt" icon="trash-alt"
class="has-text-danger" class="has-text-danger"
> >
@ -101,24 +102,7 @@ export default {
}, },
computed: { computed: {
backgroundsEnabled() { backgroundsEnabled() {
return this.$store.state.config.enabledBackgroundProviders !== null && this.$store.state.config.enabledBackgroundProviders.length > 0 return this.$store.state.config.enabledBackgroundProviders?.length > 0
},
listRoutePrefix() {
let name = 'list'
if (this.$route.name !== null && this.$route.name.startsWith('list.')) {
// HACK: we should implement a better routing for the modals
const settingsRoutes = ['edit', 'delete', 'archive', 'background', 'share', 'duplicate']
const suffix = settingsRoutes.find((route) => this.$route.name.endsWith(`.settings.${route}`))
name = this.$route.name.replace(`.settings.${suffix}`,'')
}
if (this.isSavedFilter) {
name = name.replace('list.', 'filter.')
}
return `${name}.settings`
}, },
isSavedFilter() { isSavedFilter() {
return getSavedFilterIdFromListId(this.list.id) > 0 return getSavedFilterIdFromListId(this.list.id) > 0

View File

@ -16,7 +16,7 @@ export const getDefaultParams = () => ({
/** /**
* This mixin provides a base set of methods and properties to get tasks on a list. * This mixin provides a base set of methods and properties to get tasks on a list.
*/ */
export function createTaskList(initTasks) { export function useTaskList(initTasks) {
const taskCollectionService = ref(new TaskCollectionService()) const taskCollectionService = ref(new TaskCollectionService())
dpschen marked this conversation as resolved Outdated

Maybe shallowReactive fits here better?

Maybe shallowReactive fits here better?
const loading = computed(() => taskCollectionService.value.loading) const loading = computed(() => taskCollectionService.value.loading)
const totalPages = computed(() => taskCollectionService.value.totalPages) const totalPages = computed(() => taskCollectionService.value.totalPages)
@ -70,12 +70,14 @@ export function createTaskList(initTasks) {
tasks.value = await taskCollectionService.value.getAll(list, loadParams, page) tasks.value = await taskCollectionService.value.getAll(list, loadParams, page)
currentPage.value = page currentPage.value = page
loadedList.value = JSON.parse(JSON.stringify(currentList)) loadedList.value = JSON.parse(JSON.stringify(currentList))
return tasks.value
} }
async function loadTasksForPage(query) { async function loadTasksForPage(query) {
const { page, search } = query const { page, search } = query
initTasks(params) initTasks(params)
await loadTasks( return await loadTasks(
// The page parameter can be undefined, in the case where the user loads a new list from the side bar menu // The page parameter can be undefined, in the case where the user loads a new list from the side bar menu
typeof page === 'undefined' ? 1 : Number(page), typeof page === 'undefined' ? 1 : Number(page),
search, search,

View File

@ -13,8 +13,8 @@ import DataExportDownload from '../views/user/DataExportDownload'
// Tasks // Tasks
import ShowTasksInRangeComponent from '../views/tasks/ShowTasksInRange' import ShowTasksInRangeComponent from '../views/tasks/ShowTasksInRange'
import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth' import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth'
import TaskDetailView from '../views/tasks/TaskDetailView'
import ListNamespaces from '../views/namespaces/ListNamespaces' import ListNamespaces from '../views/namespaces/ListNamespaces'
import TaskDetailViewModal from '../views/tasks/TaskDetailViewModal'
// Team Handling // Team Handling
import ListTeamsComponent from '../views/teams/ListTeams' import ListTeamsComponent from '../views/teams/ListTeams'
// Label Handling // Label Handling
@ -29,6 +29,7 @@ import Kanban from '../views/list/views/Kanban'
import List from '../views/list/views/List' import List from '../views/list/views/List'
import Gantt from '../views/list/views/Gantt' import Gantt from '../views/list/views/Gantt'
import Table from '../views/list/views/Table' import Table from '../views/list/views/Table'
// List Settings // List Settings
import ListSettingEdit from '../views/list/settings/edit' import ListSettingEdit from '../views/list/settings/edit'
import ListSettingBackground from '../views/list/settings/background' import ListSettingBackground from '../views/list/settings/background'
@ -200,109 +201,123 @@ const router = createRouter({
{ {
path: '/namespaces/new', path: '/namespaces/new',
name: 'namespace.create', name: 'namespace.create',
components: { component: NewNamespaceComponent,
popup: NewNamespaceComponent, meta: {
}, showAsModal: true,
},
{
path: '/namespaces/:id/list',
name: 'list.create',
components: {
popup: NewListComponent,
}, },
}, },
{ {
path: '/namespaces/:id/settings/edit', path: '/namespaces/:id/settings/edit',
name: 'namespace.settings.edit', name: 'namespace.settings.edit',
components: { component: NamespaceSettingEdit,
popup: NamespaceSettingEdit, meta: {
showAsModal: true,
}, },
}, },
{ {
path: '/namespaces/:id/settings/share', path: '/namespaces/:id/settings/share',
name: 'namespace.settings.share', name: 'namespace.settings.share',
components: { component: NamespaceSettingShare,
popup: NamespaceSettingShare, meta: {
showAsModal: true,
}, },
}, },
{ {
path: '/namespaces/:id/settings/archive', path: '/namespaces/:id/settings/archive',
name: 'namespace.settings.archive', name: 'namespace.settings.archive',
components: { component: NamespaceSettingArchive,
popup: NamespaceSettingArchive, meta: {
showAsModal: true,
}, },
}, },
{ {
path: '/namespaces/:id/settings/delete', path: '/namespaces/:id/settings/delete',
name: 'namespace.settings.delete', name: 'namespace.settings.delete',
components: { component: NamespaceSettingDelete,
popup: NamespaceSettingDelete, meta: {
showAsModal: true,
}, },
}, },
{ {
path: '/tasks/:id', path: '/tasks/:id',
name: 'task.detail', name: 'task.detail',
component: TaskDetailView, component: TaskDetailViewModal,
}, },
{ {
path: '/tasks/by/upcoming', path: '/tasks/by/upcoming',
name: 'tasks.range', name: 'tasks.range',
component: ShowTasksInRangeComponent, component: ShowTasksInRangeComponent,
}, },
{
path: '/lists/:id/new',
name: 'list.create',
component: NewListComponent,
meta: {
showAsModal: true,
},
},
{ {
path: '/lists/:listId/settings/edit', path: '/lists/:listId/settings/edit',
name: 'list.settings.edit', name: 'list.settings.edit',
components: { component: ListSettingEdit,
popup: ListSettingEdit, meta: {
showAsModal: true,
}, },
}, },
{ {
path: '/lists/:listId/settings/background', path: '/lists/:listId/settings/background',
name: 'list.settings.background', name: 'list.settings.background',
components: { component: ListSettingBackground,
popup: ListSettingBackground, meta: {
showAsModal: true,
}, },
}, },
{ {
path: '/lists/:listId/settings/duplicate', path: '/lists/:listId/settings/duplicate',
name: 'list.settings.duplicate', name: 'list.settings.duplicate',
components: { component: ListSettingDuplicate,
popup: ListSettingDuplicate, meta: {
showAsModal: true,
}, },
}, },
{ {
path: '/lists/:listId/settings/share', path: '/lists/:listId/settings/share',
name: 'list.settings.share', name: 'list.settings.share',
components: { component: ListSettingShare,
popup: ListSettingShare, meta: {
showAsModal: true,
}, },
}, },
{ {
path: '/lists/:listId/settings/delete', path: '/lists/:listId/settings/delete',
name: 'list.settings.delete', name: 'list.settings.delete',
components: { component: ListSettingDelete,
popup: ListSettingDelete, meta: {
showAsModal: true,
}, },
}, },
{ {
path: '/lists/:listId/settings/archive', path: '/lists/:listId/settings/archive',
name: 'list.settings.archive', name: 'list.settings.archive',
components: { component: ListSettingArchive,
popup: ListSettingArchive, meta: {
showAsModal: true,
}, },
}, },
{ {
path: '/lists/:listId/settings/edit', path: '/lists/:listId/settings/edit',
name: 'filter.settings.edit', name: 'filter.settings.edit',
components: { component: FilterEdit,
popup: FilterEdit, meta: {
showAsModal: true,
}, },
}, },
{ {
path: '/lists/:listId/settings/delete', path: '/lists/:listId/settings/delete',
name: 'filter.settings.delete', name: 'filter.settings.delete',
components: { component: FilterDelete,
popup: FilterDelete, meta: {
showAsModal: true,
}, },
}, },
{ {
@ -340,8 +355,9 @@ const router = createRouter({
{ {
path: '/teams/new', path: '/teams/new',
name: 'teams.create', name: 'teams.create',
components: { component: NewTeamComponent,
popup: NewTeamComponent, meta: {
showAsModal: true,
}, },
}, },
{ {
@ -357,8 +373,9 @@ const router = createRouter({
{ {
path: '/labels/new', path: '/labels/new',
name: 'labels.create', name: 'labels.create',
components: { component: NewLabelComponent,
popup: NewLabelComponent, meta: {
showAsModal: true,
}, },
}, },
{ {
@ -374,8 +391,9 @@ const router = createRouter({
{ {
path: '/filters/new', path: '/filters/new',
name: 'filters.create', name: 'filters.create',
components: { component: FilterNew,
popup: FilterNew, meta: {
showAsModal: true,
}, },
}, },
{ {

View File

@ -1,12 +1,12 @@
import {HTTPFactory} from '@/http-common' import {HTTPFactory} from '@/http-common'
import {getCurrentLanguage, saveLanguage} from '@/i18n' import {i18n, getCurrentLanguage, saveLanguage} from '@/i18n'
import {LOADING} from '../mutation-types' import {LOADING} from '../mutation-types'
import UserModel from '@/models/user' import UserModel from '@/models/user'
import UserSettingsService from '@/services/userSettings' import UserSettingsService from '@/services/userSettings'
import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth' import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth'
import {setLoading} from '@/store/helper' import {setLoading} from '@/store/helper'
import {i18n} from '@/i18n'
import {success} from '@/message' import {success} from '@/message'
import {redirectToProvider} from '@/helpers/redirectToProvider'
const AUTH_TYPES = { const AUTH_TYPES = {
'UNKNOWN': 0, 'UNKNOWN': 0,
@ -201,7 +201,19 @@ export default {
ctx.commit('authenticated', authenticated) ctx.commit('authenticated', authenticated)
if (!authenticated) { if (!authenticated) {
ctx.commit('info', null) ctx.commit('info', null)
ctx.dispatch('config/redirectToProviderIfNothingElseIsEnabled', null, {root: true}) ctx.dispatch('redirectToProviderIfNothingElseIsEnabled')
}
},
redirectToProviderIfNothingElseIsEnabled({rootState}) {
const {auth} = rootState.config
if (
auth.local.enabled === false &&
auth.openidConnect.enabled &&
auth.openidConnect.providers?.length === 1 &&
window.location.pathname.startsWith('/login') // Kinda hacky, but prevents an endless loop.
) {
redirectToProvider(auth.openidConnect.providers[0], auth.openidConnect.redirectUrl)
} }
}, },

View File

@ -1,7 +1,6 @@
import {CONFIG} from '../mutation-types' import {CONFIG} from '../mutation-types'
import {HTTPFactory} from '@/http-common' import {HTTPFactory} from '@/http-common'
import {objectToCamelCase} from '@/helpers/case' import {objectToCamelCase} from '@/helpers/case'
import {redirectToProvider} from '../../helpers/redirectToProvider'
import {parseURL} from 'ufo' import {parseURL} from 'ufo'
export default { export default {
@ -75,16 +74,5 @@ export default {
ctx.commit(CONFIG, info) ctx.commit(CONFIG, info)
return info return info
}, },
redirectToProviderIfNothingElseIsEnabled(ctx) {
if (ctx.state.auth.local.enabled === false &&
ctx.state.auth.openidConnect.enabled &&
ctx.state.auth.openidConnect.providers &&
ctx.state.auth.openidConnect.providers.length === 1 &&
window.location.pathname.startsWith('/login') // Kinda hacky, but prevents an endless loop.
) {
redirectToProvider(ctx.state.auth.openidConnect.providers[0])
}
},
}, },
} }

View File

@ -51,10 +51,6 @@
</div> </div>
</div> </div>
<ShowTasks class="mt-4" :show-all="true" v-if="hasLists" :key="showTasksKey"/> <ShowTasks class="mt-4" :show-all="true" v-if="hasLists" :key="showTasksKey"/>
<transition name="modal">
<task-detail-view-modal v-if="showTaskDetail" />
</transition>
</div> </div>
</template> </template>
@ -71,9 +67,6 @@ import {getHistory} from '@/modules/listHistory'
import {parseDateOrNull} from '@/helpers/parseDateOrNull' import {parseDateOrNull} from '@/helpers/parseDateOrNull'
import {formatDateShort, formatDateSince} from '@/helpers/time/formatDate' import {formatDateShort, formatDateSince} from '@/helpers/time/formatDate'
import {useDateTimeSalutation} from '@/composables/useDateTimeSalutation' import {useDateTimeSalutation} from '@/composables/useDateTimeSalutation'
import TaskDetailViewModal, { useShowModal } from '@/views/tasks/TaskDetailViewModal.vue'
const showTaskDetail = useShowModal()
const welcome = useDateTimeSalutation() const welcome = useDateTimeSalutation()

View File

@ -9,153 +9,128 @@
v-shortcut="'g l'" v-shortcut="'g l'"
:title="$t('keyboardShortcuts.list.switchToListView')" :title="$t('keyboardShortcuts.list.switchToListView')"
:class="{'is-active': $route.name === 'list.list'}" :class="{'is-active': $route.name === 'list.list'}"
:to="{ name: 'list.list', params: { listId: listId } }"> :to="{ name: 'list.list', params: { listId } }">
{{ $t('list.list.title') }} {{ $t('list.list.title') }}
</router-link> </router-link>
<router-link <router-link
v-shortcut="'g g'" v-shortcut="'g g'"
:title="$t('keyboardShortcuts.list.switchToGanttView')" :title="$t('keyboardShortcuts.list.switchToGanttView')"
:class="{'is-active': $route.name === 'list.gantt'}" :class="{'is-active': $route.name === 'list.gantt'}"
:to="{ name: 'list.gantt', params: { listId: listId } }"> :to="{ name: 'list.gantt', params: { listId } }">
{{ $t('list.gantt.title') }} {{ $t('list.gantt.title') }}
</router-link> </router-link>
<router-link <router-link
v-shortcut="'g t'" v-shortcut="'g t'"
:title="$t('keyboardShortcuts.list.switchToTableView')" :title="$t('keyboardShortcuts.list.switchToTableView')"
:class="{'is-active': $route.name === 'list.table'}" :class="{'is-active': $route.name === 'list.table'}"
:to="{ name: 'list.table', params: { listId: listId } }"> :to="{ name: 'list.table', params: { listId } }">
{{ $t('list.table.title') }} {{ $t('list.table.title') }}
</router-link> </router-link>
<router-link <router-link
v-shortcut="'g k'" v-shortcut="'g k'"
:title="$t('keyboardShortcuts.list.switchToKanbanView')" :title="$t('keyboardShortcuts.list.switchToKanbanView')"
:class="{'is-active': $route.name === 'list.kanban'}" :class="{'is-active': $route.name === 'list.kanban'}"
:to="{ name: 'list.kanban', params: { listId: listId } }"> :to="{ name: 'list.kanban', params: { listId } }">
{{ $t('list.kanban.title') }} {{ $t('list.kanban.title') }}
</router-link> </router-link>
</div> </div>
</div> </div>
<transition name="fade"> <transition name="fade">
<message variant="warning" v-if="currentList.isArchived" class="mb-4"> <Message variant="warning" v-if="currentList.isArchived" class="mb-4">
{{ $t('list.archived') }} {{ $t('list.archived') }}
</message> </Message>
</transition> </transition>
<router-view/> <router-view/>
</div> </div>
</template> </template>
<script> <script setup>
import {ref, shallowRef, computed, watchEffect} from 'vue'
import {useRouter, useRoute} from 'vue-router'
import Message from '@/components/misc/message' import Message from '@/components/misc/message'
import ListModel from '../../models/list'
import ListService from '../../services/list'
import {CURRENT_LIST} from '../../store/mutation-types'
import {getListView} from '../../helpers/saveListView'
import {saveListToHistory} from '../../modules/listHistory'
export default { import ListModel from '@/models/list'
components: {Message}, import ListService from '@/services/list'
data() {
return {
listService: new ListService(),
listLoaded: 0,
}
},
watch: {
// call again the method if the route changes
'$route.path': {
handler: 'loadList',
immediate: true,
},
},
computed: {
// Computed property to let "listId" always have a value
listId() {
return typeof this.$route.params.listId === 'undefined' ? 0 : this.$route.params.listId
},
background() {
return this.$store.state.background
},
currentList() {
return typeof this.$store.state.currentList === 'undefined' ? {
id: 0,
title: '',
isArchived: false,
} : this.$store.state.currentList
},
},
methods: {
replaceListView() {
const savedListView = getListView(this.$route.params.listId)
this.$router.replace({name: savedListView, params: {id: this.$route.params.listId}})
console.debug('Replaced list view with', savedListView)
},
async loadList() { import {store} from '@/store'
if (this.$route.name.includes('.settings.')) { import {CURRENT_LIST} from '@/store/mutation-types'
return
}
const listData = {id: parseInt(this.$route.params.listId)} import {getListView} from '@/helpers/saveListView'
import {getListTitle} from '@/helpers/getListTitle'
import {saveListToHistory} from '@/modules/listHistory'
import { useTitle } from '@/composables/useTitle'
saveListToHistory(listData) const route = useRoute()
const router = useRouter()
this.setTitle(this.currentList.id ? this.getListTitle(this.currentList) : '') const listService = shallowRef(new ListService())
const loadedListId = ref(0)
// This invalidates the loaded list at the kanban board which lets it reload its content when // beforeRouteEnter(to) {
// switched to it. This ensures updates done to tasks in the gantt or list views are consistently // Redirect the user to list view by default
// shown in all views while preventing reloads when closing a task popup. if (route.name !== 'list.index') {
// We don't do this for the table view because that does not change tasks. const savedListView = getListView(route.params.listId)
if ( console.debug('Replaced list view with', savedListView)
this.$route.name === 'list.list' || router.replace({name: 'list.list', params: {id: route.params.listId}})
this.$route.name === 'list.gantt' }
) { // },
this.$store.commit('kanban/setListId', 0)
}
// // When clicking again on a list in the menu, there would be no list view selected which means no list const currentList = computed(() => {
// // at all. Users will then have to click on the list view menu again which is quite confusing. return typeof store.state.currentList === 'undefined' ? {
// if (this.$route.name === 'list.index') { id: 0,
// return this.replaceListView() title: '',
// } isArchived: false,
} : store.state.currentList
})
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and // Computed property to let "listId" always have a value
// the currently loaded list has the right set. const listId = computed(() => typeof route.params.listId === 'undefined' ? 0 : parseInt(route.params.listId))
if ( // call again the method if the listId changes
( watchEffect(() => loadList(listId.value))
this.$route.params.listId === this.listLoaded ||
typeof this.$route.params.listId === 'undefined' ||
this.$route.params.listId === this.currentList.id ||
parseInt(this.$route.params.listId) === this.currentList.id
)
&& typeof this.currentList !== 'undefined' && this.currentList.maxRight !== null
) {
return
}

Remove this

Remove this
// Redirect the user to list view by default useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '')
if (
this.$route.name !== 'list.list' &&
this.$route.name !== 'list.gantt' &&
this.$route.name !== 'list.table' &&
this.$route.name !== 'list.kanban'
) {
return this.replaceListView()
}
console.debug(`Loading list, $route.name = ${this.$route.name}, $route.params =`, this.$route.params, `, listLoaded = ${this.listLoaded}, currentList = `, this.currentList) async function loadList(listId) {
const listData = {id: listId}
saveListToHistory(listData)
// We create an extra list object instead of creating it in this.list because that would trigger a ui update which would result in bad ux. // This invalidates the loaded list at the kanban board which lets it reload its content when
const list = new ListModel(listData) // switched to it. This ensures updates done to tasks in the gantt or list views are consistently
try { // shown in all views while preventing reloads when closing a task popup.
const loadedList = await this.listService.get(list) // We don't do this for the table view because that does not change tasks.
await this.$store.dispatch(CURRENT_LIST, loadedList) // FIXME: remove this
this.setTitle(this.getListTitle(loadedList)) if (
} finally { route.name === 'list.list' ||
this.listLoaded = this.$route.params.listId route.name === 'list.gantt'
} ) {
}, store.commit('kanban/setListId', 0)
}, }
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
// the currently loaded list has the right set.
if (
(
listId.value === loadedListId.value ||
typeof listId.value === 'undefined' ||
listId.value === currentList.value.id
)
&& typeof currentList.value !== 'undefined' && currentList.value.maxRight !== null
) {
return
}
console.debug(`Loading list, $route.name = ${route.name}, $route.params =`, route.params, `, loadedListId = ${loadedListId.value}, currentList = `, currentList.value)
// 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, loadedList)
} finally {
loadedListId.value = listId
}
} }
</script> </script>

View File

@ -52,61 +52,43 @@
:show-taskswithout-dates="showTaskswithoutDates" :show-taskswithout-dates="showTaskswithoutDates"
/> />
<transition name="modal">
<task-detail-view-modal v-if="showTaskDetail" />
</transition>
</card> </card>
</div> </div>
</template> </template>
<script> <script setup>
import GanttChart from '../../../components/tasks/gantt-component' import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import flatPickr from 'vue-flatpickr-component' import flatPickr from 'vue-flatpickr-component'
import Fancycheckbox from '../../../components/input/fancycheckbox'
import { i18n } from '@/i18n'
import { store } from '@/store'
import GanttChart from '@/components/tasks/gantt-component'
import Fancycheckbox from '@/components/input/fancycheckbox'
import {saveListView} from '@/helpers/saveListView' import {saveListView} from '@/helpers/saveListView'
import TaskDetailViewModal, { useShowModal } from '@/views/tasks/TaskDetailViewModal.vue' const route = useRoute()
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
saveListView(route.params.listId, route.name)
export default { const showTaskswithoutDates = ref(false)
name: 'Gantt', const dayWidth = ref(35)
components: { const dateFrom = ref(new Date((new Date()).setDate((new Date()).getDate() - 15)))
Fancycheckbox, const dateTo = ref(new Date((new Date()).setDate((new Date()).getDate() + 30)))
flatPickr,
GanttChart,
TaskDetailViewModal, const flatPickerConfig = computed(() => ({
altFormat: i18n.global.t('date.altFormatShort'),
altInput: true,
dateFormat: 'Y-m-d',
enableTime: false,
locale: {
firstDayOfWeek: store.state.auth.settings.weekStart,
}, },
setup() { }))
return {
showTaskDetail: useShowModal(),
}
},
created() {
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
saveListView(this.$route.params.listId, this.$route.name)
},
data() {
return {
showTaskswithoutDates: false,
dayWidth: 35,
dateFrom: new Date((new Date()).setDate((new Date()).getDate() - 15)),
dateTo: new Date((new Date()).setDate((new Date()).getDate() + 30)),
}
},
computed: {
flatPickerConfig() {
return {
altFormat: this.$t('date.altFormatShort'),
altInput: true,
dateFormat: 'Y-m-d',
enableTime: false,
locale: {
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
},
}
},
},
}
</script> </script>
<style lang="scss"> <style lang="scss">

View File

@ -205,9 +205,8 @@
</div> </div>
<transition name="modal"> <transition name="modal">
<task-detail-view-modal v-if="showTaskDetail" />
<modal <modal
v-else-if="showBucketDeleteModal" v-if="showBucketDeleteModal"
@close="showBucketDeleteModal = false" @close="showBucketDeleteModal = false"
@submit="deleteBucket()" @submit="deleteBucket()"
> >
@ -236,7 +235,6 @@ import Dropdown from '@/components/misc/dropdown.vue'
import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState' import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState'
import {calculateItemPosition} from '../../../helpers/calculateItemPosition' import {calculateItemPosition} from '../../../helpers/calculateItemPosition'
import KanbanCard from '@/components/tasks/partials/kanban-card' import KanbanCard from '@/components/tasks/partials/kanban-card'
import TaskDetailViewModal, { useShowModal } from '@/views/tasks/TaskDetailViewModal.vue'
const DRAG_OPTIONS = { const DRAG_OPTIONS = {
// sortable options // sortable options
@ -256,7 +254,6 @@ export default {
Dropdown, Dropdown,
FilterPopup, FilterPopup,
draggable, draggable,
TaskDetailViewModal,
}, },
data() { data() {
return { return {
@ -293,12 +290,6 @@ export default {
} }
}, },
setup() {
return {
showTaskDetail: useShowModal(),
}
},
created() { created() {
// Save the current list view to local storage // 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 vuex here to make it persistent across reloads.

View File

@ -122,10 +122,6 @@
:current-page="currentPage" :current-page="currentPage"
/> />
</card> </card>
<transition name="modal">
<task-detail-view-modal v-if="showTaskDetail" />
</transition>
</div> </div>
</template> </template>
@ -133,13 +129,10 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import TaskService from '../../../services/task'
import TaskModel from '../../../models/task'
import EditTask from '../../../components/tasks/edit-task' import EditTask from '../../../components/tasks/edit-task'
import AddTask from '../../../components/tasks/add-task' import AddTask from '../../../components/tasks/add-task'
import SingleTaskInList from '../../../components/tasks/partials/singleTaskInList' import SingleTaskInList from '../../../components/tasks/partials/singleTaskInList'
import { createTaskList } from '@/composables/taskList' import { useTaskList } from '@/composables/taskList'
import {saveListView} from '@/helpers/saveListView' import {saveListView} from '@/helpers/saveListView'
import Rights from '../../../models/constants/rights.json' import Rights from '../../../models/constants/rights.json'
import FilterPopup from '@/components/list/partials/filter-popup.vue' import FilterPopup from '@/components/list/partials/filter-popup.vue'
@ -147,7 +140,6 @@ import {HAS_TASKS} from '@/store/mutation-types'
import Nothing from '@/components/misc/nothing.vue' import Nothing from '@/components/misc/nothing.vue'
import Pagination from '@/components/misc/pagination.vue' import Pagination from '@/components/misc/pagination.vue'
import {ALPHABETICAL_SORT} from '@/components/list/partials/filters.vue' import {ALPHABETICAL_SORT} from '@/components/list/partials/filters.vue'
import TaskDetailViewModal, { useShowModal } from '@/views/tasks/TaskDetailViewModal.vue'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import {calculateItemPosition} from '../../../helpers/calculateItemPosition' import {calculateItemPosition} from '../../../helpers/calculateItemPosition'
@ -174,7 +166,6 @@ export default {
name: 'List', name: 'List',
data() { data() {
return { return {
taskService: new TaskService(),
ctaVisible: false, ctaVisible: false,
showTaskSearch: false, showTaskSearch: false,
@ -193,11 +184,10 @@ export default {
AddTask, AddTask,
draggable, draggable,
Pagination, Pagination,
TaskDetailViewModal,
}, },
setup() { setup() {
const taskEditTask = ref(TaskModel) const taskEditTask = ref(null)
const isTaskEdit = ref(false) const isTaskEdit = ref(false)
// This function initializes the tasks page and loads the first page of tasks // This function initializes the tasks page and loads the first page of tasks
@ -206,17 +196,18 @@ export default {
isTaskEdit.value = false isTaskEdit.value = false
} }
const taskList = createTaskList(beforeLoad) const taskList = useTaskList(beforeLoad)
// Save the current list view to local storage // 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 vuex here to make it persistent across reloads.
const route = useRoute() const route = useRoute()
saveListView(route.params.listId, route.name) saveListView(route.params.listId, route.name)
taskList.initTaskList()
return { return {
taskEditTask, taskEditTask,
isTaskEdit, isTaskEdit,
showTaskDetail: useShowModal(),
...taskList, ...taskList,
} }
}, },

View File

@ -68,19 +68,19 @@
<tr> <tr>
<th v-if="activeColumns.id"> <th v-if="activeColumns.id">
# #
<sort :order="sortBy.id" @click="sort('id')"/> <Sort :order="sortBy.id" @click="sort('id')"/>
</th> </th>
<th v-if="activeColumns.done"> <th v-if="activeColumns.done">
{{ $t('task.attributes.done') }} {{ $t('task.attributes.done') }}
<sort :order="sortBy.done" @click="sort('done')"/> <Sort :order="sortBy.done" @click="sort('done')"/>
</th> </th>
<th v-if="activeColumns.title"> <th v-if="activeColumns.title">
{{ $t('task.attributes.title') }} {{ $t('task.attributes.title') }}
<sort :order="sortBy.title" @click="sort('title')"/> <Sort :order="sortBy.title" @click="sort('title')"/>
</th> </th>
<th v-if="activeColumns.priority"> <th v-if="activeColumns.priority">
{{ $t('task.attributes.priority') }} {{ $t('task.attributes.priority') }}
<sort :order="sortBy.priority" @click="sort('priority')"/> <Sort :order="sortBy.priority" @click="sort('priority')"/>
</th> </th>
<th v-if="activeColumns.labels"> <th v-if="activeColumns.labels">
{{ $t('task.attributes.labels') }} {{ $t('task.attributes.labels') }}
@ -90,27 +90,27 @@
</th> </th>
<th v-if="activeColumns.dueDate"> <th v-if="activeColumns.dueDate">
{{ $t('task.attributes.dueDate') }} {{ $t('task.attributes.dueDate') }}
<sort :order="sortBy.due_date" @click="sort('due_date')"/> <Sort :order="sortBy.due_date" @click="sort('due_date')"/>
</th> </th>
<th v-if="activeColumns.startDate"> <th v-if="activeColumns.startDate">
{{ $t('task.attributes.startDate') }} {{ $t('task.attributes.startDate') }}
<sort :order="sortBy.start_date" @click="sort('start_date')"/> <Sort :order="sortBy.start_date" @click="sort('start_date')"/>
</th> </th>
<th v-if="activeColumns.endDate"> <th v-if="activeColumns.endDate">
{{ $t('task.attributes.endDate') }} {{ $t('task.attributes.endDate') }}
<sort :order="sortBy.end_date" @click="sort('end_date')"/> <Sort :order="sortBy.end_date" @click="sort('end_date')"/>
</th> </th>
<th v-if="activeColumns.percentDone"> <th v-if="activeColumns.percentDone">
{{ $t('task.attributes.percentDone') }} {{ $t('task.attributes.percentDone') }}
<sort :order="sortBy.percent_done" @click="sort('percent_done')"/> <Sort :order="sortBy.percent_done" @click="sort('percent_done')"/>
</th> </th>
<th v-if="activeColumns.created"> <th v-if="activeColumns.created">
{{ $t('task.attributes.created') }} {{ $t('task.attributes.created') }}
<sort :order="sortBy.created" @click="sort('created')"/> <Sort :order="sortBy.created" @click="sort('created')"/>
</th> </th>
<th v-if="activeColumns.updated"> <th v-if="activeColumns.updated">
{{ $t('task.attributes.updated') }} {{ $t('task.attributes.updated') }}
<sort :order="sortBy.updated" @click="sort('updated')"/> <Sort :order="sortBy.updated" @click="sort('updated')"/>
</th> </th>
<th v-if="activeColumns.createdBy"> <th v-if="activeColumns.createdBy">
{{ $t('task.attributes.createdBy') }} {{ $t('task.attributes.createdBy') }}
@ -173,22 +173,13 @@
:current-page="currentPage" :current-page="currentPage"
/> />
</card> </card>
<!-- This router view is used to show the task popup while keeping the table view itself -->
<router-view v-slot="{ Component }">
<transition name="modal">
<component :is="Component" />
</transition>
</router-view>
</div> </div>
</template> </template>
<script> <script setup>
import { defineComponent, ref, reactive, computed, toRaw } from 'vue' import { ref, reactive, computed, toRaw } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { createTaskList } from '@/composables/taskList'
import Done from '@/components/misc/Done.vue' import Done from '@/components/misc/Done.vue'
import User from '@/components/misc/user' import User from '@/components/misc/user'
import PriorityLabel from '@/components/tasks/partials/priorityLabel' import PriorityLabel from '@/components/tasks/partials/priorityLabel'
@ -196,11 +187,13 @@ import Labels from '@/components/tasks/partials/labels'
import DateTableCell from '@/components/tasks/partials/date-table-cell' import DateTableCell from '@/components/tasks/partials/date-table-cell'
import Fancycheckbox from '@/components/input/fancycheckbox' import Fancycheckbox from '@/components/input/fancycheckbox'
import Sort from '@/components/tasks/partials/sort' import Sort from '@/components/tasks/partials/sort'
import {saveListView} from '@/helpers/saveListView'
import FilterPopup from '@/components/list/partials/filter-popup.vue' import FilterPopup from '@/components/list/partials/filter-popup.vue'
import Pagination from '@/components/misc/pagination.vue' import Pagination from '@/components/misc/pagination.vue'
import Popup from '@/components/misc/popup' import Popup from '@/components/misc/popup'
import { useTaskList } from '@/composables/taskList'
import {saveListView} from '@/helpers/saveListView'
const ACTIVE_COLUMNS_DEFAULT = { const ACTIVE_COLUMNS_DEFAULT = {
id: true, id: true,
done: true, done: true,
@ -233,102 +226,86 @@ function useSavedView(activeColumns, sortBy) {
} }
} }
export default defineComponent({ const activeColumns = reactive({ ...ACTIVE_COLUMNS_DEFAULT })
name: 'Table', const sortBy = ref({ ...SORT_BY_DEFAULT })
components: {
Popup,
Done,
FilterPopup,
Sort,
Fancycheckbox,
DateTableCell,
Labels,
PriorityLabel,
User,
Pagination,
},
setup() {
const activeColumns = reactive({ ...ACTIVE_COLUMNS_DEFAULT })
const sortBy = ref({ ...SORT_BY_DEFAULT })
useSavedView(activeColumns, sortBy) useSavedView(activeColumns, sortBy)
function beforeLoad(params) { function beforeLoad(params) {
// This makes sure an id sort order is always sorted last. // This makes sure an id sort order is always sorted last.
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes // When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
// precedence over everything else, making any other sort columns pretty useless. // precedence over everything else, making any other sort columns pretty useless.
let hasIdFilter = false let hasIdFilter = false
const sortKeys = Object.keys(sortBy.value) const sortKeys = Object.keys(sortBy.value)
for (const s of sortKeys) { for (const s of sortKeys) {
if (s === 'id') { if (s === 'id') {
sortKeys.splice(s, 1) sortKeys.splice(s, 1)
hasIdFilter = true hasIdFilter = true
break break
}
}
if (hasIdFilter) {
sortKeys.push('id')
}
params.value.sort_by = sortKeys
params.value.order_by = sortKeys.map(s => sortBy.value[s])
} }
}
if (hasIdFilter) {
sortKeys.push('id')
}
params.value.sort_by = sortKeys
params.value.order_by = sortKeys.map(s => sortBy.value[s])
}
const taskList = createTaskList(beforeLoad) const {
tasks,
loading,
showTaskFilter,
params,
loadTasks,
totalPages,
currentPage,
searchTerm,
initTaskList,
} = useTaskList(beforeLoad)
Object.assign(taskList.params.value, { Object.assign(params.value, {
filter_by: [], filter_by: [],
filter_value: [], filter_value: [],
filter_comparator: [], filter_comparator: [],
})
const router = useRouter()
const taskDetailRoutes = computed(() => Object.fromEntries(
taskList.tasks.value.map(({id}) => ([
id,
{
name: 'task.detail',
params: { id },
state: { backgroundView: router.currentRoute.value.fullPath },
},
])),
))
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
const route = useRoute()
saveListView(route.params.listId, route.name)
function sort(property) {
const order = sortBy.value[property]
if (typeof order === 'undefined' || order === 'none') {
sortBy.value[property] = 'desc'
} else if (order === 'desc') {
sortBy.value[property] = 'asc'
} else {
delete sortBy.value[property]
}
beforeLoad(taskList.currentPage.value, taskList.searchTerm.value)
// Save the order to be able to retrieve them later
localStorage.setItem('tableViewSortBy', JSON.stringify(sortBy.value))
}
function saveTaskColumns() {
localStorage.setItem('tableViewColumns', JSON.stringify(toRaw(activeColumns)))
}
taskList.initTaskList()
return {
...taskList,
sortBy,
activeColumns,
sort,
saveTaskColumns,
taskDetailRoutes,
}
},
}) })
const router = useRouter()
const taskDetailRoutes = computed(() => Object.fromEntries(
tasks.value.map(({id}) => ([
id,
{
name: 'task.detail',
params: { id },
state: { backgroundView: router.currentRoute.value.fullPath },
},
])),
))
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
const route = useRoute()
saveListView(route.params.listId, route.name)
function sort(property) {
const order = sortBy.value[property]
if (typeof order === 'undefined' || order === 'none') {
sortBy.value[property] = 'desc'
} else if (order === 'desc') {
sortBy.value[property] = 'asc'
} else {
delete sortBy.value[property]
}
beforeLoad(currentPage.value, searchTerm.value)
// Save the order to be able to retrieve them later
localStorage.setItem('tableViewSortBy', JSON.stringify(sortBy.value))
}
function saveTaskColumns() {
localStorage.setItem('tableViewColumns', JSON.stringify(toRaw(activeColumns)))
}
initTaskList()
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -4,35 +4,19 @@
variant="scrolling" variant="scrolling"
class="task-detail-view-modal" class="task-detail-view-modal"
> >
<a @click="close()" class="close"> <a @click="close()" class="close">
<icon icon="times"/> <icon icon="times"/>
</a> </a>
<task-detail-view/> <task-detail-view/>
</modal> </modal>
</template> </template>
<script> <script setup>
import TaskDetailView from './TaskDetailView' import TaskDetailView from './TaskDetailView'
import {computed} from 'vue' import router from '@/router'
import {useRoute} from 'vue-router'
export function useShowModal() { function close() {
const route = useRoute() router.back()
const historyState = computed(() => route.fullPath && window.history.state)
const show = computed(() => historyState.value.backgroundView)
return show
}
export default {
name: 'TaskDetailViewModal',
components: {
TaskDetailView,
},
methods: {
close() {
this.$router.back()
},
},
} }
</script> </script>